= Référence de Singe Scott Duensing :revnumber: 3.00 :revdate: 2026 :doctype: book :toc: left :toclevels: 3 :sectnums: :sectnumlevels: 3 :title-logo-image: image:images/singeLogo.png[Singe, pdfwidth=2.6in, align=center] :source-highlighter: rouge :icons: font :experimental: :lang: fr :imagesdir: .. include::locale.adoc[] [#_game_development] == Développement de jeux [#_lua] === Lua Singe utilise le langage de programmation Lua (http://www.lua.org) pour écrire la logique des jeux. Lua est rapide, léger, orienté objet, facile à utiliser, et réellement employé dans l'industrie du jeu vidéo. Ce livre ne l'enseigne pas ; _Apprendre à programmer avec Singe_, dans le même téléchargement que celui-ci, s'en charge, de zéro jusqu'à un jeu terminé, en trente et une leçons. Des tutoriels vidéo sur Lua se trouvent sur la chaîne YouTube de Kangaroo Punch, et la référence du langage lui-même est sur http://www.lua.org. [#_basic_rules] === Règles de base * Singe est multiplateforme. Des choses dont vous n'avez normalement pas à vous soucier sous Windows deviennent importantes. Les noms de fichiers sont sensibles à la casse -- `MyScript.singe` et `myscript.singe` ne sont pas le même fichier. Utilisez aussi les séparateurs de chemin UNIX -- `/` plutôt que `\`. Cela rend votre code plus propre (pas de barres obliques inverses doublées) et fonctionne partout où Singe est pris en charge. * Choisissez pour le dossier de votre jeu un nom qui le distingue. N'y mettez pas d'espaces. * Utilisez le framework de Singe fourni. Ne copiez pas le framework dans les scripts de votre jeu. En incluant le framework fourni, vous favorisez la compatibilité future à mesure que Singe est mis à jour. Commencez (ou terminez) le script de votre jeu par : + [source,lua] ---- dofile("Singe/Framework.singe") ---- * Gardez votre jeu autonome. Si vous vous appuyez sur un framework tiers ou partagez du code entre vos jeux, copiez-le dans le dossier de votre jeu. Ne faites jamais référence à un dossier situé à côté de votre jeu ; le seul fichier hors de votre jeu qu'un script peut charger est `Singe/Framework.singe`. Un jeu en un seul fichier (voir <>) empaquette exactement un dossier, et un jeu qui va chercher en dehors ne peut pas être empaqueté. * Restez en dehors du dossier `Singe/`. Il est géré par Singe, et tout ce qui y est ajouté ou modifié risque d'être supprimé à l'avenir. * Incluez un `games.dat`. C'est extrêmement important pour les nouveaux utilisateurs. Même s'il existe d'autres front-ends et des systèmes de menus basés sur Singe, il est vital d'inclure les données destinées au système de menus intégré. Presque tous les problèmes d'assistance viennent de fichiers `games.dat` manquants ou mal formés. * Ne distribuez pas de fichiers non essentiels. Ne livrez jamais les exécutables de Singe ni les scripts fournis par Singe avec votre jeu. Ne livrez pas les fichiers d'index générés à partir des vidéos. [#reloading] === Recharger pendant que vous travaillez Lancez le jeu avec `--reload` (`-R`) et Singe surveille chaque fichier de script non empaqueté que le jeu a chargé. Enregistrez une modification dans l'un d'eux et le jeu repart de son script un instant plus tard : tout ce que le script a créé disparaît (sprites, sons, polices, vidéos, GUI, la scène 3D, la physique, les émetteurs, l'overlay revient à sa taille par défaut, le volume des effets et la souris reviennent à leur état de démarrage), le moteur reste (la fenêtre, le GPU, les manettes), le disque est de nouveau arrêté sur sa première image comme au démarrage, et le script s'exécute à neuf, si bien qu'un modèle de 52 Mo revient à l'écran dans le temps qu'il faut pour le charger plutôt que dans celui qu'il faut pour démarrer Singe. Une touche ou un bouton maintenu pendant le rechargement est ignoré jusqu'à ce qu'il soit relâché, comme lors d'un démarrage à froid. `F5` recharge à la demande, et un script peut le demander avec `singeReload()`. Les jeux empaquetés n'ont rien à surveiller ; l'option est faite pour un jeu dans un dossier. Une erreur de script au rechargement est affichée et le jeu reste vide jusqu'à ce que le rechargement suivant la corrige ; le fichier reste surveillé, donc enregistrer la correction suffit. Comme le rechargement exécute le même nettoyage que lorsqu'on quitte, toute fuite d'un script se voit ici en premier. [[zerobrane]] === Déboguer dans ZeroBrane Studio https://studio.zerobrane.com[ZeroBrane Studio] est un IDE Lua gratuit, et Singe apporte tout ce qu'il faut pour y lancer et y déboguer un jeu : points d'arrêt, pas à pas, fenêtres Pile d'exécution et Expressions espionnes, un jeu qui reste immobile tant qu'il est arrêté, un arrêt dans l'IDE quand une erreur va mettre fin au jeu, et l'autocomplétion avec des infobulles pour toute l'API, en anglais, en français, en italien, en allemand ou en espagnol. La partie débogueur est MobDebug, intégré à Singe et démarré par `--debug`, si bien qu'un jeu n'a besoin d'aucune ligne à lui ; tout autre outil qui parle le protocole de MobDebug peut se connecter de la même façon. [[zerobranesetup]] ==== Mise en place Singe décompresse un paquet pour ZeroBrane Studio dans son dossier de support, sous `Singe/ZeroBrane/singe.lua`, et le tient à jour d'une version à l'autre. Indiquez à ZeroBrane Studio où il se trouve par une seule ligne dans son `user.lua` (Édition > Préférences > Paramètres : Utilisateur), puis redémarrez-le : [source,lua] ---- package "/home/me/Singe/Singe/ZeroBrane/singe.lua" ---- Singe apparaît alors dans Projet > Interpréteur Lua, avec Projet > Déboguer avec Singe et un bouton Singe dans la barre d'outils pour lancer un jeu sous le débogueur en une seule étape. Les fichiers `.singe` s'ouvrent en Lua, les documents `.rml` en XML et les feuilles de style `.rcss` en CSS. Faites du dossier du jeu le projet (Projet > Répertoire de projet > Choisir...). Le paquet cherche Singe dans ce dossier et dans les trois au-dessus, en prenant la version la plus récente pour votre machine, puis dans le `PATH` ; s'il n'en trouve aucun, il vous le demande la première fois que vous lancez un jeu, et s'en souvient. Projet > Choisir l'exécutable de Singe... en désigne un autre, et `path.singe = "/path/to/Singe"` dans `user.lua` l'emporte sur les deux. [[zerobranerun]] ==== Lancer et déboguer Projet > Exécuter (`F6`) lance le jeu, et Projet > Lancer le débogage (`F5`) le lance sous le débogueur. Le script est le point d'entrée du projet s'il y en a un (faites un clic droit dessus dans l'arborescence du projet et choisissez Définir le fichier en tant que point d'entrée), et sinon le fichier en cours d'édition ; quand celui-ci est un des modules du jeu plutôt qu'un fichier `.singe`, c'est l'unique fichier `.singe` du projet qui est lancé. Singe démarre dans le dossier qui contient son exécutable, comme le menu le lancerait, si bien que `Singe/Framework.singe` et tous les noms relatifs à ce dossier se résolvent comme d'habitude, et l'entrée `games.dat` du jeu fournit sa vidéo et ses réglages. Projet > Paramètres de ligne de commande... ajoute vos propres options, dont `-v` et une vidéo pour un jeu sans entrée. Cliquez dans la marge pour poser un point d'arrêt. Le jeu s'y arrête avec ses variables locales dans la fenêtre Pile d'exécution, la fenêtre Expressions espionnes évalue des expressions, et Pas à pas détaillé (`F10`), Pas à pas sommaire (`Shift+F10`) et Pas à pas sortant (`Ctrl+F10`) se comportent comme dans n'importe quel programme Lua, à l'intérieur de `singeMain` aussi. Projet > Interrompre arrête un jeu là où il en est. Tant que le jeu est arrêté, le disque, chaque vidéo et chaque son se mettent en pause exactement comme pour la touche pause, et reprennent ensuite au même endroit. Quand une erreur va mettre fin au jeu -- une erreur Lua, ou un appel du moteur qui reçoit quelque chose dont il ne peut rien faire --, l'IDE s'arrête d'abord, dans le `Debugger.singe` du dossier Singe, et la fenêtre Sortie dit ce qui ne va pas. La fenêtre Pile d'exécution garde sous ce fichier les propres cadres du script, avec leurs variables locales telles qu'elles étaient. Continuez, et le jeu se termine comme il l'aurait fait, avec l'erreur et une trace de pile dans la fenêtre Sortie ; double-cliquez sur une ligne de la trace pour ouvrir le fichier à cet endroit. Le jeu tourne avec `--reload`, si bien qu'enregistrer un script relance le jeu et le débogueur avec lui, points d'arrêt compris. Projet > Relancer Singe quand un script change désactive ce comportement. [[zerobranecompletion]] ==== Autocomplétion et Référence L'autocomplétion propose chaque fonction, callback et constante de l'API, et une infobulle en donne les arguments et le premier paragraphe de l'entrée. Projet > Analyser (`Shift+F7`) connaît l'API lui aussi, si bien qu'il signale un appel mal orthographié plutôt que chaque appel. Le réglage de langue de ZeroBrane Studio choisit la langue des infobulles : `language = "fr"` dans `user.lua` donne le français, et il en va de même pour l'italien (`it`), l'allemand (`de`) et l'espagnol (`es`). Faites un clic droit sur un nom et choisissez Chercher dans la Référence de Singe pour ouvrir son entrée dans un navigateur ; Aide > Référence de Singe ouvre le début. Les deux ont besoin du téléchargement de la documentation décompressé à côté de Singe, sous `Singe-Docs-v3.00` ou `Singe-Docs-v3.00-fr` et ainsi de suite, celui dans la langue de ZeroBrane Studio étant pris en premier ; `singe = { reference = "/path/to/Reference.html" }` dans `user.lua` en désigne un autre. [[zerobranecabinet]] ==== Déboguer une borne Le débogueur fonctionne à travers un réseau. Sur la machine qui a ZeroBrane Studio, ouvrez le dossier du jeu comme projet et choisissez Projet > Lancer le serveur de débogage. Sur la borne, lancez le jeu avec `--debug=HOST`, où `HOST` est le nom ou l'adresse de cette machine ; le port est `8172` sauf si `--debug=HOST:PORT` en donne un autre, et la première machine doit accepter les connexions sur ce port. Une borne qui lance ses jeux depuis le menu peut porter `debug = "HOST:PORT"` dans son fichier de réglages à la place, et chaque jeu qu'elle lance se connecte, le menu compris. L'IDE arrête chacun sur sa première ligne, où Continuer (`F5`) le met en route ; `debugger.runonstart = true` dans `user.lua` le laisse aller tout droit jusqu'au premier point d'arrêt. La borne annonce ses propres chemins, que ZeroBrane Studio fait correspondre aux fichiers du projet la première fois qu'il s'arrête, si bien que le même dossier de jeu sur les deux machines suffit. Si rien n'écoute, un jeu se joue comme d'habitude une fois que la tentative de connexion abandonne, ce qui ne prend pas plus de deux secondes. [[zerobranelimits]] ==== Limites * Déboguez un jeu non empaqueté, depuis son dossier. Un `.game` empaqueté n'a aucun fichier sur le disque que l'IDE puisse ouvrir. * Une erreur dans `singeMain` s'arrête dans `Framework.singe`, qui exécute `singeMain` comme une coroutine ; le message donne la ligne de votre script. * Tant que l'IDE tient le jeu arrêté, la fenêtre ne se redessine pas, et la fermer attend que vous continuiez. Arrêter le processus (`Shift+F5`) termine le jeu aussitôt. * Singe arrête un script qui ne rend jamais la main quand on ferme sa fenêtre, mais pas sous le débogueur, qui possède le script tant qu'il tourne ; Interrompre trouve où il est bloqué. [[deterministic]] === Mode de test déterministe `--deterministic` est un outil de test, pas une façon de jouer. Il fait ignorer complètement le temps réel au moteur : au lieu de demander à la machine l'heure qu'il est, le moteur tourne sur une horloge virtuelle qui avance d'un pas fixe -- 15 millisecondes par défaut -- exactement une fois par image. Tout ce qui est cadencé par le temps suit cette horloge : les pas de la physique, l'animation des sprites et des modèles, les particules, la navigation, les animations et les transitions de la GUI, `os.clock()`, `singeGetTicks()` et un `os.time()` sans argument (un jeu qui initialise son générateur à partir de l'horloge au début d'une partie l'initialise à partir de l'horloge virtuelle, et tire donc les mêmes nombres à chaque exécution ; `os.time` auquel on donne une table de date la convertit comme Lua l'a toujours fait). Le disque la suit aussi : pendant la lecture, chaque image de la vidéo reste affichée pendant autant d'images du moteur que vaut son temps de présentation sur cette horloge (deux ou trois au pas par défaut pour un disque à trente images par seconde), comme en jeu réel, et un disque en pause ou qui a fait un saut reste là où on l'a mis. Le générateur de Lua et celui du moteur sont initialisés à partir d'un nombre fixe. Rien n'attend rien, donc le jeu tourne aussi vite que la machine le permet. Tout l'intérêt est qu'une capture d'écran prise à un numéro d'image fixe est la même image à chaque exécution. Sans cela, l'image 40 est un moment différent à chaque fois -- la physique a fait un nombre de pas différent et le disque est sur une autre image de la vidéo -- si bien que deux exécutions du même jeu inchangé peuvent différer d'un million de pixels, et qu'un vrai changement peut se cacher dans ce bruit. Ce que cela coûte, c'est tout ce qui dépend du temps réel. Le son est toujours joué sur l'horloge du périphérique, qu'aucune horloge virtuelle n'atteint, donc le son dérive par rapport à l'image et ne veut rien dire -- sauf si l'exécution est enregistrée avec `--record` : le son est alors mixé en mémoire un pas à la fois en même temps que l'image, si bien qu'un enregistrement fait de cette façon est exact image par image et échantillon par échantillon (voir <>). La fréquence d'images ne veut rien dire non plus, pas plus que tout ce qu'un jeu mesure en secondes. Ne rapportez jamais un chiffre de performance, un problème de son ou un problème de synchronisation à partir d'une exécution dans ce mode, et ne livrez jamais un lanceur qui l'utilise. Le nombre que porte l'option, si elle en porte un, est à la fois la taille du pas et la graine dont partent les générateurs, de `1` à `1000` : ---- Singe --deterministic le pas par défaut de 15 ms, graine 15 Singe --deterministic=20 un pas de 20 ms, graine 20 ---- Deux exécutions avec le même nombre sont identiques ; deux exécutions avec des nombres différents sont chacune reproductibles mais différentes l'une de l'autre, et c'est ainsi qu'un test demande une exécution différente mais tout aussi reproductible. L'option peut se régler dans un fichier de réglages sous le nom `deterministic`, comme toute autre option. [#_reference_screenshots] ==== Captures d'écran de référence Les scènes de `testScripts` prennent chacune une capture d'écran ou deux à un numéro d'image fixe et quittent d'elles-mêmes, si bien que dans ce mode elles produisent un jeu d'images de référence auquel un changement ultérieur peut être comparé pixel par pixel. Pour les enregistrer, lancez chaque scène avec l'option et gardez ce qui arrive dans le dossier de données : [source,sh] ---- for n in 2 3 4 5 6 7 8 9 10 11 13 14 15 16 18 19 21 22 23 24 25 26 \ 36 37 38 39 40 41 42 43 44 45 48 49 50 51; do Singe -w -d data --deterministic -v Singe/menuBackground.mkv \ testScripts/scene$n.singe done ---- Les scènes laissées hors de cette boucle veulent leurs propres options, celles que leur donne le tableau de `testScripts/README.md` : `scene12` se lance sans aucun disque, `scene17` avec le disque, et `scene20` avec `-C 720x480`, toutes trois avec `SDL_GPU_DRIVER=nothing` ; `scene27` à `scene35` veulent `-x 1280 -y 720 -C 1280x720` et pas de disque ; `scene46` veut `-g '10 5'` ; et `scene47` veut `--bezeldir=testScripts/bezels --bezel=cabinet.png`. Copiez les fichiers `singe*.png` hors de `data/testScripts` après chaque exécution, puisque l'exécution suivante numérote de nouveau ses captures à partir de zéro. Pour comparer une version ultérieure, il suffit alors de lancer les mêmes commandes et de demander la différence, qui doit être nulle : [source,sh] ---- compare -metric AE reference/scene36-singe000.png new/scene36-singe000.png null: ---- [#_where_files_live] === Où vivent les fichiers Les chemins dans un script sont relatifs au dossier depuis lequel Singe a été lancé, pas au script. Utilisez la variable globale `DIR`, que `Framework.singe` règle sur le dossier du script lui-même, pour qu'un jeu fonctionne où qu'il soit installé : [source,lua] ---- crosshair = spriteLoad(DIR .. "sprite_Crosshair.png") ---- Tout ce que Singe écrit va dans le dossier de données (`singeGetDataPath()`), `data/` à côté des jeux sauf si `--datadir` en décide autrement, si bien qu'un jeu peut vivre sur un support en lecture seule. Singe 2.x écrivait les sauvegardes et les réglages dans le dossier du jeu lui-même ; un jeu qui le fait encore échoue sur une installation en lecture seule, alors gardez chaque `io.output` sous `singeGetDataPath()`. [#_packaging_your_game] === Empaqueter votre jeu Un jeu terminé se livre sous la forme d'un seul fichier `.game`, décrit ci-après. Rien d'autre n'est nécessaire : le joueur copie le fichier à côté de l'exécutable de Singe et le menu l'affiche. Singe n'installe plus d'archives. [#singlefile] === Jeux en un seul fichier Un jeu peut tenir en un seul fichier. `singe --pack` écrit un dossier de jeu dans une base de données SQLite, et Singe le lance depuis celle-ci : scripts, sprites, sons, polices, framefiles et vidéo se chargent tous directement depuis la base de données, et installer le jeu revient à copier le fichier à côté de l'exécutable. Le menu affiche chaque fichier `.game` qu'il trouve à côté des dossiers de jeux. ---- Singe --pack MyGame MyGame.game Empaquette le dossier MyGame dans MyGame.game Singe MyGame.game Lance la première entrée du games.dat qu'il contient Singe --entry=2 MyGame.game Lance sa deuxième entrée Singe --unpack MyGame.game MyGame Réécrit les fichiers sur le disque Singe --patch MyGame.game fixes Remplace des fichiers depuis un dossier (ou une base de correctif) ---- Rien dans un jeu n'a besoin de changer pour qu'il soit empaqueté. Chaque nom qu'utilise un script, que ce soit par `dofile`, `require`, `io.open` et ses semblables, `spriteLoad`, `soundLoad`, `fontLoad`, `videoLoad`, un framefile ou un `controls.cfg`, passe par une seule et même recherche. Pour un jeu non empaqueté, cette recherche est le simple système de fichiers, relativement au dossier depuis lequel Singe a été lancé, exactement comme avant. Pour un jeu empaqueté, un nom est essayé à trois endroits, dans cet ordre, et le premier trouvé l'emporte : . Un dossier non empaqueté portant le nom de la base de données sans son extension (`MyGame/` à côté de `MyGame.game`). C'est ainsi que vous continuez à modifier un jeu empaqueté, et qu'un joueur en fait un mod : les fichiers déposés là l'emportent sur les copies empaquetées. . Le dossier de données du jeu, sous `files/`. Tout ce qu'un jeu écrit avec `io.output` ou `io.open(name, "w")` arrive ici, si bien qu'un `game.cfg` enregistré l'emporte sur l'original empaqueté et que la base de données n'est jamais écrite. . La base de données elle-même. Dans une base de données, les noms sont comparés sans tenir compte de la casse, avec l'un ou l'autre séparateur de chemin, et relativement à la racine du jeu. Un premier composant égal au nom du dossier du jeu lui-même est ignoré, si bien que `MyGame/Cfg/game.cfg`, `Cfg/game.cfg` et `DIR .. "Cfg/game.cfg"` trouvent tous le même fichier. Le nom de la base de données elle-même (`MyGame.game`, avec ou sans séparateur final) est la racine du jeu, comme l'est `.` depuis l'intérieur. Les noms commençant par `Singe/` ou par le dossier de données restent sur le système de fichiers ; `Singe/Framework.singe` et `singeGetDataPath()` fonctionnent comme toujours, et un tel nom peut utiliser `..` tant qu'il reste sous ce préfixe (le `controls.cfg` à la racine du dossier de données est trouvé ainsi). Tout autre nom relatif comportant un composant `..` ne mène nulle part dans un jeu empaqueté : `io.open` renvoie `nil` avec un message disant que le nom sort du jeu, `lfs.attributes` renvoie `nil`, et `spriteLoad` et ses semblables signalent le fichier comme manquant. La racine du jeu est un dossier, pas un fichier : `io.open` sur elle renvoie `nil`, et `lfs.mkdir` et `lfs.rmdir` la refusent. `lfs.dir` sur un dossier empaqueté liste la réunion des trois endroits, triée sans tenir compte de la casse ; quand un fichier non empaqueté et un fichier empaqueté ne diffèrent que par la casse, c'est l'orthographe du fichier non empaqueté qui est listée. `lfs.attributes` sur un fichier empaqueté répond comme le vrai `lfs` : la table entière avec un seul argument, un champ par son nom (un nom inconnu lève une erreur), ou une table à vous qu'il remplit ; un fichier manquant renvoie `nil`, un message et le numéro d'erreur. `require("name")` trouve `name.lua`, `name/init.lua` ou `name.singe` sous le dossier du script, puis sous la racine du jeu. `io.open`, `io.lines` et `io.input` sur un nom qui n'existe que dans la base de données renvoient un fichier propre à Singe plutôt qu'un fichier de Lua. Le handle de fichier de Lua est un `FILE *` du C, et une ligne de base de données n'est pas un fichier, si bien que la lire voulait dire autrefois l'écrire à côté du jeu et ouvrir cette copie ; le handle de Singe lit la ligne là où elle se trouve. Il répond à tout ce à quoi répond celui de Lua -- `read` avec tous les formats (`"a"`, `"l"`, `"L"`, `"n"` et un nombre d'octets), `lines`, `seek`, `close`, `flush` et `setvbuf` -- `io.type` le considère comme un fichier, `io.read` et `io.close` l'acceptent, et il se ferme de lui-même quand il est collecté ou quand une variable `` sort de sa portée. La seule différence est qu'y écrire est une erreur, parce que ce qu'il lit est dans la base de données et que la base de données n'est jamais écrite. Un jeu qui écrit un fichier en obtient un vrai dans son dossier de données exactement comme avant, et il en va de même pour chaque nom qui n'est pas empaqueté. `testScripts/packedIo` est le test qui confronte tout cela au comportement de Lua lui-même ; son en-tête dit comment le lancer. L'empaqueteur refuse un dossier qui n'a pas de `games.dat`, qui contient `controls.dat`, `Framework.singe`, un fichier dont l'extension est `exe`, `sh`, `bat` ou `cmd`, ou un fichier sans extension dont le nom commence par `singe`, qui a une entrée de premier niveau portant le nom du dossier lui-même (cela rendrait ambiguë la règle du dossier propre au jeu), ou dont les scripts et les fichiers de données sortent du jeu avec `..` ; il signale chacun de ces fichiers, avec la ligne. Deux fichiers dont les noms ne diffèrent que par la casse ne peuvent pas être empaquetés tous les deux. Les fichiers `.index` périmés sont ignorés. `--unpack` refuse une base de données dont les noms stockés atterriraient hors du dossier cible (un chemin absolu, une lettre de lecteur ou un composant `..`). Un jeu qui fait référence au dossier d'un autre jeu (un framework partagé situé à côté de lui) doit d'abord copier ce dossier à l'intérieur. Avant que la base de données soit validée, son `games.dat` est vérifié : chaque entrée a besoin d'un `SCRIPT`, et chaque `SCRIPT`, `VIDEO`, `CABINET`, `MARQUEE` et `ATTRACT` qu'elle nomme doit être un fichier de la base de données (ou sous `Singe/`) ; un mauvais chemin est signalé avec le titre de l'entrée et l'empaquetage est refusé. `--patch` effectue la même vérification sur le jeu corrigé, si bien qu'un correctif ne peut pas non plus laisser derrière lui un chemin qui ne mène nulle part. `--patch` prend soit un dossier organisé comme le jeu, dont les fichiers remplacent ceux qui sont empaquetés, soit un fichier `.patch` créé par `--pack` à partir d'un tel dossier (`Singe --pack fixes MyGame-1.1.patch` ; un correctif n'a pas besoin de `games.dat`). Les fichiers que le correctif ne nomme pas sont laissés tels quels. Dans les deux cas, le changement est une seule transaction : le jeu reste intact s'il est interrompu. Un correctif peut aussi porter une table `removed(path)` qui nomme des fichiers à supprimer. Un `.patch` peut être désempaqueté comme un jeu, mais il ne peut pas être lancé et le menu l'ignore. Un correctif qui remplace un gros fichier par un petit laisse dans la base de données l'espace qu'occupait l'ancien contenu, où SQLite le garde pour la prochaine chose écrite plutôt que de le rendre. C'est sans importance pour un correctif qui change un script, et c'est du gaspillage pour un développeur qui réempaquette une vidéo une dizaine de fois : le `.game` peut finir par faire plusieurs fois la taille de son contenu. Aussi `--patch` regarde l'espace libre une fois la transaction validée et, quand il y en a au moins un mégaoctet, compacte la base de données et indique combien il a récupéré : ---- >>> Recovered 8007680 bytes of space the old contents were using ---- Le résultat a, à l'octet près, la taille d'un empaquetage neuf des mêmes fichiers. Le compactage réécrit le fichier entier, il est donc sauté quand il y a peu à gagner. Rien d'autre dans Singe n'en a besoin ; un jeu n'est jamais écrit que par l'empaqueteur. Le dossier de données d'un jeu empaqueté porte le nom de la base de données, puis celui du dossier du script à l'intérieur : `MyGame/` pour un `MyGame.game` dont le script se trouve à la racine, sous le dossier de données (`data/MyGame/` par défaut), là même où l'a le `MyGame/MyGame.singe` non empaqueté, si bien que les sauvegardes et les réglages sont partagés entre une installation non empaquetée et une installation empaquetée ; un script empaqueté dans un sous-dossier (`Game/Main.singe`) obtient `MyGame/Game/`. L'index de la vidéo va là aussi. NOTE: Les versions 3.00 antérieures donnaient à un jeu empaqueté dont le script se trouvait dans un sous-dossier le dossier de données `Game/` sous la base, que partageait chaque archive organisée de cette façon. Les sauvegardes d'un tel jeu ont désormais leur place sous `MyGame/Game/` ; déplacez-les à la main. Les versions 3.00 antérieures gardaient aussi le dossier propre du jeu, celui sous lequel son `games.dat` nomme le script, si bien qu'un `MyGame.game` empaqueté depuis `MyGame/` écrivait dans `MyGame/MyGame/` ; remontez d'un niveau ce qui s'y trouve. Quand Singe lance un jeu depuis une base de données, le menu passe `CONTAINER` dans l'entrée de `games.dat` qu'il lance ; ce champ est réglé par le menu, jamais à la main. Pour vos propres données, `require("sqlite3")` donne aux scripts la liaison https://lua.sqlite.org[lsqlite3] vers le même SQLite que celui qu'utilise le moteur. Gardez les bases de données que le jeu écrit dans `singeGetDataPath()` : [source,lua] ---- local sqlite3 = require("sqlite3") local db = sqlite3.open(singeGetDataPath() .. "scores.db") db:exec("CREATE TABLE IF NOT EXISTS scores (name TEXT, score INTEGER)") for row in db:nrows("SELECT name, score FROM scores ORDER BY score DESC LIMIT 10") do print(row.name, row.score) end db:close() ---- `sqlite3.open` trouve un nom comme le fait `io.open`, si bien qu'une base de données peut être livrée dans le jeu -- tables de niveaux, dialogues, listes d'objets -- et s'ouvrir quand même quand le jeu est empaqueté. Ouverte en lecture seule, une base de données empaquetée est lue là où elle se trouve, sans rien copier : [source,lua] ---- local levels = sqlite3.open(DIR .. "levels.db", sqlite3.OPEN_READONLY) ---- Ouverte en écriture, ce que fait par défaut `sqlite3.open`, une base de données empaquetée est copiée une fois dans le dossier de données du jeu, sous `files/` comme tout ce qu'écrit un jeu empaqueté, et c'est la copie qui est ouverte. À partir de là, le nom désigne la copie, en lecture seule ou non, et l'original empaqueté n'est jamais modifié. La base de données d'un jeu non empaqueté s'ouvre sur place exactement comme avant. `testScripts/packedSql` est le test ; son en-tête dit comment le lancer. Le format de fichier, pour qui écrit des outils : trois tables. `meta(key, value)` contient `version` (1), `gamedir` (le nom du dossier empaqueté), `chunk` (la taille des blocs en octets, 4194304) et `packer`. `assets(path, name, size, data)` a une ligne par fichier, `path` étant la clé en minuscules avec des barres obliques et `name` l'orthographe de l'auteur ; les fichiers qui ne dépassent pas la taille d'un bloc sont dans `data`, les plus gros ont `data` à NULL et vivent dans `chunks(path, chunk, data)` sous forme de morceaux numérotés consécutifs. Singe ouvre la base de données en lecture seule et immuable, si bien qu'il tourne depuis un support en lecture seule et n'écrit jamais de journal à côté. [#_event_driven_or_not] === Piloté par les événements... ou pas ? Traditionnellement, Singe utilisait un modèle de programmation piloté par les événements -- Singe s'occupe de tout et n'appelle votre code que lorsqu'il a besoin de vous dire quelque chose ou de mettre à jour l'écran. C'est efficace et c'est ainsi que fonctionnent la plupart des moteurs de jeu basés sur Lua, mais cela peut être verbeux. Depuis Singe 2.10, il existe un nouveau modèle _à fil d'exécution_ (« threaded ») qui vous permet d'écrire du code procédural. [#_event_driven] ==== Piloté par les événements Avec le modèle de programmation piloté par les événements, Singe contrôle la boucle principale du programme et décide de l'ordre d'exécution. Singe gère automatiquement tous les détails du décodage et de la présentation de la vidéo et du son. Il gère les manettes, les souris et le clavier. Quand Singe a besoin de quelque chose de propre au jeu, il appelle une partie de votre script. Le script Singe le plus simple qui montre tous les callbacks (fonctions de rappel) existants ressemble à ceci : [source,lua] ---- -- Squelette de jeu Singe. -- Charge le framework de Singe. dofile("Singe/Framework.singe") -- Déclarez ici les variables globales dont vous avez besoin. function onControllerMoved(axis, value, which) --[[ Indique quel axe de manette a bougé, ainsi que sa valeur actuelle. (Plage : -32768 à 32767.) Sert pour les périphériques analogiques. Les entrées numériques sont gérées par onInput et onKey. --]] end function onInputPressed(what, device) --[[ En MODE_NORMAL du clavier, les événements d'entrée sont signalés ici quand la touche ou le bouton est enfoncé. Pour la liste complète des touches, boutons et manettes, voir Singe/Framework.singe. En MODE_FULL, cet événement est appelé avec le keysym de la touche enfoncée. Quand le contact vient d'une souris ou d'un pistolet optique, device est l'index de ce périphérique (0 pour le premier), tel que le passe Hypseus, si bien qu'un jeu à deux pistolets peut distinguer ses joueurs ; pour une touche ou une manette, il vaut nil. --]] end function onInputReleased(what, device) --[[ En MODE_NORMAL du clavier, les événements d'entrée sont signalés ici quand la touche ou le bouton est relâché. Pour la liste complète des touches, boutons et manettes, voir Singe/Framework.singe. En MODE_FULL, cet événement est appelé avec le keysym de la touche relâchée. device est comme pour onInputPressed. --]] end function onKeyPressed(key, scancode) --[[ En MODE_FULL du clavier, les événements d'entrée sont signalés ici quand la touche est enfoncée. Le keysym et le scancode sont tous deux renvoyés. Pour la liste des scancodes disponibles, voir Singe/Framework.singe. --]] end function onKeyReleased(key, scancode) --[[ En MODE_FULL du clavier, les événements d'entrée sont signalés ici quand la touche est relâchée. Le keysym et le scancode sont tous deux renvoyés. Pour la liste des scancodes disponibles, voir Singe/Framework.singe. --]] end function onMouseMoved(x, y, xr, yr, which) --[[ Appelée quand la souris bouge. En mode SINGLE_MOUSE, les valeurs absolues de X et Y ainsi que le changement relatif de position sont renvoyés. En mode MANY_MOUSE, seul le changement relatif est disponible, ainsi que la souris qui a bougé. --]] end function onOverlayUpdate() --[[ C'est le seul endroit où vous pouvez dessiner sans risque. Si vous voulez afficher un curseur de visée, il faut enregistrer la position de la souris reçue par onMouseMoved dans des variables globales, puis vous en servir ici pour dessiner le curseur. --]] -- Indique à Singe si l'affichage a changé ou non. return(OVERLAY_UPDATED) -- Ou OVERLAY_NOT_UPDATED si rien n'a été dessiné. end function onShutdown() -- Appelée quand l'utilisateur quitte votre jeu. Libérez ici les ressources chargées. end function onSoundCompleted(channel) -- Le son sur « channel » vient de finir de jouer. end -- Remarque : il n'y a pas d'événement « onStartup ». -- Tout code de démarrage nécessaire peut être placé ici. ---- [#threaded] ==== À fil d'exécution Depuis Singe 2.10, vous pouvez utiliser le modèle de programmation procédural classique. Pour utiliser ce modèle, déclarez une fonction `singeMain()` et incluez le framework de Singe à la *fin* de votre programme plutôt qu'au début. Lua n'est pas vraiment multithread, si bien que ce modèle compte sur le développeur du jeu pour faire du multitâche coopératif en appelant `singeYield()` partout où le code consomme un temps notable. Comme avec le modèle piloté par les événements, Singe gère toujours toutes les entrées, le décodage de la vidéo et le décodage du son. Un exemple de programme à fil d'exécution ressemble à ceci : [source,lua] ---- function singeMain() local x = 10 local y = 10 while true do colorBackground(0, 0, 0, 255) overlayClear() colorForeground(255, 255, 255, 255) overlayPrint(x, y, "+") if keyboardGetLastUp() == SCANCODE.LEFT.value then x = x - 1 end if keyboardGetLastUp() == SCANCODE.RIGHT.value then x = x + 1 end if keyboardGetLastUp() == SCANCODE.UP.value then y = y - 1 end if keyboardGetLastUp() == SCANCODE.DOWN.value then y = y + 1 end singeYield() end end dofile("Singe/Framework.singe") ---- [#_hybrid] ==== Hybride Les deux modèles se combinent librement. Quand `singeMain` existe, `Framework.singe` installe son propre `onOverlayUpdate`, mais tous les autres callbacks sont toujours appelés : définissez `onInputPressed`, `onSoundCompleted` ou `onShutdown` à côté de `singeMain` et ils sont appelés entre les suspensions de la coroutine. Un découpage typique garde le déroulement du jeu procédural tout en réagissant aux événements d'entrée et de son : [source,lua] ---- local fired = false function onInputPressed(what) if what == SWITCH_BUTTON1 then fired = true end end function onSoundCompleted(channel) if channel == gunshotChannel then soundPlay(reloadSound) end end function singeMain() while true do if fired then fired = false gunshotChannel = soundPlay(gunshotSound) end singeYield() end end dofile("Singe/Framework.singe") ---- Ne définissez pas `onOverlayUpdate` vous-même dans ce modèle ; dessinez plutôt depuis `singeMain`. Une erreur levée dans `singeMain` termine le jeu avec une trace de pile, et revenir de `singeMain` quitte proprement. [#pausing] === La pause Chaque jeu dispose d'office d'une pause qui fonctionne. Tant que la touche de pause est activée (c'est le cas par défaut), appuyer sur la touche affectée à `INPUT_PAUSE` fait que le moteur met en pause le disque, chaque vidéo chargée et chaque son, dessine un indicateur PAUSED par-dessus la dernière image et cesse d'exécuter le script : pas de `onOverlayUpdate`, pas de reprise de `singeMain`, pas de callbacks d'entrée, de souris, de manette ni de son. Les minuteries construites sur des bibliothèques Lua s'arrêtent avec lui, parce que plus rien ne les fait tourner. Appuyer de nouveau sur la touche reprend tout ce qui était en cours de lecture et le script continue là où il s'était arrêté. Seuls les contacts propres au moteur (pause, quitter, capture d'écran, capture de la souris) fonctionnent encore pendant le gel. Les entrées restent fidèles à la réalité pendant le gel. Tout ce que le script croit maintenu enfoncé est relâché avec `onInputReleased` (ou `onKeyReleased` en `MODE_FULL`) quand la pause commence, et tout ce qui est encore physiquement maintenu quand la pause se termine est de nouveau enfoncé. Un joystick maintenu pendant une pause ne reste jamais bloqué. Un jeu qui veut son propre comportement de pause (un menu de pause, un gel de l'histoire que le joueur ne doit pas pouvoir sauter) appelle `singeSetPauseKeyEnabled(false)`. La touche est alors transmise au script sous la forme de `SWITCH_PAUSE` et le jeu pilote lui-même `singeSetPauseFlag`, qui met les médias en pause sans geler le script. En `MODE_FULL`, le clavier appartient entièrement au jeu, donc les touches affectées aux contacts du moteur n'y font rien (un `p` tapé dans une saisie de meilleur score doit rester un `p`). Les boutons de manette et de souris affectés à ces contacts continuent de fonctionner dans les deux modes, puisqu'on ne peut pas les taper. [#gamesdat] === games.dat Le fichier `games.dat` permet à Singe de trouver automatiquement les nouveaux jeux quand l'utilisateur final les installe. Ce fichier est extrêmement important et doit être inclus avec chaque jeu Singe. Placez `games.dat` dans le dossier le plus haut de votre jeu. Voici un exemple qui contient plusieurs jeux : [source,lua] ---- GAMES = { { TITLE = "Asteroid Patrol", SCRIPT = "Collection/AsteroidPatrol.singe", VIDEO = "Collection/frame_AsteroidPatrol.txt", STRETCH = false, NO_MOUSE = false, RESOLUTION_X = 720, RESOLUTION_Y = 480, SINDEN_GUN = "", GAME_ID = "6f1e7b62-0a4e-4d9c-9b2f-1c7a5e3d8a10", CABINET = "Collection/cabinet_AsteroidPatrol.png", MARQUEE = "Collection/marquee_Collection.png", ATTRACT = "Collection/video_AsteroidPatrol.mkv", ATTRACT_START = 3000, ATTRACT_END = 3500, YEAR = 2026, PLATFORM = "Laserdisc", DEVELOPER = "Your Studio", PUBLISHER = "Your Studio", GENRE = "Shooter", DESCRIPTION = "Keep the asteroids away from the station for as long as you can.", CREATOR = "Scott Duensing", SOURCE = "http://kangaroopunch.com" }, -- ... d'autres entrées ... } ---- Les clés `SCRIPT`, `VIDEO`, `CANVAS_X`, `CANVAS_Y`, `STRETCH`, `NO_MOUSE`, `RESOLUTION_X`, `RESOLUTION_Y`, `SINDEN_GUN`, `AUDIO_TRACK`, `AUDIO_DELAY`, `AUDIO_SUFFIX`, `GAME_ID`, `LEGACY_SPRITE_ARGS`, `REQUIRES` et `TIER` sont lues par le moteur quand le menu (ou votre propre script, par `scriptExecute` / `scriptPush`) lance l'entrée ; elles l'emportent sur la ligne de commande. La ligne `VIDEO` est le disque : une entrée qui en nomme un est un jeu laserdisc, et une entrée qui l'omet ou la laisse vide se lance sans disque et peut régler `CANVAS_X` / `CANVAS_Y` (720x480 par défaut) ; voir <>. `AUDIO_SUFFIX = "-es"` joue `-es.ogg`, à côté de la vidéo du disque, au lieu du son qu'elle contient, pour une édition dont les autres langues sont livrées dans des fichiers séparés, ce que fait aussi `--altaudio`. `LEGACY_SPRITE_ARGS = true` lance un jeu écrit pour Singe 2.10 avec l'ancien ordre des arguments des sprites (voir <>). Les autres clés sont lues par le menu pour l'affichage. `REQUIRES` indique ce dont le jeu a besoin de la part d'une machine, sous forme de liste de noms : `"SCENE"` (la scène 3D), `"GUI"` (les documents GUI), `"SHADOWS"`, `"POST"` (le bloom et les autres effets qui veulent des cibles de rendu en virgule flottante), `"SKIN"` (les modèles avec skinning) et `"MORPH"` (les morph targets). Sans `REQUIRES`, le jeu est en 2D. Le menu signale un jeu que cette machine ne peut pas faire tourner et dit pourquoi au lieu de le lancer, et vous écrivez rarement la liste vous-même : le moteur remarque ce qu'un jeu utilise pendant qu'il tourne, signale dans la trace quand le jeu utilise quelque chose que `REQUIRES` ne liste pas, et `--pack` écrit la liste dans une entrée qui n'en a pas. `TIER` (0 à 3) indique la puissance de machine que demande le jeu ; le menu mesure le niveau de cette machine à partir de son propre décor de fond et signale un jeu qui en demande plus comme pouvant tourner lentement. Voir <>. [source,lua] ---- REQUIRES = { "SCENE", "SHADOWS" }, TIER = 1, ---- Un script lancé directement, par son nom ou par son dossier, reçoit le même traitement : Singe cherche un `games.dat` dans le dossier du script et jusqu'à trois dossiers au-dessus, et si le `SCRIPT` d'une entrée nomme le script lancé (les chemins des entrées partent du dossier situé au-dessus du `games.dat`, si bien que `Collection/games.dat` nomme `Collection/AsteroidPatrol.singe`), les clés de cette entrée s'appliquent. La seule différence avec un lancement depuis le menu est la priorité : une option tapée sur la ligne de commande (`--framefile`, `--disc`, `--stretch`, `--nomouse`, `--xresolution`, `--yresolution`, `--sindengun`, `--audio`, `--audiodelay`, `--altaudio`, `--canvas`) garde sa valeur, puisque la personne qui l'a tapée le voulait. Une valeur venue d'un fichier de réglages, non : c'est une valeur par défaut, et l'entrée décrit mieux le jeu (voir xref:Manual.adoc#settings[Le fichier de réglages]). Un `games.dat` qui ne peut pas s'exécuter est signalé et ignoré lors d'un lancement direct. [#withoutadisc] === Jeux sans disque Singe ne suppose plus de laserdisc. Un jeu n'est un jeu laserdisc que lorsqu'il en nomme un : un `VIDEO` dans son entrée de `games.dat`, ou `--disc` (ou un `--framefile`) sur la ligne de commande. Tout le reste tourne sans disque, et le monde est un *canevas* au lieu d'une image de la vidéo : `CANVAS_X` / `CANVAS_Y` dans `games.dat` ou `--canvas=WxH` sur la ligne de commande, 720x480 par défaut, si bien qu'un jeu sans disque ressemble à un jeu laserdisc en définition standard, sauf s'il demande autre chose. Sans disque, le moteur dessine sur du noir, l'overlay fait par défaut la moitié du canevas comme d'habitude, et les fonctions `disc*` et `vldp*` deviennent inoffensives : `discGetState` renvoie `DISC_EJECTED`, `discGetFrame` renvoie `0`, `discGetWidth` / `discGetHeight` renvoient la taille du canevas, `vldpGetPixel` renvoie du noir, et les appels de transport écrivent dans la trace et ne font rien. Tout le reste -- l'overlay, les sprites, les polices, les sons, les vidéos supplémentaires par `videoLoad`, les entrées et les deux modèles de programmation -- est identique. La variable globale `SINGE_DISC` indique à un script en tant que quel type de jeu il tourne. [#gui] === GUI Les menus, les pages d'options, les HUD et la saisie des meilleurs scores se construisaient autrefois avec `fontPrint`, `overlayBox` et un curseur fait maison. Singe 3 ajoute une GUI : un document écrit en RML (un balisage proche du HTML) et mis en forme par RCSS (une feuille de style proche du CSS), mis en page et dessiné par RmlUi, avec retour à la ligne du texte, flexbox, panneaux en neuf tranches tirés de vos propres illustrations, transitions, animations, contrôles de formulaire et un modèle de focus qu'une manette peut piloter. Une *GUI* est un contexte RmlUi de taille fixe rendu dans sa propre texture, redessiné chaque fois que son image change (voir <>), et cette texture va partout où une texture peut aller : composée par-dessus l'overlay avec `guiDraw`, ou affichée sur un matériau de la scène 3D avec `materialSetGui`, si bien que la même page d'options fonctionne à plat sur l'écran et sur le moniteur d'une borne d'arcade modélisée. Une GUI a besoin du périphérique GPU qu'utilise la scène 3D, elle n'est donc pas disponible sur une machine qui n'en a pas (voir <>) ; `guiNew` met fin au script au lieu de répondre false, alors interrogez d'abord <> si votre jeu peut s'en passer. Le menu fourni fait exactement cela, et se dessine à la place dans l'overlay quand la réponse est non (voir xref:Manual.adoc#menurenderers[Les deux moteurs de rendu]). Chaque appel cité ici a une entrée dans la section <> de la référence. La référence des langages RML et RCSS est la documentation de RmlUi elle-même, sur https://mikke89.github.io/RmlUiDoc/, et elle mérite d'être lue avant le premier document ; ce chapitre couvre ce que Singe ajoute par-dessus. [#_documents_and_style_sheets] ==== Documents et feuilles de style `guiNew(width, height)` crée une GUI et renvoie un handle entier ; il peut en exister jusqu'à seize à la fois. `guiLoad(gui, name)` y charge un document RML, l'affiche et renvoie un handle de document (jusqu'à trente-deux par GUI) ; `guiHide`, `guiShow` et `guiClose` font ce que disent leurs noms, et `guiDelete` supprime la GUI, ses documents et sa texture. Un document a généralement la taille de sa GUI, et une GUI a généralement la taille de l'overlay pour une page plein écran, ou celle du panneau pour un élément de HUD : [source,lua] ---- local gui = guiNew(overlayGetWidth(), overlayGetHeight()) local doc = guiLoad(gui, DIR .. "options.rml") ---- Le nom du document passe par le système de fichiers virtuel comme celui d'un sprite, il peut donc être un fichier non empaqueté sur le disque ou se trouver dans un jeu empaqueté, et il est résolu relativement au dossier depuis lequel Singe a été lancé : ajoutez donc `DIR` devant pour les fichiers livrés avec votre jeu, exactement comme pour `spriteLoad`. Les noms *à l'intérieur* d'un document (le `href` d'une feuille de style, une image dans un décorateur, le `src` d'une planche de sprites) sont résolus relativement au fichier qui les nomme, comme le fait RmlUi, puis par la même recherche, si bien qu'un document, sa feuille de style et ses images se trouvent côte à côte dans le dossier du jeu et qu'un jeu empaqueté les trouve tous. Deux sortes de noms sont pris tels quels : les fichiers propres au moteur sous `Singe/`, et les chemins absolus. La même règle lue dans l'autre sens permet à un document qui se trouve sous `Singe/` d'atteindre les fichiers d'un jeu : un nom relatif est résolu depuis le dossier du document lui-même, donc depuis `Singe/Menu.rml` le nom `../Collection/cabinet.png` est `Collection/cabinet.png` dans le dossier du jeu, et un nom sous `../MyGame.game/` est un fichier dans ce jeu empaqueté. Le menu fourni fait exactement cela pour les illustrations de borne et de fronton du jeu sélectionné, en réglant depuis Lua un décorateur d'image sur le panneau d'illustration pour que l'image y soit ajustée quelle que soit sa taille : [source,lua] ---- -- Menu.singe : ajuste l'image d'un jeu dans l'un des panneaux d'illustration. function menuArt(id, file) local path = tostring(file or "") if path:sub(1, 6):lower() ~= "singe/" then path = "../" .. path end menuElement(id).style.decorator = 'image("' .. path .. '" contain)' end ---- Un document RML est un `` avec un titre, des liens vers des feuilles de style ou un `

Options

Choose a difficulty and press Start.

---- RmlUi ne dessine rien dont on ne lui a pas donné la taille, et chaque élément est `display: inline` tant qu'un style ne dit pas autre chose, donc une feuille de style n'est pas facultative. `Singe/gui.rcss` est le thème que livre le moteur : il donne au corps, aux titres et aux paragraphes une taille et une couleur, fait de `div` un bloc, et met en forme chaque contrôle de formulaire. Liez-le en premier et redéfinissez ce que vous voulez dans une feuille à vous ou dans un `

Options

plays the intro

Press Start when ready.

---- Le script le charge dans une GUI de la taille de l'overlay, branche les trois éléments qui l'intéressent, et la dessine à chaque image jusqu'à ce que le joueur appuie sur Start ; le disque joue en dessous pendant tout ce temps : [source,lua] ---- dofile("Singe/Framework.singe") local gui = guiNew(overlayGetWidth(), overlayGetHeight()) local doc = guiLoad(gui, DIR .. "options.rml") local settings = { name = "Dirk", volume = 48, hard = false } local inMenu = true local function report(text) guiSetValue(gui, doc, "status", text) end guiSetHandler(gui, doc, "volume", "change", function(g, d, id, event, value) settings.volume = math.floor(tonumber(value)) soundSetVolume(settings.volume) report("Volume " .. settings.volume) end) guiSetHandler(gui, doc, "difficulty", "change", function(g, d, id, event, value) settings.hard = (value == "hard") report("Difficulty " .. value) end) guiSetHandler(gui, doc, "go", "click", function(g, d) settings.name = guiGetValue(g, d, "name") guiHide(g, d) guiSetInput(g, false) inMenu = false discPlay() end) guiSetHandler(gui, doc, "back", "click", function() singeQuit() end) function onInputPressed(what) -- Seul ce que la page n'a pas utilisé arrive ici : rien tant qu'elle a le -- focus, tout une fois qu'elle est cachée. if not inMenu and what == SWITCH_BUTTON1 then fire() end end function onOverlayUpdate() overlayClear() if inMenu then guiDraw(gui) else fontPrint(10, 10, settings.name .. " " .. (settings.hard and "HARD" or "NORMAL")) end return OVERLAY_UPDATED end ---- La même page sur l'écran d'une borne modélisée, ce sont les cinq lignes de <> à la place de `guiDraw`, et un clic sur le moniteur appuie sur le bouton qui se trouve dessous. [#subtitles] === Sous-titres Un jeu peut livrer un fichier de sous-titres SubRip et demander à Singe de l'afficher par-dessus le disque pendant la lecture. Trois appels font tout le travail : <> lit le fichier, <> l'active, et <> indique où se placent les lignes. <> retire ce qui est affiché. La fonctionnalité vient d'Hypseus Singe, et les quatre appels sont les siens. [source,lua] ---- if srtLoad("subs/english.srt") then srtPosition(80) srtEnable(true) end ---- [#_the_file_format] ==== Le format de fichier SubRip (`.srt`) est un fichier texte brut de sous-titres numérotés : ---- 1 00:00:04,120 --> 00:00:06,880 Stay away from the caves. 2 00:00:07,000 --> 00:00:09,500 I mean it -- the caves are where they nest. ---- Chaque sous-titre est un numéro seul sur sa ligne, puis une ligne contenant deux horodatages séparés par `-->`, puis une ou plusieurs lignes de texte, puis une ligne vide. Un horodatage s'écrit `HH:MM:SS,mmm` : les heures, les minutes et les secondes séparées par des deux-points, et les millisecondes après une virgule. Le numéro du sous-titre est lu puis jeté, si bien que les numéros n'ont pas besoin d'être dans l'ordre, ni même d'être justes. L'analyseur est volontairement tolérant aux endroits où un fichier modifié à la main tourne mal. Les retours chariot et les espaces au début et à la fin sont retirés de chaque ligne, si bien qu'un fichier écrit sous Windows se charge tel quel. Un sous-titre dont la ligne de temps n'a pas de `-->`, ou dont les horodatages ne s'analysent pas, est sauté et le chargement du fichier continue. Un sous-titre sans texte est sauté. Les lignes d'un sous-titre sont jointes par des retours à la ligne et affichées telles qu'elles sont écrites, et `<`, `>` et `&` arrivent à l'écran tels quels au lieu d'être pris pour du balisage. Ce qui n'est *pas* pris en charge : les coordonnées de positionnement que certains outils ajoutent à la ligne de temps sont ignorées (`srtPosition` décide de la hauteur), et les balises `` et `` qu'on trouve parfois dans SubRip sont affichées comme du texte au lieu d'être appliquées. Les sous-titres peuvent se chevaucher dans le temps ; le dernier qui a commencé l'emporte. `srtLoad` répond `false` et ne charge rien quand le fichier ne peut pas être lu, quand rien dedans ne s'analyse, ou quand il n'y a pas de disque -- si bien qu'un fichier cassé est un `false` sur lequel un jeu peut agir, et non un plantage. Il affiche dans chaque cas un avertissement qui nomme le fichier. [#_the_timing] ==== La synchronisation Les horodatages sont convertis en *numéros d'image du disque* au chargement du fichier, d'après la fréquence d'images du disque chargé à ce moment-là, et le moteur compare chaque image dessinée à ces numéros. Il en découle deux choses. Premièrement, **un sous-titre survit à un saut.** Envoyer le disque n'importe où -- `discSearch`, `discSkipForward`, un pas, un framefile qui passe dans un autre segment -- affiche le sous-titre qui couvre l'endroit où il a atterri, à la prochaine image dessinée, en avant comme en arrière. Rien n'a besoin d'être rechargé et aucune horloge n'a besoin d'être remise à zéro. Deuxièmement, **le fichier doit être synchronisé sur le disque, pas sur l'horloge.** Un `.srt` découpé pour une vidéo à une autre fréquence d'images tombe au mauvais endroit, et un fichier chargé avant l'ouverture du disque ne se charge pas du tout. Chargez les sous-titres après le disque, ce qui pour un jeu ordinaire veut dire depuis le corps du script. Un sous-titre est affiché tant que l'image du disque se trouve entre ses deux images, incluses. L'affichage n'est redessiné que lorsque le sous-titre change, si bien que les sous-titres ne coûtent rien tant qu'une ligne reste affichée. [#_styling] ==== Mise en forme Les lignes sont un document RmlUi, pas une ligne de texte copiée par blit, si bien qu'elles se mettent en forme comme une GUI. Deux fichiers sont livrés dans le dossier `Singe` et extraits à côté des autres au premier lancement : * `Singe/subtitle.rml` -- le document. Il a un seul élément, `#slot`, dans lequel le moteur écrit le sous-titre en cours ou le bandeau. * `Singe/subtitle.rcss` -- l'apparence. `.cue` est le bloc qui porte la hauteur demandée par `srtPosition`, sous forme d'un `top` en ligne ; `.cue .text` est la barre de texte qu'il contient, qui par défaut est blanche sur un panneau sombre arrondi qui épouse le texte au lieu de couvrir toute l'image. Tous deux sont réécrits à chaque démarrage, comme tout ce que contient le dossier `Singe`, si bien que les modifier ne change rien bien longtemps. Pour changer le style, un jeu fournit ses propres copies : un `subtitle.rml` à côté de son script, dont le lien renvoie à une feuille à lui (un `subtitle.rcss` à côté, par exemple), et Singe dessine ce document à la place du sien ; la trace du programme le signale. Le moteur ne règle jamais que le contenu de `#slot` et le `top` de `.cue`, si bien que tout le reste -- la police, la taille, les couleurs, le panneau, une bordure, une ombre -- relève de la feuille du jeu et survit à une mise à jour du moteur. Le document est dessiné par-dessus l'image, au-dessus de l'overlay et sous les GUI en espace écran, suit le rectangle de la vidéo partout où le placent `--scalefactor`, `--shiftx`, une découpe de bezel ou `--rotate`, et ne prend aucune entrée de souris, si bien qu'un clic le traverse directement. <> utilise le même document, si bien qu'un bandeau et un sous-titre se ressemblent, et qu'un jeu qui change le style de l'un change celui des deux. Sur une machine sans GPU, il n'y a aucune GUI ; `srtLoad` analyse quand même le fichier et répond honnêtement, et rien n'est dessiné. Un avertissement le signale une fois. [#scenes3d] === Scènes 3D Singe 3 dessine une scène 3D entre la vidéo du laserdisc et l'overlay 2D. Tout ce que vous savez déjà reste valable : les sprites, le texte et l'overlay se placent par-dessus la scène, et la vidéo apparaît partout où le fond de la scène est transparent. Un jeu qui n'appelle jamais de fonction 3D n'est pas concerné, et un jeu sans disque (voir <>) peut être carrément un jeu 3D. Les sections qui suivent construisent une scène dans l'ordre où vous en écrirez une : les nœuds, puis les maillages et les matériaux, les lumières, la caméra, l'aspect de l'image dans son ensemble, les modèles, et enfin ce qui se place par-dessus tout cela. Chaque appel cité ici a une entrée dans la <>. [#_what_a_scene_is_made_of] ==== De quoi est faite une scène Tout ce qui se trouve dans une scène est un *nœud* d'un seul et même arbre, et le nœud `0` en est la racine. Un nœud a une position, une rotation et une échelle relatives à son parent, et il peut porter une seule chose : un maillage avec un matériau, une lumière, un sprite ou une ligne de texte. Un modèle chargé devient un sous-arbre de nœuds sous une seule racine, si bien que les mêmes appels déplacent aussi bien un personnage modélisé qu'un cube créé par script. La scène est en repère direct, +Y vers le haut et -Z vers l'avant, comme glTF et l'export de Blender. Les unités sont celles qu'utilisent vos modèles, et les primitives prennent les mêmes unités ; la physique (voir <>) suppose des mètres. Les rotations sont en degrés, données sous forme de trois angles d'Euler appliqués comme lacet, tangage et roulis, ou sous forme de quaternion quand la valeur vient d'un modèle. L'avant propre d'un nœud est son -Z : la caméra regarde dans cette direction, les lumières directionnelles et les projecteurs éclairent dans cette direction, et `nodeLookAt` le tourne vers un point du monde. Il y a deux façons de remplir une scène, qui se combinent librement : * *La construire par script* avec les maillages primitifs (`meshBox`, `meshSphere`, `meshPlane`, `meshCylinder`, `meshCone`, `meshTorus`, `meshHeightmap`) ou vos propres données de sommets (`meshNew`), plus des matériaux, des lumières et une caméra. * *Charger des modèles* au format binaire glTF 2.0 (`.glb`) avec `modelLoad`, puis les placer avec `modelInstance` autant de fois que vous voulez. Blender, Maya, 3ds Max et tous les grands moteurs exportent en `.glb`. Une scène minimale, c'est un maillage, une lumière et une caméra : [source,lua] ---- sceneEnable(true) sceneSetBackground(0, 0, 0, 0) -- Transparent : le disque apparaît derrière la scène. local red = materialNew() materialSetColor(red, 220, 40, 40) local cube = nodeNew() nodeSetMesh(cube, meshBox(1, 1, 1), red) local sun = lightNew(LIGHT_DIRECTIONAL) nodeSetPosition(sun, 2, 4, 3) nodeLookAt(sun, 0, 0, 0) local camera = nodeNew() nodeSetPosition(camera, 0, 1.5, 5) nodeLookAt(camera, 0, 0, 0) cameraSet(camera) function onOverlayUpdate() nodeRotate(cube, 0, 1, 0) -- Un degré par image autour de son propre Y. return OVERLAY_UPDATED end ---- `sceneEnable(true)` est l'appel que fait tout jeu 3D, et sur une machine qui ne sait pas dessiner en 3D, c'est l'appel qui échoue (voir <>). Sans caméra, la scène est vue depuis `(0, 0, 5)` en regardant l'origine, et sans appel de fond, le fond de la scène est noir opaque. [#nodes] ==== Les nœuds La transformation d'un nœud se règle d'un coup (`nodeSetPosition`, `nodeSetRotation`, `nodeSetQuaternion`, `nodeSetScale`) ou par petites touches (`nodeMove`, `nodeRotate`), toujours dans l'espace de son parent. `nodeSetParent` raccroche un nœud ailleurs en gardant sa transformation locale ; `nodeGetWorldPosition` lit où il a fini une fois que chaque parent a eu son mot à dire. `nodeSetVisible` cache un nœud et tout ce qui se trouve en dessous, et `nodeDelete` le libère avec ses enfants, ainsi que tout corps, émetteur ou son qui leur est attaché. Les noms viennent des modèles : `nodeFind(name, root)` trouve une articulation ou une pièce par le nom qu'elle avait dans le fichier, sous l'instance donnée comme `root`. Un nœud créé par script peut aussi recevoir un nom (`nodeSetName`), et c'est là que cherche `nodeFind` sans racine. [source,lua] ---- -- Une tourelle sur un char : le canon est un enfant de la tourelle, la tourelle de la caisse, -- si bien que tourner la tourelle entraîne le canon et que déplacer la caisse entraîne les deux. local hull = nodeNew() local turret = nodeNew() local barrel = nodeNew() nodeSetMesh(hull, meshBox(2, 0.8, 3.2), olive) nodeSetMesh(turret, meshCylinder(0.7, 0.5, 16), olive) nodeSetMesh(barrel, meshCylinder(0.1, 1.8, 8), steel) nodeSetParent(turret, hull) nodeSetParent(barrel, turret) nodeSetPosition(turret, 0, 0.65, 0) nodeSetPosition(barrel, 0, 0, -1.2) nodeSetRotation(barrel, 90, 0, 0) -- Couche le cylindre le long de -Z. function onOverlayUpdate() local stick = controllerGetAxis(0, GAMEPAD_AXIS_RIGHT_X) / 32767 -- De -1 à 1. nodeRotate(turret, 0, stick * 2, 0) return OVERLAY_NOT_UPDATED end ---- Deux appels font le pont entre la scène et l'overlay. `sceneProject(x, y, z)` transforme un point du monde en coordonnées de l'overlay, si bien qu'une étiquette de nom ou une barre de vie dessinée avec `fontPrint` peut suivre un objet 3D. `sceneUnproject(sx, sy, distance)` fait l'inverse : il donne le point du monde situé à cette distance sur le rayon qui passe par un pixel de l'overlay, ce qui, avec `physicsRaycast`, permet la sélection à la souris. [source,lua] ---- function onMouseMoved(x, y, dx, dy, which) -- La souris pointe le long d'un rayon partant de la caméra ; ce qu'il touche est la cible. local cx, cy, cz = nodeGetWorldPosition(camera) local fx, fy, fz = sceneUnproject(x, y, 50) local hit = physicsRaycast(cx, cy, cz, fx - cx, fy - cy, fz - cz, 50) highlighted = hit end ---- [#_meshes_and_materials] ==== Maillages et matériaux Un maillage est une géométrie ; un matériau est son apparence ; un nœud réunit l'un et l'autre (`nodeSetMesh(node, mesh, material)`). Partagez-les librement : un seul `meshBox` et un seul matériau peuvent se trouver sur mille nœuds, et c'est ce partage qui rend le moteur de rendu rapide (voir <>). `meshNew` construit un maillage à partir de tables de positions, de normales, de coordonnées de texture et d'indices quand les primitives ne suffisent pas. Un matériau commence par une couleur de base avec alpha (`materialSetColor`) et l'aspect métal-rugosité de glTF : `materialSetMetallic` et `materialSetRoughness`, de `0` à `1`. Une rugosité proche de `0` donne un reflet net comme dans un miroir, et `1` une surface mate ; un métal teinte ses reflets et ses réflexions de sa propre couleur et n'a pas de couleur diffuse à lui. Au-delà : * `materialSetEmissive` fait émettre à une surface une lumière de sa propre couleur (un écran, une lampe, du métal chauffé) ; elle n'éclaire pas les autres objets, mais elle produit du bloom (voir <>). * `materialSetUnlit` affiche la couleur telle quelle, en ignorant toutes les lumières : éléments de HUD dans la scène, lueur propre au verre d'une ampoule. * `materialSetDoubleSided` dessine l'arrière des formes ouvertes, comme un plan vu d'en dessous ou un drapeau. * `materialSetBlend` fait compter l'alpha, pour le verre, les fantômes et l'eau. Les maillages mélangés sont dessinés après tout ce qui est opaque, triés par distance, et ne projettent pas d'ombre. * `materialSetCutoff` ne garde que les texels dont l'alpha atteint le seuil et jette le reste, et c'est ainsi que se dessinent les feuilles, les clôtures, les grilles et le grillage : un seul quad, presque entièrement retiré. Contrairement au mélange, il n'a besoin d'aucun tri et projette quand même une ombre, et l'ombre est découpée elle aussi, si bien qu'un arbre projette des ombres de feuilles plutôt que l'ombre de son quad. Ce qui passe le seuil est opaque : un texel à moitié transparent est soit gardé entier, soit jeté, jamais mélangé, si bien que rien de ce qui se trouve derrière la scène (l'image du disque, surtout) ne teinte une feuille. Un seuil de 0 (la valeur par défaut) désactive le masquage. Un modèle chargé depuis glTF apporte le sien : `MASK` règle le seuil d'après le fichier, `BLEND` active le mélange, et `OPAQUE` ne fait ni l'un ni l'autre. La texture d'un matériau peut être l'image de n'importe quel sprite chargé (`materialSetTexture`), le laserdisc lui-même ou une vidéo chargée (`materialSetVideo`), l'image d'une seconde caméra (`materialSetView`, voir <>), ou un document GUI (`materialSetGui`, voir <>). Une borne d'arcade modélisée peut lire le disque sur son propre écran, ou y afficher sa page d'options. Les coordonnées de texture viennent du maillage ; les primitives appliquent une image une fois sur chaque face, et `materialSetTiling` la répète, ce que demandent les sols et les murs. Les textures ont des mipmaps et un filtrage anisotrope, si bien qu'un sol reste net jusqu'au loin ; `materialSetFilter(material, FILTER_NEAREST)` garde au contraire le pixel art bien carré. [source,lua] ---- local stone = materialNew() materialSetTexture(stone, spriteLoad(DIR .. "flagstones.png")) materialSetTiling(stone, 12, 12) -- Douze répétitions sur le plan. materialSetRoughness(stone, 0.9) local floor = nodeNew() nodeSetMesh(floor, meshPlane(24, 24), stone) local glass = materialNew() materialSetColor(glass, 180, 220, 255, 90) -- Alpha de 90 sur 255. materialSetBlend(glass, true) materialSetRoughness(glass, 0.05) ---- Quatre autres cartes donnent à une surface un détail que sa géométrie n'a pas, et un modèle chargé les apporte avec lui quand son fichier les contient : * `materialSetNormalMap` infléchit l'éclairage avec une carte de normales en espace tangent, si bien que les briques ressortent d'un mur plat. Une intensité supérieure à `1` exagère l'effet. * `materialSetOcclusionMap` assombrit les recoins que la lumière ambiante et le ciel ne peuvent pas atteindre. * `materialSetMetallicRoughnessMap` fait varier le caractère métallique et la rugosité sur la surface, rangés à la manière de glTF (la rugosité dans le vert, le métallique dans le bleu) et multipliés par les facteurs propres du matériau, alors réglez ceux-ci à `1` quand la carte doit décider. * `materialSetEmissiveMap` indique quelles parties brillent, teintées et mises à l'échelle par `materialSetEmissive`. [source,lua] ---- local brick = materialNew() materialSetTexture(brick, spriteLoad(DIR .. "brick.png")) materialSetNormalMap(brick, spriteLoad(DIR .. "brickNormal.png"), 1.5) materialSetMetallicRoughnessMap(brick, spriteLoad(DIR .. "brickMetalRough.png")) materialSetMetallic(brick, 1) materialSetRoughness(brick, 1) materialSetEmissiveMap(brick, spriteLoad(DIR .. "brickEmissive.png")) materialSetEmissive(brick, 180, 100, 40) -- Le mortier brille en orange. ---- .Textures compressées Un PNG est décodé à quatre octets par texel sur le GPU, plus encore un tiers pour ses mipmaps ; un niveau de la taille de Sponza contient ainsi 390 Mo de textures, plus qu'un Raspberry Pi 4 n'en partage avec son affichage. Les textures livrées en fichiers KTX2 (Basis Universal, créés par `util/compressTextures.py` à partir de PNG ou d'un `.gltf` entier) restent compressées sur le GPU, au quart de la taille ou moins, dans le format de blocs dont dispose la machine : BC7 sur les ordinateurs de bureau, ASTC sur les GPU Apple et le Pi 4, ETC2 sur toute puce OpenGL ES 3 (le Mali de chaque console portable, le Pi sous GLES), BC3 sur les cartes plus anciennes, et du RGBA simple là où rien d'autre n'existe, le tout à partir d'un seul fichier. Donnez un nom `.ktx2` à la place d'un sprite à `materialSetTexture` et aux appels de cartes, et un modèle dont le fichier utilise `KHR_texture_basisu` les charge de lui-même. `sceneGetStats` indique la mémoire de textures utilisée, pour que vous puissiez voir la différence. [source,lua] ---- materialSetTexture(ground, DIR .. "flagstones.ktx2") local draws, drawn, calls, textureKB = sceneGetStats() debugPrint("textures: " .. textureKB .. " KB") ---- [#_lights_and_shadows] ==== Lumières et ombres Jusqu'à huit lumières éclairent à la fois, et chacune est un nœud, si bien que les lumières se rattachent à un parent et s'animent comme n'importe quoi d'autre : * `LIGHT_DIRECTIONAL` est un soleil : seule sa direction compte, et tout est éclairé de la même façon. Une intensité de `1` éclaire en blanc une surface blanche tournée vers lui. * `LIGHT_POINT` est une ampoule, qui faiblit avec la distance, et `lightSetRange` l'éteint complètement au-delà d'une distance. Son intensité est sa luminosité à l'ampoule elle-même, la moitié à une unité de distance, si bien qu'une ampoule qui doit éclairer une pièce a besoin d'un nombre de l'ordre de quelques dizaines. * `LIGHT_SPOT` est un cône avec un angle intérieur entièrement éclairé et un angle extérieur où la lumière s'est éteinte (`lightSetCone`). `sceneSetAmbient` ajoute un peu de lumière venant de partout pour que les ombres ne soient pas noires ; un ciel le fait mieux (voir <>). Les couleurs données de `0` à `255` sont en sRGB, comme tout le reste dans Singe, et sont converties en lumière linéaire à l'entrée, si bien qu'une lumière de `128, 128, 128` est quatre fois moins lumineuse que le blanc, et non deux fois. Toute lumière peut projeter des ombres aux bords doux à partir de tout ce qui est opaque (`lightSetShadow`). Les lumières directionnelles et les projecteurs projettent par une carte d'ombres ; les lumières ponctuelles par une carte cubique qui couvre toutes les directions, si bien qu'une ampoule peut se trouver à l'intérieur de ce qu'elle éclaire (une pièce, un abat-jour, la borne) : les maillages fermés projettent aussi depuis leurs faces arrière, et la plage de profondeur de l'ombre est ajustée aux objets qui font de l'ombre autour de la lumière. `nodeSetShadow(node, false)` dispense un maillage de projeter une ombre : le verre de l'ampoule elle-même, une enseigne lumineuse, une étiquette de nom. `sceneSetShadowSize` règle la résolution de la carte (`256` à `4096`, `1024` par défaut) et échange de la netteté contre de la vitesse. L'ombre d'une lumière directionnelle est découpée en cascades le long de la vue (`sceneSetShadowCascades`, trois par défaut), chacune étant une carte ajustée à une tranche de ce que voit la caméra, si bien que les ombres restent nettes de près dans un grand niveau et portent quand même jusqu'à `sceneSetShadowDistance`. Une seule cascade ajuste une carte unique à toute la scène, ce qui convient aux petites scènes et aux caméras orthographiques. Une vue (`viewNew`) dessine avec les mêmes cartes, en prenant pour chaque point la cascade la plus fine qui le couvre, si bien qu'un moniteur montre les ombres que montre la fenêtre. [source,lua] ---- local sun = lightNew(LIGHT_DIRECTIONAL) nodeSetPosition(sun, 30, 40, 20) nodeLookAt(sun, 0, 0, 0) lightSetIntensity(sun, 2.5) lightSetColor(sun, 255, 240, 220) lightSetShadow(sun, true) sceneSetShadowSize(2048) sceneSetShadowDistance(80) -- Trois cascades jusqu'à 80 unités. local lamp = lightNew(LIGHT_POINT) nodeSetPosition(lamp, 3, 3.1, -2) lightSetColor(lamp, 255, 230, 190) lightSetIntensity(lamp, 12) lightSetRange(lamp, 14) lightSetShadow(lamp, true) -- Une carte cubique : six passes par image. nodeSetShadow(bulbGlass, false) -- Le verre qui l'entoure ne projette rien. ---- [#scenecamera] ==== La caméra et les vues N'importe quel nœud peut être la caméra (`cameraSet`) ; elle regarde le long de son propre -Z, si bien que `nodeLookAt` la vise, et l'accrocher sous un nœud en mouvement donne une caméra de poursuite. `cameraSetPerspective(fovDegrees, near, far)` est l'objectif par défaut ; `cameraSetOrthographic(height, near, far)` dessine sans perspective, pour les schémas, les scènes façon HUD et les jeux vus de profil. Gardez `near` aussi grand que la scène le permet, parce que la précision du tampon de profondeur se répartit entre `near` et `far`. [source,lua] ---- local camera = nodeNew() nodeSetParent(camera, car) nodeSetPosition(camera, 0, 2.5, 7) -- Derrière et au-dessus de la voiture, dans son espace. nodeLookAt(camera, 0, 1, 0) cameraSet(camera) cameraSetPerspective(55, 0.1, 300) ---- Une *vue* est une seconde caméra rendue dans une texture à chaque image et affichée sur un matériau : un moniteur de surveillance, un rétroviseur, un portail, une image dans l'image. `viewNew(width, height)` en crée une à cette résolution, `viewSetCamera` lui donne un nœud caméra, et `materialSetView` met son image sur n'importe quel maillage. Les vues partagent les ombres de l'image et sautent le bloom, et chacune rend de nouveau la scène, alors gardez-les peu nombreuses et petites. Chaque vue tourne les billboards (voir <>) vers sa propre caméra et dessine les matériaux transparents de l'arrière vers l'avant depuis celle-ci, si bien qu'un moniteur voit les étiquettes de nom de face et le verre dans le bon ordre. [source,lua] ---- local rearCamera = nodeNew() nodeSetParent(rearCamera, car) nodeSetPosition(rearCamera, 0, 1.2, -2) nodeSetRotation(rearCamera, 0, 180, 0) -- Regarde vers l'arrière. local rear = viewNew(384, 256) viewSetCamera(rear, rearCamera) local mirror = materialNew() materialSetView(mirror, rear) materialSetUnlit(mirror, true) local panel = nodeNew() nodeSetParent(panel, car) nodeSetMesh(panel, meshPlane(0.6, 0.2), mirror) nodeSetPosition(panel, 0, 1.6, 0.5) nodeSetRotation(panel, 90, 0, 0) -- Le plan regarde vers le haut ; on le redresse face au conducteur. ---- [#scenelook] ==== L'aspect de l'image La scène est éclairée en lumière linéaire. Les couleurs que vous donnez de `0` à `255` sont en sRGB et sont décodées à l'entrée, les textures sont décodées par le GPU au moment où elles sont échantillonnées, les lumières s'additionnent sans écrêtage, et à la fin de l'image une courbe de tons ramène le résultat vers l'écran. Ce que cela veut dire en pratique : * `sceneSetExposure(stops)` éclaircit ou assombrit toute l'image comme l'exposition d'un appareil photo : `+1` double la lumière, `-1` la divise par deux, si bien qu'une grotte et une plage peuvent partager les mêmes lumières et les mêmes matériaux. * `sceneSetTonemap` choisit la courbe : `TONEMAP_NEUTRAL` (par défaut, qui garde les couleurs là où les visionneuses glTF les gardent et ne compresse que la lumière la plus vive), `TONEMAP_ACES` (un rendu cinéma avec plus de contraste), ou `TONEMAP_NONE` (écrêtage au blanc). * `sceneSetBloom(threshold, strength)` fait briller tout ce qui est plus lumineux que le seuil : le soleil à travers une fenêtre, une ampoule, une enseigne émissive, une flamme. La lueur est prise dans la lumière de la scène avant la courbe de tons, si bien que seul ce qui est vraiment lumineux produit du bloom ; un seuil de `1` correspond au blanc. .Le ciel et sa lumière `sceneSetSky(file)` enveloppe la scène dans un panorama équirectangulaire. Un `.hdr` Radiance garde toute sa plage ; un PNG ou un JPEG fonctionne aussi. Le ciel est dessiné derrière tout le reste et, sauf avec `sceneSetEnvironment(false)`, il éclaire aussi la scène : sa couleur globale, venue de toutes les directions, remplace l'ambiance uniforme, et son reflet apparaît sur les métaux et les surfaces brillantes, net sur un miroir et de plus en plus flou à mesure que la rugosité augmente. Sans ciel, les métaux n'ont rien à refléter, et seules l'ambiance et les lumières les modèlent. `sceneSetSkyIntensity` règle ensemble le ciel et sa lumière, si bien qu'un seul panorama sert pour midi et pour le crépuscule. .Brouillard `sceneSetFog(r, g, b, near, far)` estompe tout, particules comprises, vers une couleur entre deux distances. Le ciel n'est jamais pris dans le brouillard, alors accordez la couleur du brouillard à l'horizon du ciel et le bord lointain de la scène s'y fondra. [source,lua] ---- sceneSetSky(DIR .. "harbor.hdr") sceneSetSkyIntensity(0.8) sceneSetFog(190, 200, 215, 40, 160) -- La couleur du ciel à l'horizon. sceneSetExposure(-0.5) -- Un peu plus sombre que ne le rendrait le soleil. sceneSetBloom(1.0, 0.35) -- Seul ce qui est plus lumineux que le blanc brille. ---- [#_models_and_animation] ==== Modèles et animation `modelLoad` prend un `.glb` d'après le même nom relatif au jeu que prend `spriteLoad`, et un `.game` empaqueté l'emporte avec lui. Le fichier doit être autonome : maillages, skins, animations et textures, tout dans ce seul fichier, ce qui est l'export par défaut de Blender. Un modèle qui fait référence à un `.bin` ou à une image situés à côté de lui est refusé avec un message qui nomme le fichier, pour la même raison qu'un jeu empaqueté doit être autonome. Les matériaux utilisent le modèle métal-rugosité avec `KHR_materials_emissive_strength` et `KHR_texture_basisu` ; une texture adressée par un second jeu de coordonnées de texture lit le premier jeu, et `KHR_texture_transform` est ignoré, chacun avec une ligne dans la trace du programme. Un skin peut avoir jusqu'à `128` articulations. Le chargement est la partie coûteuse ; `modelInstance` place ensuite une copie de l'arbre de nœuds du modèle, autant de fois que vous voulez, et chaque instance partage les maillages et les matériaux du fichier. `modelDelete` libère le modèle ; les instances gardent leurs nœuds, nus. [source,lua] ---- local foxModel = modelLoad(DIR .. "Fox.glb") local pack = {} for i = 1, 6 do local fox = modelInstance(foxModel) nodeSetScale(fox, 0.02) -- Le fichier est en centimètres. nodeSetPosition(fox, i * 1.5 - 5, 0, 0) pack[i] = fox end ---- Les animations d'un modèle sont listées par `modelGetAnimations` et pilotées instance par instance avec `animationPlay(node, nameOrIndex, loop, speed, fade)` sur le nœud racine de l'instance, plus des appels de pause, de reprise, d'arrêt et de saut. L'animation de nœuds (des objets qui bougent), le skinning (des personnages qui se déforment) et les morph targets (blend shapes : visages, synchronisation labiale, muscles qui gonflent) fonctionnent tous. Les animations avancent en temps réel et s'arrêtent pendant que le jeu est en pause. Un poids de morphing peut aussi être réglé par script avec `nodeSetMorph`, par nom ou par numéro, sur le nœud qui porte le maillage. Un `fade` en secondes fait un fondu enchaîné depuis ce qui jouait, si bien qu'une marche devient une course sans à-coup. Quand rien ne jouait (après `animationStop`, ou quand un ragdoll vient d'être désactivé), le fondu part de la pose dans laquelle se trouvent les nœuds, si bien qu'un personnage se relève en douceur de là où il est tombé. Jusqu'à quatre clips de plus peuvent jouer en même temps sur des couches au-dessus de la base (`animationPlayLayer`), chacun mélangé selon `animationSetLayerWeight` et limité à une partie du squelette par `animationSetLayerMask` : un regard alentour sur le cou pendant que les jambes marchent, un salut d'un bras pendant une course. [source,lua] ---- animationPlay(fox, "Walk", true) animationPlayLayer(fox, 1, "Survey", true, 1, 0.5) -- La tête tourne, mélangée en une demi-seconde animationSetLayerMask(fox, 1, nodeFind("b_Neck_04", fox)) -- ... sur le cou et au-dessus seulement. function onCollision(a, b, x, y, z, speed) if (a == fox or b == fox) and speed > 3 then animationPlay(fox, "Run", true, 1.2, 0.4) -- Plus vite, en fondu enchaîné de 0.4 s. end end ---- [#_sound_in_the_scene] ==== Le son dans la scène Un effet sonore peut être placé dans la scène pour qu'on l'entende depuis l'endroit où il se trouve. `soundSetPosition(channel, x, y, z)` place un canal en cours de lecture en un point du monde, `soundSetNode(channel, node)` lui fait plutôt suivre un nœud, et `soundSetRange(channel, near, far)` indique où il est à plein volume et où il s'est éteint. Les sons sont entendus depuis la caméra sauf si `soundSetListener` désigne un autre nœud (la tête du joueur, pour un jeu à la troisième personne), et ils se répartissent à gauche et à droite quand l'auditeur tourne. Un canal qui n'est pas placé dans la scène joue comme il l'a toujours fait, avec `soundSetPan` pour la stéréo. [source,lua] ---- local crackle = soundPlay(fireSound, -1) -- Boucle sans fin. soundSetNode(crackle, brazier) soundSetRange(crackle, 2, 18) -- Plein volume dans un rayon de 2 unités, plus rien à 18. soundSetListener(hero) ---- [#scenesprites] ==== Sprites et texte dans la scène N'importe quel nœud peut afficher une image au lieu d'un maillage. `nodeSetSprite(node, sprite, height)` place un sprite chargé (chacune des images d'un sprite animé, au choix de `nodeSetSpriteFrame`) sur un quad haut d'autant d'unités du monde, éclairé comme le reste et projetant des ombres, sauf si `nodeSetShadow` en décide autrement. `nodeSetText(node, text, height)` fait de même avec une ligne de texte dans la police et la couleur sélectionnées, sans éclairage. Pour le reste du moteur, les deux sont des maillages : ils se rattachent à un parent, changent d'échelle et acceptent le mélange d'un matériau. `nodeSetBillboard` tourne un nœud face à la caméra à chaque image : `BILLBOARD_ALL` complètement (étiquettes de nom, lueurs, imposteurs), `BILLBOARD_Y` autour de son propre axe vertical seulement (arbres, barres de vie, tout ce qui doit rester debout), et `BILLBOARD_NONE` pour arrêter. Cela fonctionne aussi sur les maillages. [source,lua] ---- local tag = nodeNew() nodeSetParent(tag, fox) nodeSetPosition(tag, 0, 110, 0) -- Dans les centimètres du Fox, au-dessus de sa tête. nodeSetScale(tag, 50) -- Annule l'échelle de 0.02 de l'instance. nodeSetText(tag, "FOX", 0.6) nodeSetBillboard(tag, BILLBOARD_ALL) nodeSetShadow(tag, false) local icon = nodeNew() nodeSetParent(icon, fox) nodeSetPosition(icon, 0, 150, 0) nodeSetScale(icon, 50) nodeSetSprite(icon, spriteLoad(DIR .. "crate.png"), 0.5) nodeSetBillboard(icon, BILLBOARD_Y) ---- [#scenefront] ==== Devant l'overlay La scène est normalement dessinée sous l'overlay, si bien que tout ce qui est dessiné sur l'overlay la recouvre. `nodeSetLayer(node, LAYER_FRONT)` place plutôt un nœud, et tout ce qui lui est rattaché, dans la couche de premier plan : la même caméra le dessine après l'overlay, éclairé par les mêmes lumières et fini par la même exposition, la même courbe de tons et le même bloom, si bien qu'un logo qui tourne, un titre en 3D ou un modèle sur un tableau des meilleurs scores peut se dresser par-dessus des illustrations 2D. La couche de premier plan est une image à part, effacée en transparent, si bien que l'overlay apparaît partout où elle ne dessine rien, et rien dans la scène derrière l'overlay ne peut la cacher. Les GUI et les particules dessinées par-dessus l'overlay passent toujours au-dessus d'elle. Les nœuds de premier plan projettent toujours des ombres sur la scène. Le ciel, les particules 3D et les lignes de débogage restent dans la scène, et les vues (`viewNew`) ne dessinent que les nœuds de la scène. La couche coûte une seconde passe de rendu, et seulement pour les images où elle contient quelque chose. [source,lua] ---- -- Une pièce d'or qui tourne par-dessus l'écran-titre 2D. local coin = nodeNew() nodeSetMesh(coin, meshCylinder(1, 0.2, 48), gold) nodeSetRotation(coin, 90, 0, 0) nodeSetLayer(coin, LAYER_FRONT) function onOverlayUpdate() overlayClear() spriteDraw(titleArt, 0, 0) nodeRotate(coin, 0, 2, 0) return OVERLAY_UPDATED end ---- [#sceneterrain] ==== Le terrain `meshHeightmap(image, sizeX, sizeY, sizeZ)` transforme une image en niveaux de gris en terrain vallonné : un sommet par pixel, le noir en bas et le blanc à `sizeY` de haut, avec des normales tirées des pentes et des coordonnées de texture sur l'ensemble. Donnez à son matériau une texture et `materialSetTiling` pour la répéter toutes les quelques unités. Les images peuvent faire jusqu'à 1025 pixels dans chaque sens ; une carte de 257 pixels sur 120 unités donne un sommet toutes les demi-unités, ce qui suffit largement pour des collines. Un corps `BODY_STATIC` avec `SHAPE_MESH` sur ce nœud devient un champ de hauteur, bien moins coûteux que ne le seraient les triangles, si bien que les voitures y roulent et que les joueurs y marchent tel quel. `terrainGetHeight(node, x, z)` indique la hauteur du sol en un point, pour y placer des choses sans lancer de rayon. [source,lua] ---- local ground = materialNew() materialSetTexture(ground, DIR .. "grass.ktx2") materialSetTiling(ground, 24, 24) local hills = nodeNew() nodeSetMesh(hills, meshHeightmap(DIR .. "hills.png", 120, 14, 120), ground) bodyNew(hills, BODY_STATIC, SHAPE_MESH) -- Un champ de hauteur. for i = 1, 20 do local x, z = math.random(-50, 50), math.random(-50, 50) local tree = modelInstance(treeModel) nodeSetPosition(tree, x, terrainGetHeight(hills, x, z), z) end ---- [#_navigation] ==== La navigation Un maillage de navigation indique aux personnages où ils peuvent marcher. `navNew(radius, height, maxSlope, maxStep)` en crée un pour une taille d'agent donnée, `navAddNode` lui fournit la géométrie du niveau (la racine d'une instance de modèle fournit tout le modèle), et `navBuild` précalcule la surface praticable : les sols, les rampes et les escaliers que l'agent peut gravir, moins une marge autour de chaque mur et de chaque colonne. Précalculer Sponza prend un dixième de seconde sur un ordinateur de bureau ; `navSave` et `navLoad` gardent un maillage précalculé dans le dossier de données du jeu pour les machines où cela prend plus longtemps, et un maillage livré avec le jeu se charge de la même façon. `navDraw` affiche le maillage par-dessus la scène pendant que vous mettez au point les paramètres. Les requêtes répondent ensuite aux questions que pose un jeu : `navPath` renvoie les angles du plus court chemin entre deux points, `navNearest` ramène un point sur le maillage, `navRaycast` indique si une marche en ligne droite est bloquée, et `navRandomPoint` choisit un endroit vers lequel errer. Les agents (`navAgentNew`) parcourent le maillage pour vous. `navAgentMoveTo` en envoie un quelque part, et il contourne les autres agents en chemin, en déplaçant son nœud (ou en pilotant un contrôleur de joueur posé dessus, avec `navAgentSetPlayer`) et en appelant `onNavArrived` quand il arrive. `navAgentGetVelocity` vous dit s'il faut jouer la marche ou la course. [source,lua] ---- local nav = navNew(0.4, 1.6, 45, 0.3) -- Un agent de 0.8 de large et 1.6 de haut, qui gravit 0.3. navAddNode(nav, level) navBuild(nav) local guards = {} for i = 1, 6 do local guard = modelInstance(guardModel) local x, y, z = navRandomPoint(nav) nodeSetPosition(guard, x, y, z) animationPlay(guard, "Walk", true) local agent = navAgentNew(nav, guard, 0.35, 1.6, 1.4) navAgentMoveTo(agent, navRandomPoint(nav)) guards[agent] = guard end function onNavArrived(agent) navAgentMoveTo(agent, navRandomPoint(nav)) -- En route vers l'endroit suivant. end ---- [#sceneperformance] ==== Performances et configuration requise La scène a besoin d'un GPU qui parle Vulkan (Linux), Direct3D 12 (Windows 10 et versions ultérieures), Metal (macOS) ou OpenGL ES 3.1. Le backend ES est ce qui fait entrer dans le jeu les consoles portables Linux bon marché et le Raspberry Pi, dont les puces Mali et VideoCore ont un pilote GLES mûr et soit pas de Vulkan du tout, soit un Vulkan immature ; il est choisi automatiquement quand aucun des trois autres n'est disponible, et il dessine la même image à partir des mêmes shaders. Le Raspberry Pi 4 reste le Pi minimum pour la 3D : le Pi 3 n'a qu'OpenGL ES 2.0, qui ne peut exprimer ni le skinning ni les morph targets de la scène. Sur une machine qui n'a aucun d'eux, les jeux 2D tournent exactement comme avant (physique 2D comprise) et le premier appel 3D termine le jeu avec une erreur qui nomme le problème. Les maillages hors du champ de la caméra sont sautés, et les copies d'un même maillage avec le même matériau sont dessinées en un seul appel instancié, si bien qu'une forêt faite d'un seul et même arbre coûte à peu près ce que coûte un arbre. `sceneGetStats` indique les dessins collectés, combien étaient dans le champ, combien d'appels de dessin cela a pris, et la mémoire de textures utilisée. Pour l'aider : * Partagez les maillages et les matériaux entre les nœuds (un seul `meshBox`, beaucoup de nœuds ; un seul modèle, beaucoup d'appels à `modelInstance`) plutôt que d'en créer un par objet. * Les maillages avec skinning ou morphing et les maillages mélangés sont dessinés un par un ; limitez-les à ce dont la scène a besoin. * L'anticrénelage (multiéchantillonnage 4x) est actif partout où le GPU le propose ; `sceneSetAntialias(false)` fait regagner de la vitesse sur un Pi, tout comme un `sceneSetShadowSize` plus petit et moins de lumières ponctuelles projetant des ombres, chacune coûtant six passes. * Surveillez le nombre de triangles et la taille des textures sur les petites cartes, livrez des textures KTX2, et chargez un modèle une fois plutôt que deux. [source,lua] ---- function onOverlayUpdate() local draws, drawn, calls, textureKB = sceneGetStats() overlayClear() local text = string.format("%d/%d meshes, %d calls, %d KB", drawn, draws, calls, textureKB) fontPrint(10, 10, text) return OVERLAY_UPDATED end ---- [#physics] === Physique Les corps, les articulations, les déclencheurs et les lancers de rayons vivent dans la scène 3D et reposent sur Jolt Physics. Un corps s'attache à un nœud comme un maillage, et dès lors le nœud et le corps ne font qu'un : la forme du corps est dimensionnée en unités du monde et mise à l'échelle par le nœud, et quel que soit celui des deux qui bouge, l'autre suit. Les unités sont les mètres, les kilogrammes et les secondes, et la gravité vaut par défaut `0, -9.81, 0`. La physique n'a pas besoin de GPU ; voir <>. [#_bodies] ==== Corps `bodyNew(node, type, shape, a, b, c)` donne à un nœud un corps de l'un de trois types : * Un corps *statique* (`BODY_STATIC`) reste là où était le nœud quand le corps a été créé : sols, murs, la borne. Déplacer son nœud ensuite ne déplace pas le corps. * Un corps *cinématique* (`BODY_KINEMATIC`) suit son nœud partout où le script ou une animation le déplace, et écarte les corps dynamiques de son chemin sans être poussé lui-même : une raquette, une plateforme mobile, la main d'un personnage. * Un corps *dynamique* (`BODY_DYNAMIC`) est déplacé par la simulation et entraîne son nœud : il tombe, rebondit, glisse et se fait pousser. Ne déplacez pas son nœud vous-même ; donnez-lui plutôt des vitesses, des forces ou des impulsions (`bodySetVelocity`, `bodyApplyForce`, `bodyApplyImpulse`). La forme est une boîte, une sphère, une capsule ou un cylindre dimensionné par `a`, `b` et `c` (`SHAPE_BOX` prend la largeur, la hauteur et la profondeur ; `SHAPE_SPHERE` un rayon ; `SHAPE_CAPSULE` et `SHAPE_CYLINDER` un rayon et une hauteur le long de Y), une enveloppe convexe du maillage du nœud (`SHAPE_HULL`, tout ce qui se trouve sous le nœud, si bien que la racine d'une instance de modèle convient), ou les triangles exacts du maillage (`SHAPE_MESH`, corps statiques et cinématiques seulement ; sur un maillage de carte de hauteur, c'est un champ de hauteurs, voir <>). [source,lua] ---- floor = nodeNew() nodeSetMesh(floor, meshBox(14, 0.2, 14), stone) nodeSetPosition(floor, 0, -1.9, 0) bodyNew(floor, BODY_STATIC, SHAPE_BOX, 14, 0.2, 14) for i = 1, 6 do local crate = nodeNew() nodeSetMesh(crate, meshBox(0.6, 0.6, 0.6), wood) nodeSetPosition(crate, -1, -1.5 + (i - 1) * 0.62, 0) bodyNew(crate, BODY_DYNAMIC, SHAPE_BOX, 0.6, 0.6, 0.6) bodySetMass(crate, 5) end paddle = nodeNew() nodeSetMesh(paddle, meshBox(0.3, 1.2, 1.6), red) nodeSetPosition(paddle, -4, -1.2, 0) bodyNew(paddle, BODY_KINEMATIC, SHAPE_BOX, 0.3, 1.2, 1.6) function onOverlayUpdate() nodeMove(paddle, 0.06, 0, 0) -- La raquette cinématique balaie la pile. return OVERLAY_UPDATED end ---- .Masse et matériaux La masse d'un corps dynamique vient du volume de sa forme à la densité de l'eau, si bien qu'une boîte de la taille d'une porte pèse 140 kilogrammes et qu'une légère poussée ne la fera pas bouger. Réglez `bodySetMass` sur tout ce qui doit paraître léger, et dimensionnez les impulsions selon la masse : une impulsion de `mass * speed` donne cette vitesse. `bodySetFriction` et `bodySetBounce` font la glace et le caoutchouc ; `bodySetEnabled` retire un corps du monde et l'y remet sans le perdre. .Synchronisation Le monde avance d'un pas fixe soixante fois par seconde selon l'horloge, après que les animations ont posé leurs nœuds et avant le rendu de la scène, si bien qu'un corps cinématique mû par une animation pousse avec la bonne vitesse. Mettre le jeu en pause met la physique en pause ; `physicsSetEnabled(false)` la met en pause à elle seule. `bodyIsResting` indique quand un corps s'est stabilisé, c'est-à-dire quand un objet lancé s'est arrêté et qu'un score peut être compté. Un corps, un joueur, un ragdoll, un corps mou ou un véhicule dont le nœud a été supprimé est libéré au début de l'image suivante, activé ou non et en pause ou non, si bien que son emplacement redevient libre ; un handle de nœud que `nodeNew` distribue plus tard à nouveau ne porte rien de la physique de l'ancien nœud, et `playerExists` et ses semblables répondent `false` pour lui jusqu'à ce qu'il ait la sienne. [#_events_and_queries] ==== Événements et requêtes Définissez `onCollision(nodeA, nodeB, x, y, z, speed)` et Singe l'appelle une fois pour chaque nouveau contact, avec le point et la vitesse auxquels les deux corps se sont rencontrés, si bien qu'un petit choc et un fracas peuvent sonner différemment. Un corps devenu déclencheur par `bodySetTrigger` ne pousse rien mais signale ce qui y entre et en sort par `onTrigger(trigger, other, entered)` : une ligne d'arrivée, une porte, une zone dangereuse. `physicsRaycast` trouve le premier corps le long d'un rayon ; avec `sceneUnproject` pour le rayon, c'est la sélection à la souris (voir <>). [source,lua] ---- finish = nodeNew() nodeSetPosition(finish, 0, 1, -40) bodyNew(finish, BODY_STATIC, SHAPE_BOX, 12, 2, 0.5) bodySetTrigger(finish, true) function onTrigger(trigger, other, entered) if trigger == finish and other == car and entered then lapDone() end end function onCollision(a, b, x, y, z, speed) if speed > 6 then local channel = soundPlay(crashSound) soundSetPosition(channel, x, y, z) end end ---- [#_joints] ==== Articulations `jointHinge`, `jointBall` et `jointSlider` relient deux corps, ou un corps et le monde quand le second nœud vaut `-1` : portes, pendules, tiroirs, les roues d'une voiture vue de profil. L'ancre et l'axe sont donnés dans l'espace du monde, là où se trouvent les corps quand l'articulation est créée. Les charnières et les glissières acceptent des limites (`jointSetLimits`) : un angle de charnière positif est une rotation du premier nœud autour de l'axe selon la règle de la main droite, et une distance de glissière positive va dans le sens de l'axe. [source,lua] ---- door = nodeNew() nodeSetMesh(door, meshBox(0.9, 2.0, 0.05), oak) nodeSetPosition(door, 0.45, 1.0, 0) bodyNew(door, BODY_DYNAMIC, SHAPE_BOX, 0.9, 2.0, 0.05) bodySetMass(door, 20) local hinge = jointHinge(door, -1, 0, 1.0, 0, 0, 1, 0) -- Sur le chambranle, autour de Y. jointSetLimits(hinge, 0, 110) -- S'ouvre dans un sens, 110 degrés. ---- Les corps sur des instances de modèle fonctionnent comme les autres : `bodyNew(instanceRoot, BODY_DYNAMIC, SHAPE_HULL)` entoure tout le modèle d'une enveloppe convexe. Un personnage skinné est de préférence un joueur (voir plus bas) ou une capsule cinématique qui pousse les choses plutôt que d'être poussée. [#physics2d] ==== Jeux 2D La physique n'est pas liée à la scène 3D. Les nœuds existent sur toutes les machines, avec ou sans GPU, si bien qu'un jeu 2D peut donner des corps à des nœuds, appeler `physicsSet2D(true)` pour qu'ils ne se déplacent qu'en X et Y et ne tournent qu'autour de Z, travailler en coordonnées de l'overlay avec une gravité dirigée vers le bas de l'écran, et dessiner des sprites là où sont les nœuds. C'est la forme qui tourne sur un Raspberry Pi 3, qui n'a pas de 3D. [source,lua] ---- physicsSet2D(true) physicsSetGravity(0, 900, 0) -- Pixels par seconde au carré ; le Y de l'overlay descend. floor = nodeNew() nodeSetPosition(floor, 360, 470, 0) bodyNew(floor, BODY_STATIC, SHAPE_BOX, 720, 20, 50) crate = nodeNew() nodeSetPosition(crate, 200, -40, 0) bodyNew(crate, BODY_DYNAMIC, SHAPE_BOX, 40, 40, 50) bodySetMass(crate, 1) function onOverlayUpdate() local x, y = nodeGetPosition(crate) local _, _, angle = nodeGetRotation(crate) spriteRotate(crateArt, angle) spriteDraw(crateArt, x, y, true) return OVERLAY_UPDATED end ---- Donnez à chaque corps 2D une certaine profondeur (la troisième dimension) pour que les formes se chevauchent en Z ; `50` convient. Les cercles sont des `SHAPE_SPHERE`. Les joueurs et les émetteurs 2D (voir <>) fonctionnent aussi dans ce monde, et un jeu de plateforme est un joueur avec `playerMove(node, vx)`. [#_players] ==== Joueurs Un corps va là où les forces l'envoient ; un *joueur* va là où le script le dit. `playerNew` met un contrôleur de personnage sur un nœud : une capsule par défaut (`playerNew(node, radius, height)`), ou n'importe quelle forme convexe de `bodyNew` (`playerNew(node, SHAPE_BOX, w, h, d)` pour un robot, `playerNew(node, SHAPE_SPHERE, r, 0)` pour quelque chose qui roule mais monte quand même les marches, `SHAPE_HULL` pour la silhouette propre du modèle ; trois arguments signifient toujours une capsule, si bien qu'une sphère donne toutes ses dimensions). La forme repose sur l'origine du nœud, qui correspond aux pieds. À chaque image, le script donne à `playerMove` la vitesse horizontale qu'il veut, et le contrôleur marche sur les sols, monte les marches jusqu'à `playerSetStep`, glisse le long des murs, refuse les pentes plus raides que `playerSetSlope`, tombe sous l'effet de la gravité, se laisse porter par les plateformes mobiles et écarte de son chemin les corps dynamiques légers (`playerSetPush`). `playerJump` ne fonctionne qu'avec le sol sous les pieds et dit s'il a sauté. La rotation du nœud appartient au script : tournez-le face à la direction où il marche, et accrochez le modèle dessous comme enfant. [source,lua] ---- hero = nodeNew() nodeSetPosition(hero, 0, 0, 0) playerNew(hero, 0.3, 1.0) -- Une capsule de rayon 0.3, haute de 1, pieds au nœud. fox = modelInstance(modelLoad(DIR .. "Fox.glb")) nodeSetParent(fox, hero) function onInputPressed(switch) if switch == SWITCH_BUTTON1 then playerJump(hero, 4.5) -- Il faut le sol sous les pieds. end end function onOverlayUpdate() local vx = controllerGetAxis(0, GAMEPAD_AXIS_LEFT_X) / 32767 * 3 local vz = controllerGetAxis(0, GAMEPAD_AXIS_LEFT_Y) / 32767 * 3 playerMove(hero, vx, vz) if vx ~= 0 or vz ~= 0 then nodeSetRotation(hero, 0, math.deg(math.atan(-vx, -vz)), 0) end return OVERLAY_NOT_UPDATED end ---- Les joueurs signalent `onCollision` quand ils commencent à toucher un corps et `onTrigger` quand ils entrent dans un capteur ou en sortent, avec leur nœud à la place qu'occuperait le nœud d'un corps, si bien qu'une porte, un objet à ramasser et une fosse de lave fonctionnent de la même façon pour un joueur que pour une balle. `playerIsOnGround` et `playerGetGround` disent sur quoi le joueur se tient, pour les bruits de pas et pour descendre exprès d'une plateforme. Dans un monde 2D, un joueur est un personnage de jeu de plateforme : `playerMove(node, vx)` court le long de X, la gravité est dirigée vers le bas de l'écran, et tout le reste est identique. Les tolérances du contrôleur s'adaptent à la forme, si bien qu'une capsule de 30 pixels dans un jeu de sprites se comporte comme une capsule de 0,3 mètre dans un jeu 3D. [#_vehicles] ==== Véhicules Un *véhicule* est un corps dynamique (le châssis, créé avec `bodyNew` comme tout corps, tourné vers -Z avec Y vers le haut) que Jolt conduit par des roues : `vehicleNew(node, VEHICLE_CAR)` pour tout ce qui a des essieux, `VEHICLE_MOTORCYCLE` pour deux roues en ligne qui s'inclinent dans les virages et tiennent en équilibre, ou `VEHICLE_TANK` pour des roues sur deux chenilles qui tournent sur place. Les roues sont des nœuds : `vehicleAddWheel(node, wheelNode, radius, width, suspension)` prend la position de la roue d'après l'endroit où se trouve son nœud par rapport au châssis, et dès lors le moteur de jeu pose ce nœud à chaque image (son axe X est l'essieu, si bien qu'une roue en `meshCylinder` demande un enfant tourné de 90 degrés). Les roues trouvées dans un modèle avec `nodeFind("wheelFL", car)` fonctionnent de même. Par défaut, les roues avant braquent et toutes sont motrices ; `vehicleSetWheel` en décide autrement. À chaque image, le script tient le rôle du conducteur : `vehicleDrive(node, forward, right, brake, handBrake)`. Le moteur, la boîte automatique et les différentiels sont ceux de Jolt, avec les valeurs par défaut d'une voiture de 1500 kg ; `vehicleSetEngine`, `vehicleSetGears`, `vehicleSetSuspension`, `vehicleSetSteering`, `vehicleSetBrakes` et `vehicleSetAntiRoll` les règlent, et toute modification reconstruit le véhicule sur place au pas suivant. `vehicleGetSpeed`, `vehicleGetRpm` et `vehicleGetGear` alimentent un tableau de bord. Un véhicule reste un corps : il saute des tremplins, heurte les murs, bouscule les caisses et signale `onCollision`. [source,lua] ---- car = nodeNew() nodeSetMesh(car, meshBox(1.8, 0.5, 4.0), paint) nodeSetPosition(car, 0, 1, 0) bodyNew(car, BODY_DYNAMIC, SHAPE_BOX, 1.8, 0.5, 4.0) bodySetMass(car, 1500) vehicleNew(car, VEHICLE_CAR) local corners = { { -0.95, -0.2, -1.4 }, { 0.95, -0.2, -1.4 }, -- Avant gauche et droite ... { -0.95, -0.2, 1.4 }, { 0.95, -0.2, 1.4 }, -- ... arrière gauche et droite. } for i, p in ipairs(corners) do local wheel = nodeNew() nodeSetParent(wheel, car) nodeSetPosition(wheel, p[1], p[2], p[3]) vehicleAddWheel(car, wheel, 0.35, 0.25, 0.4) end function onOverlayUpdate() local throttle = controllerGetAxis(0, GAMEPAD_AXIS_RIGHT_TRIGGER) / 32767 local brake = controllerGetAxis(0, GAMEPAD_AXIS_LEFT_TRIGGER) / 32767 local steer = controllerGetAxis(0, GAMEPAD_AXIS_LEFT_X) / 32767 vehicleDrive(car, throttle, steer, brake, 0) return OVERLAY_NOT_UPDATED end ---- Les chars prennent `forward` comme accélérateur et `right` comme virage (sans accélérateur, ils pivotent sur place), et ignorent les réglages de direction, de freins et d'antiroulis, puisque leurs chenilles freinent et tournent d'elles-mêmes. Le ressort d'inclinaison de la moto est proportionné au châssis, si bien qu'une moto légère tient en équilibre aussi bien qu'une lourde. Les véhicules à roues demandent un monde 3D ; une voiture 2D vue de profil est un corps avec deux roues sur charnières, ce que les appels d'articulation savent déjà construire. [#_water] ==== Eau `bodySetWater(node, density, linearDrag, angularDrag)` remplit d'eau un corps statique : le dessus de sa boîte est la surface, et chaque corps dynamique à l'intérieur flotte, coule ou roule selon la flottabilité de Jolt par rapport à cette surface (la partie immergée de la forme compte, si bien qu'une caisse inclinée se redresse), ralenti par les traînées. `bodySetCurrent` donne un courant à l'eau. `density` est un multiplicateur, `1` pour l'eau, et le `bodySetBuoyancy` propre à chaque corps flottant (par défaut `1.2`, qui flotte doucement ; en dessous de `1`, il coule) le module. Le volume est un déclencheur, si bien que `onTrigger` se déclenche à l'entrée et à la sortie, pour les éclaboussures. L'eau n'est pas dessinée : donnez au nœud du volume un matériau en mélange, ou aucun. Un joueur dans l'eau nage : `playerIsSwimming` le dit, `playerMove(node, vx, vy, vz)` dirige alors sur trois axes, il coule lentement sans commande verticale (`playerSetSwim` règle cette vitesse et la traînée de l'eau), et le courant l'emporte. Un bateau est une coque flottante à laquelle `vehicleNew(node, VEHICLE_BOAT)` donne une hélice et un gouvernail : `vehicleSetThrust` dit avec quelle force elle pousse et à quel endroit de la coque, `vehicleSetRudder` avec quelle force il tourne, et `vehicleDrive` fonctionne comme pour une voiture, le tout seulement tant que l'hélice est sous une surface. [source,lua] ---- pool = nodeNew() nodeSetMesh(pool, meshBox(8, 3, 12), blue) -- Bleu en mélange, dessiné comme l'eau. nodeSetPosition(pool, 0, -1.5, 0) -- Son dessus, à Y = 0, est la surface. bodyNew(pool, BODY_STATIC, SHAPE_BOX, 8, 3, 12) bodySetWater(pool, 1.0, 0.6, 0.2) bodySetCurrent(pool, 0.5, 0, 0) -- Dérive vers +X. bodySetBuoyancy(crate, 1.6) -- Flotte haut. bodySetBuoyancy(anchor, 0.3) -- Coule. ---- [#_ragdolls] ==== Ragdolls `ragdollNew(node)` cherche le skin sous une instance de modèle et transforme le squelette en recette de ragdoll : chaque articulation qui a une articulation enfant devient une capsule le long de cet os, reliée à l'os le plus proche au-dessus d'elle par une articulation swing-twist aux limites raisonnables, et les articulations terminales (doigts, oreilles, le bout d'une queue) suivent leurs parents. Rien ne bouge avant `ragdollActivate` : alors l'animation s'arrête, des corps sont créés là où sont les os, et à partir de ce moment la physique pilote les nœuds des articulations, si bien que le maillage skinné s'affaisse, heurte le sol et s'immobilise avec le reste du monde. `ragdollApplyImpulse` pousse un os désigné par le nom de son articulation. `ragdollSetStrength` motorise chaque articulation vers la pose dans laquelle le ragdoll a été activé, ce qui change une chute molle en titubement ou permet à un personnage à terre de ramener ses membres ; cela ne remet pas un corps debout, puisque rien ne prend appui sur le sol. `ragdollDeactivate` rend le squelette à l'animation là où il gît, et un `animationPlay` avec un fondu le relève alors en douceur depuis cette position. Réglez un os qui a l'air faux avec `ragdollSetJoint` avant l'activation. [source,lua] ---- fox = modelInstance(foxModel) ragdollNew(fox) -- Recette tirée du skin ; toujours animé. function onCollision(a, b, x, y, z, speed) if (a == hero or b == hero) and speed > 3 then playerSetEnabled(hero, false) ragdollActivate(fox) -- La physique pilote désormais le squelette. ragdollApplyImpulse(fox, "b_Spine02_03", 40, 30, 0) end end ---- [#_soft_bodies] ==== Corps mous Le tissu, la corde et la gelée sont des maillages dont les sommets sont simulés. `softNew(node, SOFT_CLOTH)` transforme le maillage du nœud en tissu, ses sommets (soudés par position) en particules reliées par des contraintes d'étirement, de cisaillement et de flexion, là où le nœud l'a placé dans le monde ; un `meshPlane(width, depth, columns, rows)` subdivisé est le point de départ habituel. `softNew(node, SOFT_BODY)` fait de même pour un maillage fermé et `softSetPressure` le gonfle : un ballon, une gelée. `softNew(node, SOFT_ROPE, x, y, z, segments, radius)` tend une chaîne de particules de la position du nœud jusqu'à ce point et donne au nœud un maillage tubulaire autour d'elles (réglez son apparence avec `nodeSetMaterial`). Les corps mous entrent en collision avec tout ce qui est rigide, tombent sous l'effet de la gravité et sont dessinés par le maillage dont ils sont issus, que le moteur réécrit à chaque image. `softPin` maintient la particule la plus proche là où elle est ou à un nœud (le bord du drapeau au mât, le haut de la corde à la grue) ; `softSetStiffness`, `softSetMass` et `softSetDamping` règlent le matériau. Un nœud dont le maillage est partagé avec d'autres nœuds les modifie aussi, alors donnez à un corps mou un maillage à lui. [source,lua] ---- flag = nodeNew() nodeSetMesh(flag, meshPlane(1.6, 1.0, 16, 10), red) nodeSetPosition(flag, -4.2, 2.4, 0) nodeSetRotation(flag, 90, 0, 0) -- Suspendu à la verticale. softNew(flag, SOFT_CLOTH) for _, y in ipairs({ 1.9, 2.15, 2.4, 2.65, 2.9 }) do softPin(flag, -5, y, 0) -- Le long du mât. end ---- Le coût croît avec le nombre de particules : une toile de 20 sur 20 compte 441 particules et passe bien sur un Raspberry Pi 4 ; limitez le tissu à quelques centaines de particules et une corde à une douzaine de segments. Les particules entrent en collision avec les corps rigides, pas avec les faces, si bien qu'un maillage assez fin (des particules plus proches que la plus petite chose qui atterrira dessus) est ce qui empêche une caisse de passer au travers. Il n'y a pas d'autocollision : le tissu peut se traverser lui-même quand il est froissé. Les maillages skinnés ne peuvent pas être mous. [#_seeing_the_physics] ==== Voir la physique Quand une caisse passe à travers le sol ou qu'une articulation plie dans le mauvais sens, `physicsSetDebug` dessine ce que la simulation contient réellement, sous forme de lignes par-dessus la scène, à chaque image : * `DEBUG_SHAPES` : les corps en fil de fer, colorés selon leur état (endormi, en mouvement, statique), avec les joueurs en jaune. * `DEBUG_CONSTRAINTS` : les articulations, les articulations de ragdoll et les contraintes des véhicules, avec leurs limites. * `DEBUG_CONTACTS` : une croix rouge là où des corps se sont rencontrés pendant ce pas. * `DEBUG_VELOCITIES` : une ligne le long de la vitesse de chaque corps en mouvement. * `DEBUG_STATIC` : les formes des corps statiques aussi, qui restent masquées sinon parce qu'un maillage de niveau fait beaucoup de lignes. Additionnez-les, ou utilisez `DEBUG_ALL` pour les quatre premiers. `DEBUG_NONE` le désactive, et il ne coûte rien tant qu'il est désactivé. Un script dessine ses propres lignes de la même façon avec `lineDraw` (un lancer de rayon, un chemin, une boîte englobante), une image à la fois comme l'overlay, et `navDraw` montre un maillage de navigation. [source,lua] ---- if debugging then physicsSetDebug(DEBUG_SHAPES + DEBUG_CONTACTS) end function onOverlayUpdate() local hit, hx, hy, hz = physicsRaycast(gunX, gunY, gunZ, aimX, aimY, aimZ, 60) if hit then lineDraw(gunX, gunY, gunZ, hx, hy, hz, 255, 255, 0) end return OVERLAY_NOT_UPDATED end ---- [#_where_it_runs] ==== Où elle tourne La physique tourne sur le CPU et n'a pas besoin de GPU, mais la bibliothèque est compilée pour SSE4.1 sur x86 (2008 et après) et NEON sur le Pi ; une machine en deçà obtient une erreur dès le premier `bodyNew`. La simulation utilise tous les cœurs sauf un, si bien qu'un Pi à quatre cœurs avance ses pas sur trois fils d'exécution ; la trace du programme dit combien. [#particles] === Particules Étincelles, fumée, feu, pluie, poussière, magie : tout ce qui est fait de nombreuses petites choses éphémères est l'affaire d'un *émetteur*. Un émetteur contient une recette (combien par seconde, combien de temps elles vivent, à quelle vitesse et dans quelle direction elles partent, ce qui les attire, comment elles grandissent, s'estompent et tournent) et une réserve de particules qu'il anime d'après cette recette. Le script règle la recette une fois, puis démarre l'émetteur pour un flux régulier (`emitterStart`), le fait jaillir d'un coup pour une bouffée ponctuelle (`emitterBurst`), ou les deux. Le moteur déplace chaque particule vivante à chaque image, et cesse de les déplacer pendant que le jeu est en pause. Une seule API d'émetteur sert l'overlay et la scène 3D ; seuls les unités et le dessin diffèrent. Tout ce qui est donné sous forme de minimum et de maximum (`emitterSetLife`, `emitterSetSpeed`, `emitterSetSize`) est tiré au hasard entre les deux pour chaque particule. [#_2d_emitters] ==== Émetteurs 2D `emitterNew()` sans argument crée un émetteur 2D. Il vit en coordonnées de l'overlay, Y vers le bas, si bien que la gravité d'une fontaine est un Y positif. Déplacez-le avec `emitterSetPosition`, et dessinez-le depuis `onOverlayUpdate` avec `emitterDraw`. L'overlay lui-même est une image que le CPU peint, si bien que les particules n'y sont pas peintes : le GPU les dessine, soit au-dessus de tout l'overlay (`PARTICLE_OVER`, par défaut), soit en dessous de lui et au-dessus de la vidéo et de la scène 3D (`PARTICLE_UNDER`, pour de la fumée derrière le score). Les émetteurs 2D tournent sur toutes les machines où tourne Singe, Raspberry Pi 3 compris, et vont bien avec la physique 2D : lisez la position du nœud d'un corps et donnez-la à `emitterSetPosition`. [source,lua] ---- sparks = emitterNew() emitterSetBlend(sparks, PARTICLE_ADD) emitterSetLife(sparks, 0.3, 0.7) emitterSetSpeed(sparks, 80, 200) emitterSetDirection(sparks, 0, -1) -- Vers le haut de l'écran. emitterSetSpread(sparks, 70) emitterSetGravity(sparks, 0, 500) emitterSetSize(sparks, 5, 1) emitterSetColor(sparks, 255, 255, 200, 255, 255, 120, 0, 0) function onOverlayUpdate() overlayClear() emitterSetPosition(sparks, grinderX, grinderY) emitterDraw(sparks) return OVERLAY_UPDATED end ---- [#_3d_emitters] ==== Émetteurs 3D `emitterNew(node)` crée un émetteur 3D attaché à un nœud. Les particules naissent à la position du nœud dans le monde (réparties sur `emitterSetRadius`), en unités du monde, et se dessinent dans la scène comme des billboards tournés vers la caméra, après tous les maillages : testées en profondeur contre la géométrie, sans jamais écrire la profondeur, non éclairées sauf demande, sans projeter ni recevoir d'ombres. Par défaut, une particule reste là où elle est née une fois qu'elle quitte l'émetteur (la fumée continue de dériver là où elle a été produite pendant que la torche s'éloigne) ; `emitterSetLocal` fait plutôt suivre le nœud à tout le nuage. Supprimer le nœud supprime ses émetteurs. [source,lua] ---- flame = emitterNew(torchTip) emitterSetBlend(flame, PARTICLE_ADD) emitterSetRate(flame, 140) emitterSetLife(flame, 0.4, 0.9) emitterSetSpeed(flame, 0.6, 1.4) emitterSetDirection(flame, 0, 1, 0) emitterSetSpread(flame, 15) emitterSetSize(flame, 0.35, 0.05, 0.3) emitterSetColor(flame, 255, 200, 90, 255, 255, 40, 0, 0) emitterStart(flame) ---- [#_the_picture] ==== L'image Une particule est un quad texturé. Sans texture, c'est un disque aux bords doux fourni par le moteur ; `emitterSetTexture` lui donne plutôt un sprite chargé (une bouffée, une traînée d'étincelle, un flocon de neige), et pour un sprite animé, `emitterSetFrames` choisit une image au hasard par particule, si bien qu'un seul atlas de bouffées donne un nuage varié. Le mélange est normal (`PARTICLE_ALPHA`) ou additif (`PARTICLE_ADD`, pour tout ce qui brille). `emitterSetColor` donne la couleur et l'alpha à la naissance et à la mort, et la particule passe de l'un à l'autre au fil de sa vie ; `emitterSetSize` fait de même pour sa taille, avec une troisième valeur pour la vitesse à laquelle elle tourne. [#_trails_collisions_lit_and_soft_particles] ==== Traînées, collisions, particules éclairées et particules douces `emitterSetTrail(emitter, length, width)` garde les dernières positions de chaque particule et dessine un ruban qui les traverse, en s'estompant vers la queue : étincelles, balles traçantes, comètes, en 2D comme en 3D. `emitterSetCollide` fait rebondir les particules : sur un sol horizontal à une hauteur que vous donnez (`COLLIDE_FLOOR`, gratuit), ou sur le monde physique par lancers de rayons (`COLLIDE_SCENE`, 3D seulement, plafonné par image, si bien qu'un gros émetteur répartit le coût sur quelques images). Deux autres sont réservés à la 3D. `emitterSetLit` éclaire chaque particule selon les lumières et le ciel de la scène comme une petite sphère tournée vers la caméra, si bien que la fumée prend la couleur d'un brasero et que la poussière scintille au soleil (les ombres ne sont pas lues). `emitterSetSoftness` fait disparaître progressivement une particule sur une certaine distance là où elle rencontre la géométrie, si bien que la fumée ne tranche pas dans un sol, ni une fontaine dans son bassin ; cela coûte une passe de profondeur supplémentaire de la scène par image tant qu'un émetteur l'utilise. [source,lua] ---- smoke = emitterNew(brazier) emitterSetTexture(smoke, spriteLoad(DIR .. "puff.png")) emitterSetRate(smoke, 30) emitterSetLife(smoke, 2, 4) emitterSetSpeed(smoke, 0.3, 0.6) emitterSetDirection(smoke, 0, 1, 0) emitterSetSize(smoke, 0.3, 1.2, 0.2) emitterSetColor(smoke, 90, 90, 90, 160, 60, 60, 60, 0) emitterSetLit(smoke, true) -- Prend la couleur du feu par en dessous. emitterSetSoftness(smoke, 0.4) -- Pas d'arête nette là où elle touche la vasque. emitterStart(smoke) embers = emitterNew(brazier) emitterSetBlend(embers, PARTICLE_ADD) emitterSetTrail(embers, 6, 0.02) -- Une courte traînée derrière chaque braise. emitterSetCollide(embers, COLLIDE_FLOOR, 0.3, 0.8, 0) -- Rebondit sur le sol à Y = 0. emitterStart(embers) ---- [#_cost] ==== Coût Les particules coûtent peu à simuler et coûtent ce qu'elles couvrent à l'écran. Quelques milliers de petites ne sont rien ; une poignée de grandes particules additives sur tout l'écran, voilà ce qui ralentit un Raspberry Pi 4. La réserve est plafonnée par `emitterSetMax` (1000 par défaut) ; quand elle est pleine, il n'en naît plus aucune tant que certaines ne sont pas mortes. [#authoring] === Décrire un jeu au lieu de l'écrire Un jeu peut être écrit sous forme de *description* -- une table de couches, d'entités et de règles -- et compilé en un jeu Singe ordinaire par Forge, l'outil de création. Forge est un projet distinct, avec son propre téléchargement et son propre livre. [[migrating]] === Migrer depuis Singe 2.10 Singe 3.00 a déplacé le handle du sprite vers le premier argument de `spriteDraw`, `spriteLoop`, `spriteQuality`, `spriteRotate`, `spriteRotateAndScale`, `spriteScale` et `spriteSetFrame`, si bien que chaque appel de sprite correspond désormais à la famille `video*`. Pour mettre à jour un jeu, déplacez le dernier argument de chacun de ces appels en tête : [source,lua] ---- spriteDraw(x, y, cursor) -- 2.10 spriteDraw(cursor, x, y) -- 3.00 spriteRotate(angle, cursor) -- 2.10 spriteRotate(cursor, angle) -- 3.00 ---- Un jeu que vous ne pouvez pas modifier peut choisir l'ancien ordre à la place. Soit vous définissez la variable globale avant de charger le framework : [source,lua] ---- SINGE_LEGACY_SPRITE_ARGS = true dofile("Singe/Framework.singe") ---- soit vous ajoutez `LEGACY_SPRITE_ARGS = true` à son entrée de `games.dat`. Rien d'autre n'a changé de forme, mais quelques comportements, si : * Les handles et les compteurs sont renvoyés sous forme d'entiers Lua ; `videoIsPlaying` renvoie un booléen ; `mouseSetMode` ne renvoie rien. * Les erreurs dans les callbacks terminent désormais le jeu avec une trace de la pile d'appels au lieu d'être affichées et ignorées, comme les erreurs d'arguments l'ont toujours fait. * Les touches maintenues ne se répètent plus en `MODE_NORMAL`. * Rien n'est écrit à côté du jeu. Sans `--datadir`, le dossier de données est `data//` sous le dossier du jeu, là où 2.x utilisait le dossier même du jeu ; les sauvegardes, les réglages et les meilleurs scores ont leur place sous `singeGetDataPath()`. * `math.randomseed` accepte une graine fractionnaire, comme sous le Lua de 2.10 ; elle est arrondie à l'entier inférieur avant que le générateur de Lua 5.4 ne la voie. `os.clock` est le temps d'horloge écoulé depuis le démarrage du moteur, pas le temps processeur. * Le disque est placé sur l'image 1 par le moteur avant que votre script ne s'exécute ; `Framework.singe` ne fait plus de saut quand il est chargé. * `onMouseMoved` en mode `MANY_MOUSE` transmet le déplacement réel dans ses troisième et quatrième arguments, et `onControllerMoved` transmet l'indice de la manette (`0` à `3`) au lieu de l'identifiant d'instance de SDL. * `mouseEnable` / `mouseDisable` et `singeEnablePauseKey` / `singeDisablePauseKey` fonctionnent toujours, comme alias de `mouseSetEnabled` et `singeSetPauseKeyEnabled`. * Un laserdisc n'est plus supposé d'office. Une entrée de `games.dat` avec un `VIDEO` est un jeu laserdisc, comme avant ; une entrée sans tourne plutôt sur un canevas. En ligne de commande, passez `--disc` (ou `--framefile`), car une vidéo trouvée à côté du script n'implique plus un disque. Voir <>. * La touche pause met désormais en pause tout le jeu, pas seulement les médias : le script est gelé jusqu'à ce que la touche soit de nouveau pressée, et `SWITCH_PAUSE` n'est transmis au script que lorsque la touche a été désactivée. Elle agit à l'appui de la touche plutôt qu'au relâchement. Voir <>. * `fontPrint` conserve les chaînes qu'il rend, et `overlayClear` ne remplit à nouveau que ce qui a été dessiné depuis le dernier effacement, si bien qu'un HUD réimprimé à chaque image coûte peu. Rendre du texte avec `fontToSprite` pour les performances est déconseillé ; cela fonctionne toujours, et c'est toujours le moyen d'obtenir du texte que l'on peut mesurer, mettre à l'échelle ou faire tourner. Voir <>. [#_engine_constants] === Constantes du moteur Le moteur définit ces variables globales avant l'exécution de tout script, si bien qu'elles sont disponibles pour `controls.cfg` comme pour `Framework.singe` : [cols="2,3",options="header"] |=== | Constante | Signification | `SWITCH_UP` ... `SWITCH_RECORD` | Valeurs passées à `onInputPressed` / `onInputReleased` en `MODE_NORMAL`. `SWITCH_MOUSE_DISCONNECT` est émis par le moteur lui-même quand une souris ou un pistolet optique est débranché, une fois comme appui et une fois comme relâchement ; aucune touche ni aucun bouton ne le produit. `SWITCH_RECORD` est le contact d'enregistrement, sur lequel le moteur agit lui-même, comme pour le contact de capture d'écran. | `FONT_QUALITY_SOLID`, `FONT_QUALITY_SHADED`, `FONT_QUALITY_BLENDED` | Arguments de `fontQuality`. | `MODE_NORMAL`, `MODE_FULL` | Arguments de `keyboardSetMode`. | `MOUSE_SINGLE`, `MOUSE_MANY` (aussi `SINGLE_MOUSE`, `MANY_MOUSE`) | Arguments de `mouseSetMode`. | `OVERLAY_NOT_UPDATED`, `OVERLAY_UPDATED` | Valeurs de retour de `onOverlayUpdate`. | `RENDER_PIXELATED`, `RENDER_SMOOTH` | Arguments de `spriteQuality` / `videoQuality`. | `SPRITE_ALPHA`, `SPRITE_ADD`, `SPRITE_MULTIPLY` | Mélange pour `spriteSetBlend`. | `DISC_STOPPED`, `DISC_PLAYING`, `DISC_PAUSED`, `DISC_EJECTED` | Valeurs renvoyées par `discGetState`. | `LIGHT_DIRECTIONAL`, `LIGHT_POINT`, `LIGHT_SPOT` | Arguments de `lightNew`. | `BODY_STATIC`, `BODY_DYNAMIC`, `BODY_KINEMATIC` | Types de corps pour `bodyNew`. | `SHAPE_BOX`, `SHAPE_SPHERE`, `SHAPE_CAPSULE`, `SHAPE_CYLINDER`, `SHAPE_HULL`, `SHAPE_MESH` | Formes de collision pour `bodyNew`. | `JOINT_HINGE`, `JOINT_BALL`, `JOINT_SLIDER` | Types d'articulation (les appels `joint*` les nomment directement). | `DEBUG_NONE`, `DEBUG_SHAPES`, `DEBUG_CONSTRAINTS`, `DEBUG_CONTACTS`, `DEBUG_VELOCITIES`, `DEBUG_STATIC`, `DEBUG_ALL` | Ce que dessine `physicsSetDebug` ; additionnez-les. | `PARTICLE_ALPHA`, `PARTICLE_ADD` | Mélange pour `emitterSetBlend`. | `PARTICLE_OVER`, `PARTICLE_UNDER` | Où se dessinent les particules 2D, pour `emitterSetLayer`. | `COLLIDE_NONE`, `COLLIDE_FLOOR`, `COLLIDE_SCENE` | Ce sur quoi rebondissent les particules, pour `emitterSetCollide`. | `BILLBOARD_NONE`, `BILLBOARD_ALL`, `BILLBOARD_Y` | Comment un nœud fait face à la caméra, pour `nodeSetBillboard`. | `LAYER_SCENE`, `LAYER_FRONT` | Si un nœud se dessine sous l'overlay ou devant lui, pour `nodeSetLayer`. | `VEHICLE_CAR`, `VEHICLE_MOTORCYCLE`, `VEHICLE_TANK`, `VEHICLE_BOAT` | Sortes de véhicules pour `vehicleNew`. | `SOFT_CLOTH`, `SOFT_BODY`, `SOFT_ROPE` | Sortes de corps mous pour `softNew`. | `FILTER_LINEAR`, `FILTER_NEAREST` | Échantillonnage des textures pour `materialSetFilter`. | `TONEMAP_NEUTRAL`, `TONEMAP_ACES`, `TONEMAP_NONE` | Courbes de tonalité pour `sceneSetTonemap`. | `SOUND_ERROR_INVALID`, `SOUND_REMOVE_HANDLE` | `-1`, ce que renvoie `soundPlay` quand aucun canal n'est libre. | `SINGE_VERSION_MAJOR`, `SINGE_VERSION_MINOR`, `SINGE_VERSION_STRING`, `SINGE_FRAMEWORK_VERSION` | La version du moteur, sous forme d'entiers, de chaîne (`"v3.00"`) et du nombre que renvoie `singeVersion()`. | `SINGE_DEAD_ZONE` | La `DEAD_ZONE` de `controls.cfg`, en unités brutes d'axe. | `SINGE_TRIGGER_THRESHOLD` | Le seuil des gâchettes analogiques dans ces mêmes unités brutes ; `0` quand les gâchettes suivent `SINGE_DEAD_ZONE`, ce qui est le cas par défaut. | `SINGE_LEGACY_SPRITE_ARGS` | Vrai quand le jeu a demandé l'ordre des arguments de sprite de 2.10. | `SINGE_DISC` | Vrai quand le jeu a un laserdisc ; faux quand le canevas est le monde. | `SINGE_GAMEPAD_BASE`, `SINGE_GAMEPAD_STRIDE`, `SINGE_AXIS_STRIDE`, `SINGE_GAMEPAD_BUTTON_OFFSET`, `SINGE_MOUSE_BASE`, `SINGE_MOUSE_STRIDE`, `SINGE_MAX_CONTROLLERS`, `SINGE_MAX_MICE`, `SINGE_CONTROLLER_AXES`, `SINGE_CONTROLLER_BUTTONS` | Disposition des codes d'entrée des manettes et des souris, et nombre d'axes et de boutons d'une manette ; `Framework.singe` construit à partir d'eux les tables `GAMEPAD_N` et `MOUSE_N`, et un écran de service s'en sert pour parcourir une manette. | `SINGE_SCALE_MIN`, `SINGE_SCALE_MAX`, `SINGE_SHIFT_MIN`, `SINGE_SHIFT_MAX`, `SINGE_ROTATE_STEP` | Ce qu'acceptent `vldpSetScale`, `vldpSetShift` et `vldpSetRotate`. Bornez à ces valeurs plutôt qu'à vos propres copies : un décalage hors limites met fin au script. |=== `Framework.singe` ajoute les tables `SCANCODE` et `MODIFIER` (les valeurs de touches et de modificateurs de SDL), `GAMEPAD_0` à `GAMEPAD_3`, `MOUSE_0` à `MOUSE_3`, les index `GAMEPAD_AXIS_*` pour `controllerGetAxis`, et `DIR`. [#_included_libraries] === Bibliothèques incluses En plus des bibliothèques standard de Lua et de l'API Singe, les bibliothèques suivantes sont aussi utilisables dans les programmes Singe sans avoir à installer de logiciel supplémentaire : [cols="1,3",options="header"] |=== | Bibliothèque | Rôle | Copas | Réseau asynchrone | LuaFileSystem | Prise en charge étendue du système de fichiers | json.lua | Encodage et décodage JSON (JavaScript Object Notation) | binaryheap | Structure de données de tas binaire | timerwheel | Planification efficace de minuteries | LuaSec | Communication TLS / SSL | LuaSocket | TCP et UDP | LuaRS232 | Accès au port série RS232 | lsqlite3 | Bases de données SQLite, `require("sqlite3")` | md5 | `require("md5")` pour `sum`, `hexsum`, `exor`, `crypt` et `decrypt` ; la bibliothèque de PUC-Rio qu'Hypseus embarque, dont elle nomme `hexsum` la fonction `sumhexa` | LuaBitOp | `require("bit")` pour les opérations bit à bit de Lua 5.1, que 5.4 a sous forme d'opérateurs | lrandom | `require("random")` pour un générateur Mersenne Twister, distinct de `math.random` | Lua CJSON | `require("cjson")` pour l'encodage et le décodage JSON, à côté du module `json` ci-dessus | LPeg | `require("lpeg")` pour les grammaires d'expressions d'analyse | middleclass | `require("middleclass")` pour les classes, si vous préférez construire vos entités avec elles plutôt qu'avec des tables | lume | `require("lume")` pour les petites choses dont tout jeu a besoin : `lerp`, `clamp`, `round`, `shuffle`, `split`, `serialize`, `weightedchoice` | inspect | `require("inspect")` pour afficher une table sous une forme qu'un humain peut lire, pour quand `debugPrint` tient lieu de débogueur | bump | `require("bump")` pour déplacer des choses qui ne doivent pas se traverser -- il répond à la question de savoir où finit un objet qui se déplace, ce que <> ne font délibérément pas | MobDebug | `require("mobdebug")`, le débogueur auquel parle <> ; `--debug` le démarre, si bien qu'un jeu n'a besoin d'aucune ligne à lui | RmlUi | Le modèle objet propre à l'interface, la variable globale `rmlui` (voir <>) |=== Leur utilisation dépasse le cadre de ce document. Les bibliothèques md5, LuaBitOp, lrandom, Lua CJSON et LPeg sont là parce qu'Hypseus les expose, si bien qu'un jeu écrit pour Hypseus trouve les mêmes modules sous les mêmes noms ; rien dans Singe n'en a besoin. [#audiosync] === Synchronisation audio Singe présente la vidéo d'après l'horloge audio : une image est affichée quand le son qui va avec elle est entendu. Le moteur mesure son propre chemin audio au démarrage. SDL ne peut pas indiquer combien d'audio est en file d'attente entre le mélangeur et le haut-parleur, alors Singe vide le périphérique une fois, avant que quoi que ce soit d'audible ne joue, et compte la rafale de tampons que SDL utilise pour le remplir de nouveau ; cette rafale est la profondeur de la file, et l'horloge la soustrait. Lancez avec `--program` pour voir la mesure dans la trace (`Audio device queue: ...`). Ce que le moteur ne peut pas voir, c'est tout ce qui se trouve en aval du serveur audio du système d'exploitation : un DAC, un ampli-tuner HDMI, une liaison Bluetooth. Ceux-ci ajoutent un délai fixe qui varie selon la machine. Si les actions à l'écran précèdent visiblement leur son, passez les millisecondes manquantes avec `--audiodelay=MS`, mettez `AUDIO_DELAY = MS` dans `games.dat`, ou laissez le jeu le régler depuis un menu de service avec <>. Les valeurs positives signifient que l'audio est en retard ; la vidéo est retenue d'autant. Une valeur négative traite le cas rare d'un audio qui arrive en avance. [#menucalibration] ==== Calibrer depuis le menu Le menu fourni a un écran de calibrage pour ce délai en aval. Appuyez sur la touche affectée à `INPUT_SERVICE` (la touche `9` par défaut) dans la liste des jeux et choisissez Audio Delay dans les outils de service (voir xref:Manual.adoc#servicetools[Les outils de service]). L'écran est une page du document du menu : il recouvre le menu de noir, affiche le délai essayé et la file du périphérique que le moteur a mesurée, joue un clic une fois par seconde et devient blanc pendant une image à chaque flash. Réglez avec gauche et droite (10 ms) et haut et bas (1 ms) jusqu'à ce que le flash et le clic se produisent ensemble, puis appuyez sur le bouton 1 pour enregistrer. Start remet à zéro et le bouton 2 (ou de nouveau la touche de service) annule. La valeur est stockée dans `machine.cfg` à la racine des données et appliquée par le moteur à chaque jeu de cette machine, qu'il soit lancé depuis le menu ou depuis la ligne de commande ; elle s'ajoute à tout `AUDIO_DELAY` propre à un jeu. Recalibrez après avoir changé de haut-parleurs, de casque ou d'écran. L'écran fonctionne parce que le clic et l'audio du disque partagent un seul périphérique de mélangeur et une seule file : le flash est programmé, après le clic, à la file mesurée plus la valeur candidate, si bien que lorsque les deux coïncident, la valeur candidate est exactement le délai que le moteur ne peut pas voir. Le retard de l'affichage est compris gratuitement. Un jeu peut lire les valeurs avec <> et <>, ou proposer son propre écran avec <>. Deux propriétés du fichier vidéo lui-même comptent aussi. L'audio AAC porte des échantillons d'amorçage de l'encodeur que le conteneur doit dire aux décodeurs d'ignorer ; les fichiers multiplexés sans cette information (un MP4 sans liste d'édition, un fichier Matroska sans délai de codec) jouent leur audio environ 20 millisecondes en retard, ce que `AUDIO_DELAY` peut absorber. Et quand la première image vidéo est horodatée plus tard que le premier échantillon audio, Singe respecte la synchronisation du conteneur, si bien que l'audio de tête n'est pas perdu. [#formats] === Formats vidéo, audio et de conteneur Singe décode la vidéo directement avec les bibliothèques de FFmpeg, en utilisant le décodeur matériel de la plateforme quand elle en offre un pour le codec (VA-API ou VDPAU sous Linux, D3D11VA sous Windows, VideoToolbox sous macOS, et sur une carte ARM les décodeurs V4L2 -- celui de mémoire à mémoire, qui sur un Pi 4 couvre le H.264, celui de la Request API sans état qu'un Pi 5 utilise pour le HEVC, et celui propre à Rockchip sur un RK3588) et son propre décodeur logiciel sinon ; la trace du programme indique lequel, et `--softwarevideo` force le logiciel. Tout conteneur et tout codec que le FFmpeg fourni sait démultiplexer et décoder sera lu : MP4, MKV, flux de programme MPEG, AVI, AV1 et les flux élémentaires `.m2v` classiques de Daphne avec un fichier audio `.ogg` correspondant à côté. Chaque piste audio du fichier est disponible pour `discSetAudioTrack` / `videoSetAudioTrack` ; toutes les pistes doivent partager un même format d'échantillon, un même nombre de canaux et une même fréquence. Un laserdisc contenait des trames entrelacées, et un rip qui les a conservées présente des effets de peigne sur tout affichage progressif. `--deinterlace` décide de ce qui se passe : `auto`, la valeur par défaut, applique bwdif aux images que le fichier marque comme entrelacées et laisse tout le reste tel quel, si bien qu'un disque progressif ne paie rien ; `on` est pour un fichier dont les indicateurs sont faux ; `off` ne touche jamais à l'image. Une image sort pour chaque image qui entre -- un disque est adressé par numéro d'image, si bien qu'un désentrelaceur qui changerait chaque image en deux trames décalerait chaque image de l'index. La première fois qu'une vidéo est ouverte, Singe l'indexe et stocke l'index à côté des autres données du jeu (`-.index`, le hachage étant fait à partir du nom complet de la vidéo pour que deux vidéos de même nom de base gardent des index séparés). L'indexation est un seul passage sur le fichier sans décodage, quelques secondes même pour un disque de la durée d'un long métrage, et se refait si la vidéo change. Quand une vidéo est chargée, Singe indique l'espacement de ses images clés dans la trace du programme (`--program`), avec un avertissement si les images clés sont espacées de plus de deux secondes, car un saut doit décoder en avançant depuis l'image clé précédente. Le décodage se fait sur un fil d'exécution séparé, si bien qu'un saut lent montre l'image précédente un peu plus longtemps au lieu de bloquer le jeu. Les segments d'un framefile sont ouverts à mesure que le disque les atteint, pas tous d'un coup : celui qui joue, celui d'après, et de la place pour qu'un saut passe de l'un à l'autre. Un long framefile peut avoir des centaines de segments -- un jeu publié en a 213 -- et garder un décodeur ouvert pour chacun d'eux coûte des centaines de fichiers ouverts sans rien y gagner. Un framefile qui nomme un fichier absent le signale toujours au démarrage. Pour les séquences de laserdisc, les contraintes sont la précision à l'image et la vitesse de saut, pas la compression. Le H.264 en MP4 ou MKV avec un intervalle d'images clés court (une ou deux secondes) saute vite et se lit sur toutes les plateformes prises en charge ; de longs intervalles d'images clés rendent `discSearch` visiblement lent car le décodeur doit avancer depuis l'image clé précédente. La définition standard (720x480 ou 720x576) est le meilleur compromis. Les sources haute définition fonctionnent mais coûtent proportionnellement plus de CPU sur du matériel de la classe du Raspberry Pi, et l'overlay prend par défaut la moitié de la résolution de la vidéo, si bien qu'une vidéo surdimensionnée n'apporte pas grand-chose. Les effets sonores et la musique sont lus d'abord par SDL_mixer, puis par FFmpeg pour tout ce qu'il refuse, si bien qu'à eux deux ils couvrent : * WAV, AIFF, AU et VOC pour l'audio non compressé. Les fichiers WAV courts non compressés donnent la latence la plus faible, et c'est ce que devrait être un coup de feu. * Ogg Vorbis, Opus, MP3, FLAC et WavPack. * AAC (`.m4a`, `.aac`), ALAC, AC-3, WMA, APE, TTA, Speex et AMR -- tout le reste de ce que décode le FFmpeg fourni. * Les modules de tracker via libxmp : MOD, S3M, XM et IT, et une cinquantaine d'autres dont 669, AMF, MED, OKT, PTM, STM et ULT. * Les chiptunes via game-music-emu : NSF et NSFE, SPC, VGM, GBS, AY, GYM, HES, KSS et SAP. La forme VGZ compressée par gzip n'est pas lue ; décompressez-la d'abord en VGM. Un chiptune qui ne dit pas sa durée joue jusqu'à ce qu'il se taise, ce qui peut ne jamais arriver, si bien qu'il est décodé au fil de la lecture plutôt que d'avance. * Le MIDI, synthétisé à partir d'une SoundFont. Voir <>. La ligne `Audio:` de l'en-tête de la trace (`--program` ou `--trace`) liste les décodeurs que la version compilée contient réellement. Les images fonctionnent de la même façon : SDL_image d'abord, FFmpeg pour le reste. Cela couvre PNG, JPEG, WebP, GIF, BMP, TGA, PCX, PNM, LBM, QOI, XCF, XPM et SVG du côté de SDL_image, et TIFF, OpenEXR, JPEG 2000, DDS, PSD, DPX, AVIF et HEIC du côté de FFmpeg. Un GIF ou un WEBP animé se charge comme une animation de sprite ; voir <>, qui prend aussi une taille pour rastériser un SVG. Le `.hdr` de Radiance et OpenEXR fonctionnent tous deux comme ciel (<>) et gardent toute leur plage. Les polices sont lues par FreeType : TrueType, OpenType, WOFF, WOFF2, Type 1, BDF et PCF, avec les glyphes en couleur (bitmap comme vectoriels) et le façonnage HarfBuzz pour l'arabe, l'hébreu et les écritures indiennes. [#reference] == Référence de l'API Lua Ce chapitre documente l'API Lua exposée par le moteur Singe -- chaque fonction que le script de votre jeu peut appeler, et chaque callback que Singe appellera chez vous. Il s'adresse aux développeurs qui écrivent de nouveaux jeux ou qui portent depuis d'autres moteurs des jeux qui reposent beaucoup sur la vidéo. [#conventions] === Conventions Chaque entrée de l'API ci-dessous suit la même structure : * *Signature(s)* dans un bloc de code, montrant chaque forme acceptée. * *Description* de ce que fait la fonction et de ses éventuels effets de bord. * *Paramètres* listés en ligne, ou sous forme de tableau quand il y en a plus de deux. * Ligne *Renvoie* décrivant la valeur rendue à Lua (omise quand la fonction ne renvoie rien). * *Remarques*, s'il y a des pièges qui méritent d'être signalés. * *Depuis* -- la version de Singe dans laquelle la fonction est apparue. * *Voir aussi* -- les fonctions associées que vous utiliserez probablement au même endroit. * *Exemple* -- un court extrait réaliste, à la forme d'un vrai code de jeu. Une entrée qui commence par *Extension Hypseus* est là pour qu'un jeu écrit pour Hypseus Singe tourne ici sans modification. Il en va de même pour une forme d'argument marquée comme étant celle d'Hypseus sur une fonction qui prend sinon celle de Singe. IMPORTANT: *N'écrivez pas de nouveaux jeux pour l'API Hypseus.* Ces entrées existent pour la compatibilité, pas comme une recommandation. Plusieurs d'entre elles répondent une valeur fixe parce que la chose qu'elles décrivent n'existe pas dans Singe, certaines sont acceptées et ignorées, et d'autres portent des échelles et des ordres d'arguments hérités d'un autre moteur -- `videoGetVolume()` compte jusqu'à `64` là où le reste de Singe compte jusqu'à `100`, et les noms `spriteAnim` prennent le sprite en dernier là où chaque appel de sprite de Singe le prend en premier. Chaque entrée nomme l'équivalent Singe à utiliser à la place. Un nouveau code qui s'en tient à l'API Singe est plus court, cohérent avec lui-même, et ne sera pas surpris quand une réponse de compatibilité reste constante. Quelques règles s'appliquent à toute l'API : * Les *couleurs* sont sur 8 bits par canal : le rouge, le vert, le bleu et l'opacité sont chacun des entiers de `0` à `255`. * Les *coordonnées de l'overlay* sont dans la résolution actuelle de l'overlay (fixée par `overlaySetResolution`), pas en pixels de l'écran. La résolution par défaut de l'overlay est la moitié de la résolution de la vidéo dans chaque dimension ; appelez `overlaySetResolution` pour la changer. * Les *handles* renvoyés par les fonctions `*Load` (sprites, sons, polices, vidéos) sont des entiers opaques. Repassez-les tels quels aux autres fonctions de cet espace de noms. Ne faites pas de calculs avec. * *Les arguments invalides mettent fin au script.* Si une fonction est appelée avec un mauvais nombre d'arguments, de mauvais types, ou un handle qui a déjà été déchargé, Singe met fin au script en cours avec une erreur -- elle ne renvoie ni `nil` ni un code d'échec. Validez les saisies de l'utilisateur dans votre script avant de les confier à l'API. * *Le dessin doit se faire depuis `onOverlayUpdate`.* Les appels à toute fonction `overlay*`, `spriteDraw`, `videoDraw` ou `fontPrint` depuis d'autres callbacks ne se verront pas à l'écran et peuvent corrompre l'overlay. C'est la règle qui piège tous les nouveaux développeurs Singe. [#animation] === Animation Ces appels pilotent les animations que porte un modèle, instance par instance. Chacun prend le *nœud racine* renvoyé par `modelInstance` ; les autres nœuds lèvent une erreur, sauf avec `animationIsPlaying`, qui répond `false`. Les animations sont numérotées à partir de `1` dans l'ordre où `modelGetAnimations` les liste, ou nommées comme le fichier les nomme. Un clip joue sur la couche de base, et jusqu'à quatre autres peuvent jouer sur les couches `1` à `4` au-dessus d'elle ; les appels de temps, de pause, de reprise et de saut ne visent que la couche de base. Le temps est en secondes, avance avec l'horloge d'au plus un dixième de seconde par image, et s'arrête pendant que le jeu est en pause. L'animation des nœuds, le skinning et les poids des morph targets sont tous pilotés. Voir la partie Animation de <>. [#animationgettime] ==== animationGetTime [source,text] ---- seconds = animationGetTime(node) ---- Le temps courant dans le clip de la couche de base, en lecture ou en pause, en secondes propres au clip (une `speed` de `2` le fait avancer deux fois plus vite). Un clip en boucle revient à `0` à sa fin ; un clip sans boucle s'arrête à sa durée. Si rien n'a encore joué, la réponse est `0`. Les clips des couches supérieures ne sont pas rapportés. Un nœud qui n'est pas une racine d'instance met fin au script. *Renvoie :* Les secondes, sous forme de nombre. *Depuis :* 3.00. *Voir aussi :* <>, <> .Exemple [source,lua] ---- -- Jouer le bruit de pas une fois à chaque passage du cycle de marche par ses deux points de contact. function onOverlayUpdate() local t = animationGetTime(hero) if (lastStepTime < 0.25 and t >= 0.25) or (lastStepTime < 0.75 and t >= 0.75) then soundPlay(footstep) end lastStepTime = t return OVERLAY_NOT_UPDATED end ---- [#animationisplaying] ==== animationIsPlaying [source,text] ---- playing = animationIsPlaying(node) ---- Indique si le clip de la couche de base avance sur cette instance. Un clip sans boucle cesse de jouer quand il atteint sa fin, et `animationPause` comme `animationStop` rendent cette valeur `false` ; un clip en boucle reste `true` jusqu'à ce qu'il soit arrêté. Contrairement aux autres appels de cette section, un nœud qui n'est pas une racine d'instance ne met pas fin au script mais répond `false`. *Renvoie :* Booléen. *Depuis :* 3.00. *Voir aussi :* <>, <> .Exemple [source,lua] ---- -- Retour au repos une fois que l'attaque unique est terminée. function onOverlayUpdate() if attacking and not animationIsPlaying(hero) then attacking = false animationPlay(hero, "Idle", true, 1, 0.2) end return OVERLAY_NOT_UPDATED end ---- [#animationpause] ==== animationPause [source,text] ---- animationPause(node) ---- Fige le clip de la couche de base à son temps courant ; les nœuds gardent cette pose jusqu'à ce que `animationResume` le reprenne ou qu'un autre clip soit joué. Les clips des couches supérieures continuent de tourner. Mettre en pause une instance sur laquelle rien ne joue est sans danger. Un nœud qui n'est pas une racine d'instance met fin au script. *Depuis :* 3.00. *Voir aussi :* <>, <> .Exemple [source,lua] ---- -- Une cinématique fige la foule pendant que la caméra bouge. function beginCutscene() for _, npc in ipairs(crowd) do animationPause(npc) end cameraSet(cutsceneCamera) end ---- [#animationplay] ==== animationPlay [source,text] ---- animationPlay(node, nameOrIndex) animationPlay(node, nameOrIndex, loop) animationPlay(node, nameOrIndex, loop, speed) animationPlay(node, nameOrIndex, loop, speed, fade) ---- Démarre l'un des clips du modèle depuis son début sur la couche de base de cette instance, en remplaçant ce qui y jouait ; la pose se met à jour aussitôt. Avec un `fade`, l'ancien clip continue de tourner pendant qu'il s'estompe sur ce nombre de secondes, et quand rien ne jouait, le fondu part de la pose où se trouvent les nœuds à présent (un ragdoll qui vient d'être désactivé, une pose fixée par le script), les nœuds que le nouveau clip n'anime pas revenant en douceur à la pose de repos du modèle. Un nœud qui n'est pas une racine d'instance, un nom que le modèle n'a pas, ou un numéro hors de `1` au nombre de clips met fin au script. *Paramètres :* * `node` -- le nœud racine de l'instance, venant de `modelInstance`. * `nameOrIndex` -- un nom de clip tel que le fichier le stocke, ou son numéro à partir de `1` dans l'ordre de `modelGetAnimations`. * `loop` -- booléen, `true` par défaut. À `false`, le clip s'arrête à sa dernière image clé et `animationIsPlaying` devient `false`. * `speed` -- vitesse de lecture, `1` par défaut. `2` est deux fois plus rapide ; `0.5` deux fois plus lent. * `fade` -- durée en secondes du fondu enchaîné depuis ce qui jouait, `0` par défaut (un passage sec). *Depuis :* 3.00. *Voir aussi :* <>, <>, <>, <> .Exemple [source,lua] ---- -- Un garde qui reste au repos jusqu'à ce que le joueur soit repéré, puis court. guardModel = modelLoad(DIR .. "Models/Guard.glb") guard = modelInstance(guardModel) animationPlay(guard, "Idle") function onSpotted() animationPlay(guard, "Run", true, 1.3, 0.4) end ---- [#animationplaylayer] ==== animationPlayLayer [source,text] ---- animationPlayLayer(node, layer, nameOrIndex) animationPlayLayer(node, layer, nameOrIndex, loop) animationPlayLayer(node, layer, nameOrIndex, loop, speed) animationPlayLayer(node, layer, nameOrIndex, loop, speed, fade) ---- Joue un clip sur l'une des quatre couches au-dessus de la base, mélangé par-dessus ce qui se trouve en dessous selon le poids de la couche (`animationSetLayerWeight`, `1` par défaut) et limité à une partie du squelette quand un masque est défini (`animationSetLayerMask`). Les couches supérieures se mélangent par-dessus les inférieures ; chaque couche fait tourner son propre clip, son temps et sa boucle indépendamment de la base. `nameOrIndex`, `loop`, `speed` et `fade` ont le même sens que dans `animationPlay`, à une différence près : un `fade` sur une couche qui était vide fait monter le poids de la couche depuis `0` au lieu de faire un fondu enchaîné depuis une pose. Une couche hors de `1` à `4`, un nœud qui n'est pas une racine d'instance, ou un clip que le modèle n'a pas met fin au script. *Paramètres :* * `node` -- le nœud racine de l'instance. * `layer` -- `1` à `4`. * `nameOrIndex` -- un nom ou un numéro de clip, comme dans `animationPlay`. * `loop` -- `true` par défaut. * `speed` -- `1` par défaut. * `fade` -- en secondes, `0` par défaut. *Depuis :* 3.00. *Voir aussi :* <>, <>, <>, <> .Exemple [source,lua] ---- -- Marcher avec tout le corps, regarder alentour avec la tête seulement. animationPlay(fox, "Walk", true) animationPlayLayer(fox, 1, "Survey", true, 1, 0.5) animationSetLayerMask(fox, 1, nodeFind("b_Neck_04", fox)) -- Plus tard, saluer d'un bras par-dessus ce que font les jambes. function onGreet() animationPlayLayer(fox, 2, "Wave", false, 1, 0.2) end ---- [#animationresume] ==== animationResume [source,text] ---- animationResume(node) ---- Reprend le clip de la couche de base là où `animationPause` l'a figé, ou là où `animationSetTime` l'a déplacé entre-temps. Reprendre un clip qui joue déjà ne change rien. Une instance sans clip sur sa couche de base (jamais joué, ou arrêté) met fin au script, de même qu'un nœud qui n'est pas une racine d'instance. *Depuis :* 3.00. *Voir aussi :* <>, <> .Exemple [source,lua] ---- function endCutscene() for _, npc in ipairs(crowd) do animationResume(npc) end cameraSet(gameCamera) end ---- [#animationsetlayermask] ==== animationSetLayerMask [source,text] ---- animationSetLayerMask(node, layer, maskNode) animationSetLayerMask(node, layer) ---- Limite une couche à `maskNode` et à tout ce qui se trouve sous lui, si bien que le clip de cette couche ne déplace que cette partie du modèle et que le reste continue de suivre les couches en dessous. `maskNode` doit être l'un des nœuds propres de l'instance, trouvé avec `nodeFind(name, node)` ; la racine de l'instance elle-même ne compte pas. Omettre `maskNode`, ou passer `nil`, lève la limite. Le masque reste sur la couche lors des appels `animationPlayLayer` suivants. Une couche hors de `1` à `4` met fin au script, tout comme un nœud qui n'est pas une racine d'instance ou un `maskNode` qui ne fait pas partie de cette instance. *Paramètres :* * `node` -- le nœud racine de l'instance. * `layer` -- `1` à `4`. * `maskNode` -- une articulation ou un nœud de l'instance ; omis ou `nil` pour tout le modèle. *Depuis :* 3.00. *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- Pointer le haut du corps vers la cible pendant que les jambes continuent de marcher. spine = nodeFind("b_Spine02_03", hero) animationSetLayerMask(hero, 1, spine) animationPlayLayer(hero, 1, "AimForward", true) function onHolster() animationStop(hero, 1) animationSetLayerMask(hero, 1) end ---- [#animationsetlayerweight] ==== animationSetLayerWeight [source,text] ---- animationSetLayerWeight(node, layer, weight) ---- Dans quelle mesure le clip d'une couche se voit par-dessus ce qui est en dessous, de `0` (pas du tout) à `1` (entièrement, la valeur par défaut) ; les valeurs hors de cet intervalle sont bornées. Le poids appartient à la couche, pas au clip, si bien qu'il survit à `animationPlayLayer` et à `animationStop` sur cette couche. Changez-le un peu à chaque image pour faire apparaître ou disparaître une couche à la main, ou laissez un `fade` sur `animationPlayLayer` le faire une fois. Une couche hors de `1` à `4` ou un nœud qui n'est pas une racine d'instance met fin au script. *Depuis :* 3.00. *Voir aussi :* <>, <> .Exemple [source,lua] ---- -- La boiterie s'aggrave à mesure que la santé baisse. function onHealthChanged(health) local limp = 1 - health / maxHealth animationSetLayerWeight(hero, 1, limp) end ---- [#animationsettime] ==== animationSetTime [source,text] ---- animationSetTime(node, seconds) ---- Fait sauter le clip de la couche de base à un temps exprimé en ses propres secondes ; les valeurs négatives deviennent `0`. La pose se met à jour aussitôt, que le clip joue ou non, si bien qu'une instance en pause peut être amenée à une image exacte, et qu'une instance en lecture continue depuis le nouveau temps. Tout fondu enchaîné en cours sur la couche de base est interrompu. Une instance sans clip sur sa couche de base met fin au script, de même qu'un nœud qui n'est pas une racine d'instance. *Depuis :* 3.00. *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- Une porte dont le clip d'ouverture suit un levier que le joueur fait glisser. animationPlay(door, "Open", false) animationPause(door) function onLeverMoved(fraction) animationSetTime(door, fraction * doorClipLength) end ---- [#animationstop] ==== animationStop [source,text] ---- animationStop(node) animationStop(node, layer) ---- Arrête et oublie tous les clips de l'instance, ou seulement le clip d'une couche au-dessus de la base (`1` à `4`) ; la couche de base seule ne peut pas être arrêtée sans les couches au-dessus d'elle. Les nœuds gardent leur dernière pose, dont un `animationPlay` suivant avec un `fade` les fait sortir en douceur. Après avoir tout arrêté, `animationResume` et `animationSetTime` mettent fin au script jusqu'à ce que quelque chose soit de nouveau joué. Les poids et les masques des couches sont conservés. Une couche hors de `1` à `4` ou un nœud qui n'est pas une racine d'instance met fin au script. *Depuis :* 3.00. *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- Confier le corps au ragdoll physique : figer la pose, puis le laisser tomber. function onKilled() animationStop(hero) ragdollActivate(hero) end -- Le salut sur la couche 2 est fini ; la marche en dessous continue. function onWaveFinished() animationStop(hero, 2) end ---- [#bezelref] === Bezel Les illustrations de borne et l'afficheur de scores d'arcade qui va avec. `mainBezelLoaded` renseigne sur les illustrations que `--bezel` a chargées ; la famille `scoreBezel` pilote le panneau des scores, un document RmlUi que le moteur fournit et qu'un jeu peut remplacer par sa propre copie, un `scoreBezel.rml` à côté de son script. Chaque appel de cette section est une extension d'Hypseus Singe, conservée pour que ses jeux tournent sans modification, et chacun d'eux est décrit du point de vue du joueur dans xref:Manual.adoc#bezels[Bezels]. Le panneau démarre éteint, comme dans Hypseus, et seul un jeu l'allume. Les valeurs qui lui sont données sont mémorisées qu'il soit affiché ou non, si bien qu'un jeu peut les régler avant d'activer le panneau. Chaque afficheur a le nombre de chiffres qu'a l'afficheur de scores d'Hypseus -- six pour un score, deux pour les crédits, un pour un nombre de vies -- et une valeur y est affichée alignée à droite avec des zéros en tête ; un score de plus de six chiffres montre ses six derniers. Les valeurs négatives d'Hypseus sont comprises aussi : `-2` allume un tiret dans chaque chiffre, `-4` un `H` et `-5` un `A`, et `-1`, `-3` et tout autre nombre négatif n'allument rien. [#mainbezelloaded] ==== mainBezelLoaded [source,text] ---- loaded, id = mainBezelLoaded() ---- Extension Hypseus. Indique si `--bezel` a chargé des illustrations de borne, et l'identifiant du fichier chargé. L'identifiant est le hachage FNV-1a sur 64 bits des octets du fichier, écrit sous forme de chaîne décimale, ce que rapporte Hypseus, si bien qu'un jeu qui reconnaît un bezel particulier à son numéro reconnaît ici le même fichier. Sans illustration chargée, les réponses sont `false` et `"0"`. *Renvoie :* booléen, et l'identifiant sous forme de chaîne. *Depuis :* 3.00, comme extension d'Hypseus Singe. *Voir aussi :* <>, xref:Manual.adoc#bezels[Bezels] .Exemple [source,lua] ---- -- Ne dessiner le cadre en sprite que si le joueur n'a pas d'illustrations à lui. local loaded, id = mainBezelLoaded() if not loaded then spriteDraw(frameSprite, 0, 0) end ---- [#scorebezelclear] ==== scoreBezelClear [source,text] ---- scoreBezelClear() ---- Extension Hypseus. Vide chaque afficheur du panneau des scores : les crédits, les deux scores et les deux nombres de vies n'affichent plus rien du tout, ne laissant que les chiffres atténués derrière eux. Les valeurs ne sont pas oubliées, mais le prochain `scoreBezelCredits`, `scoreBezelScore` ou `scoreBezelLives` rallume le panneau avec ce qu'il contient alors. Peut être appelé sans risque quand le panneau est éteint. *Depuis :* 3.00, comme extension d'Hypseus Singe. *Voir aussi :* <> [#scorebezelcredits] ==== scoreBezelCredits [source,text] ---- scoreBezelCredits(credits) ---- Extension Hypseus. Règle l'afficheur des crédits, qui a deux chiffres. Rallume aussi un panneau que `scoreBezelClear` a vidé. *Paramètres :* * `credits` -- entier ; les deux derniers chiffres sont affichés. *Depuis :* 3.00, comme extension d'Hypseus Singe. *Voir aussi :* <> [#scorebezelenable] ==== scoreBezelEnable [source,text] ---- scoreBezelEnable(enabled) scoreBezelEnable(enabled, type) ---- Extension Hypseus. Affiche ou masque le panneau des scores. Le premier `true` le construit à partir du `scoreBezel.rml` propre au jeu, à côté de son script, quand il y en a un, ou sinon à partir de `Singe/scoreBezel.rml` ; le masquer et l'afficher de nouveau ensuite ne coûte rien et conserve ses valeurs. Il démarre masqué, ce qui est aussi le cas d'Hypseus à sa livraison, si bien qu'un jeu qui n'appelle jamais ceci se comporte exactement comme avant -- et devrait interroger <> avant de supposer qu'il en a un. Le `type` d'Hypseus choisit parmi ses cinq implémentations d'afficheur de scores. Singe n'en a qu'une, un document RmlUi, si bien que l'argument est vérifié et ignoré. Une machine sans GPU n'a aucune GUI ; le panneau ne peut pas y être construit, `scoreBezelGetState` continue de répondre `false`, et un avertissement est affiché une fois. Un premier argument non booléen met fin au script. *Paramètres :* * `enabled` -- booléen. * `type` -- entier, accepté et ignoré. *Depuis :* 3.00, comme extension d'Hypseus Singe. *Voir aussi :* <>, xref:Manual.adoc#bezels[Bezels] .Exemple [source,lua] ---- -- Le panneau du moteur quand il y en a un, des sprites sinon. scoreBezelEnable(true, 0) if scoreBezelGetState() then scoreBezelTwinScoreOn(twoPlayer) scoreBezelCredits(credits) else drawSpriteScoreboard() end ---- [#scorebezelgetstate] ==== scoreBezelGetState [source,text] ---- state = scoreBezelGetState() ---- Extension Hypseus. Indique si le panneau des scores est affiché. Chaque jeu Hypseus pose cette question avant d'utiliser le panneau et dessine son propre afficheur de scores avec des sprites quand la réponse est `false`, ce qui est le bon repli et non un échec. *Renvoie :* booléen. *Depuis :* 3.00, comme extension d'Hypseus Singe. *Voir aussi :* <> [#scorebezellives] ==== scoreBezelLives [source,text] ---- scoreBezelLives(player, lives) ---- Extension Hypseus. Règle l'afficheur des vies d'un joueur, qui a un chiffre. `player` vaut `1` ou `2` ; tout autre nombre est ignoré, et `2` l'est aussi tant que <> n'a pas mis les afficheurs du second joueur sur le panneau. Rallume aussi un panneau que `scoreBezelClear` a vidé. *Paramètres :* * `player` -- `1` ou `2`. * `lives` -- entier ; le dernier chiffre est affiché. *Depuis :* 3.00, comme extension d'Hypseus Singe. *Voir aussi :* <> [#scorebezelscore] ==== scoreBezelScore [source,text] ---- scoreBezelScore(player, score) ---- Extension Hypseus. Règle l'afficheur du score d'un joueur, qui a six chiffres. `player` vaut `1` ou `2`, avec la même règle pour `2` que `scoreBezelLives`. Rallume aussi un panneau que `scoreBezelClear` a vidé. *Paramètres :* * `player` -- `1` ou `2`. * `score` -- entier ; les six derniers chiffres sont affichés. *Depuis :* 3.00, comme extension d'Hypseus Singe. *Voir aussi :* <> .Exemple [source,lua] ---- function addScore(points) score = score + points if scoreBezelGetState() then scoreBezelScore(1, score) end end ---- [#scorebezeltwinscoreon] ==== scoreBezelTwinScoreOn [source,text] ---- scoreBezelTwinScoreOn(enabled) ---- Extension Hypseus. Met sur le panneau les afficheurs de score et de vies du second joueur, ou les retire et laisse la place aux deux autres. Les désactiver vide aussi le panneau, exactement comme dans Hypseus, alors faites suivre d'une valeur si les afficheurs du premier joueur doivent rester allumés. Tant qu'ils sont désactivés, `scoreBezelScore(2, ...)` et `scoreBezelLives(2, ...)` ne font rien. Un argument non booléen met fin au script. *Paramètres :* * `enabled` -- booléen. *Depuis :* 3.00, comme extension d'Hypseus Singe. *Voir aussi :* <> [#body] === Corps Un corps donne à un nœud de la scène une place dans le monde physique. Il y a un corps par nœud, désigné par le handle du nœud : un corps statique reste là où était le nœud quand le corps a été créé, un corps cinématique suit son nœud à chaque pas et pousse sans être poussé, et un corps dynamique est déplacé par la simulation et entraîne son nœud. Les tailles, les positions et les vitesses sont en unités du monde (des mètres dans une scène 3D, des pixels de l'overlay dans un monde 2D), les masses en kilogrammes et le temps en secondes. Chaque appel de cette section met fin au script quand le nœud n'a pas de corps ; un appel qui n'a de sens que pour une sorte de corps (une force sur un corps statique, par exemple) est ignoré sur les autres, sauf `bodySetMass`, qui lève une erreur. Voir <> dans Développement de jeux pour la façon dont les corps, les nœuds et l'image s'articulent. [#bodynew] ==== bodyNew [source,text] ---- bodyNew(node, type, shape) bodyNew(node, type, shape, a) bodyNew(node, type, shape, a, b) bodyNew(node, type, shape, a, b, c) ---- Donne au nœud un corps du type indiqué, placé là où le nœud se trouve dans le monde à cet instant (ses parents compris), avec une forme de collision dimensionnée par `a`, `b` et `c` en unités du monde et mise à l'échelle par l'échelle du nœud dans le monde. Un second appel sur le même nœud remplace le premier, et un nœud qui avait un joueur le perd. La masse d'un corps dynamique vient du volume de sa forme à la densité de l'eau, alors réglez `bodySetMass` sur tout ce qui doit paraître léger. Changer l'échelle du nœud plus tard ne redimensionne pas le corps. Met fin au script pour un type ou une forme inconnus, quand la physique n'est pas disponible sur cette machine, quand le monde contient déjà `4096` corps, ou quand la forme ne peut pas être construite (un `SHAPE_MESH` dynamique, ou une enveloppe sans maillage sous le nœud). *Paramètres :* * `type` -- `BODY_STATIC`, `BODY_DYNAMIC` ou `BODY_KINEMATIC`. * `shape` -- `SHAPE_BOX` (`a`, `b`, `c` sont la largeur, la hauteur et la profondeur), `SHAPE_SPHERE` (`a` est le rayon), `SHAPE_CAPSULE` ou `SHAPE_CYLINDER` (`a` est le rayon, `b` la hauteur totale le long de Y, extrémités comprises), `SHAPE_HULL` (une enveloppe convexe du maillage du nœud et de tout ce qui est sous lui, sans tailles), ou `SHAPE_MESH` (les triangles exacts, corps statiques et cinématiques seulement ; un maillage de carte de hauteur devient un champ de hauteurs). * `a`, `b`, `c` -- les tailles dont la forme a besoin ; celles qui manquent valent `0` et toute taille est portée à au moins `0.001`. *Remarques :* Une sphère est mise à l'échelle par le plus grand axe d'échelle du nœud, le rayon d'une capsule ou d'un cylindre par le plus grand de X et de Z. Une capsule plus courte que deux fois son rayon est une sphère avec une fine tranche au milieu. *Depuis :* 3.00. *Voir aussi :* <>, <>, <>, <> .Exemple [source,lua] ---- -- Une caisse légère et un modèle de canard en enveloppe, lâchés sur le sol. crate = nodeNew() nodeSetMesh(crate, meshBox(0.6, 0.6, 0.6), wood) nodeSetPosition(crate, -1, 1, 0) bodyNew(crate, BODY_DYNAMIC, SHAPE_BOX, 0.6, 0.6, 0.6) bodySetMass(crate, 5) duck = modelInstance(modelLoad(DIR .. "Models/Duck.glb")) nodeSetPosition(duck, 3, 2, 0) bodyNew(duck, BODY_DYNAMIC, SHAPE_HULL) ---- [#bodyapplyforce] ==== bodyApplyForce [source,text] ---- bodyApplyForce(node, fx, fy, fz) bodyApplyForce(node, fx, fy, fz, px, py, pz) ---- Ajoute une force, en newtons, à un corps dynamique pour les pas de cette image seulement ; appelez-la à chaque image pour continuer à pousser. Sans point, la force agit par le centre de masse et ne fait que déplacer le corps ; avec un point du monde `(px, py, pz)`, elle fait aussi tourner le corps autour de son centre. Une force accélère de la force divisée par la masse, alors dimensionnez-la selon `bodySetMass`. Les corps statiques et cinématiques l'ignorent. *Paramètres :* * `fx`, `fy`, `fz` -- la force en newtons le long de chaque axe du monde. * `px`, `py`, `pz` -- point du monde facultatif où la force s'applique ; omettez-le pour le centre de masse. *Depuis :* 3.00. *Voir aussi :* <>, <> .Exemple [source,lua] ---- -- Un ventilateur pousse le ballon à travers la pièce tant que l'interrupteur est enclenché. function onOverlayUpdate() if fanOn then local bx, by, bz = nodeGetPosition(balloon) if bx < fanReach then bodyApplyForce(balloon, 2.5, 0.4, 0) end end return OVERLAY_NOT_UPDATED end ---- [#bodyapplyimpulse] ==== bodyApplyImpulse [source,text] ---- bodyApplyImpulse(node, ix, iy, iz) bodyApplyImpulse(node, ix, iy, iz, px, py, pz) ---- Change d'un coup la quantité de mouvement d'un corps dynamique : une impulsion égale à la masse multipliée par une vitesse ajoute cette vitesse. Sans point, elle agit par le centre de masse ; avec un point du monde `(px, py, pz)`, elle fait aussi tourner le corps, si bien que frapper une caisse près de son sommet la fait basculer. C'est l'appel pour un coup, un coup de pied, une explosion ou un lancement, là où `bodyApplyForce` sert à une poussée qui dure. Les corps statiques et cinématiques l'ignorent. *Paramètres :* * `ix`, `iy`, `iz` -- l'impulsion en kilogrammes mètres par seconde le long de chaque axe du monde. * `px`, `py`, `pz` -- point du monde facultatif où l'impulsion s'applique ; omettez-le pour le centre de masse. *Depuis :* 3.00. *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- Projeter droit vers le haut ce qui se trouve sous la souris, au point touché. local mx, my = mouseGetPosition(0) local ox, oy, oz = sceneUnproject(mx, my, 0) local fx, fy, fz = sceneUnproject(mx, my, 10) local hit, hx, hy, hz = physicsRaycast(ox, oy, oz, fx - ox, fy - oy, fz - oz) if hit and hit ~= floor then bodyApplyImpulse(hit, 0, 3, 0, hx, hy, hz) end ---- [#bodydelete] ==== bodyDelete [source,text] ---- bodyDelete(node) ---- Retire le corps du monde ; le nœud et son maillage restent où ils sont. Chaque articulation attachée au corps, et tout véhicule construit dessus, disparaissent avec lui. Supprimer le nœud supprime aussi son corps, si bien que ceci ne sert qu'à garder un nœud sans son corps. Met fin au script quand le nœud n'a pas de corps. *Depuis :* 3.00. *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- Une pièce ramassée cesse d'être solide et s'élève comme un simple nœud. function onTrigger(trigger, other, entered) if trigger == coin and other == hero and entered then bodyDelete(coin) collected = collected + 1 coinRising = true end end ---- [#bodygetangularvelocity] ==== bodyGetAngularVelocity [source,text] ---- x, y, z = bodyGetAngularVelocity(node) ---- La vitesse à laquelle le corps tourne autour de chaque axe du monde, en radians par seconde, au dernier pas. Un corps statique renvoie zéro. Utile pour la hauteur du son d'une roue ou un compteur de tours. *Renvoie :* Trois nombres, en radians par seconde autour de X, Y et Z. *Depuis :* 3.00. *Voir aussi :* <>, <> .Exemple [source,lua] ---- -- La roue de la roulette s'est arrêtée quand elle tourne à peine. function onOverlayUpdate() local _, spin = bodyGetAngularVelocity(wheel) if spinning and math.abs(spin) < 0.05 then spinning = false announceWinner(pocketUnder(ball)) end return OVERLAY_NOT_UPDATED end ---- [#bodygetvelocity] ==== bodyGetVelocity [source,text] ---- x, y, z = bodyGetVelocity(node) ---- La vitesse à laquelle le corps se déplace le long de chaque axe du monde, en unités du monde par seconde, au dernier pas. Un corps statique renvoie zéro ; un corps cinématique renvoie la vitesse qu'impliquait le mouvement de son nœud. *Renvoie :* Trois nombres, en unités par seconde le long de X, Y et Z. *Depuis :* 3.00. *Voir aussi :* <>, <> .Exemple [source,lua] ---- -- Jouer un bruit de souffle quand la balle va vraiment vite. function onOverlayUpdate() local vx, vy, vz = bodyGetVelocity(ball) local speed = math.sqrt(vx * vx + vy * vy + vz * vz) if speed > 8 and not whooshing then soundPlay(whoosh) whooshing = true elseif speed < 2 then whooshing = false end return OVERLAY_NOT_UPDATED end ---- [#bodyisresting] ==== bodyIsResting [source,text] ---- resting = bodyIsResting(node) ---- Indique si la simulation a mis le corps en sommeil parce qu'il a cessé de bouger. Les corps statiques et les corps retirés du monde avec `bodySetEnabled` comptent comme au repos. Un corps au repos se réveille de lui-même quand quelque chose le touche ou quand une vitesse, une force ou une impulsion lui est appliquée. *Renvoie :* Booléen. *Depuis :* 3.00. *Voir aussi :* <>, <> .Exemple [source,lua] ---- -- La manche se termine une fois que toutes les quilles se sont stabilisées. function onOverlayUpdate() local settled = true for _, pin in ipairs(pins) do settled = settled and bodyIsResting(pin) end if settled then scoreFrame() end return OVERLAY_NOT_UPDATED end ---- [#bodysetangularvelocity] ==== bodySetAngularVelocity [source,text] ---- bodySetAngularVelocity(node, x, y, z) ---- Règle la rotation autour de chaque axe du monde, en radians par seconde, et réveille le corps. Les corps statiques l'ignorent. La rotation d'un corps cinématique est écrasée à partir de son nœud au pas suivant, si bien que l'appel ne dure que pour les corps dynamiques. *Depuis :* 3.00. *Voir aussi :* <>, <> .Exemple [source,lua] ---- -- Lancer la pièce : vers le haut, en tournant sur elle-même. function tossCoin() nodeSetPosition(coin, 0, 1.2, 0) bodySetVelocity(coin, 0, 4, 0) bodySetAngularVelocity(coin, 25, 0, 0) tossed = true end ---- [#bodysetbounce] ==== bodySetBounce [source,text] ---- bodySetBounce(node, bounce) ---- Règle la restitution du corps, de `0` (s'arrête net) à `1` (rebondit avec tout ce qu'il avait en arrivant) ; les valeurs hors de cet intervalle sont bornées. La valeur par défaut est `0.1`. Les valeurs des deux corps en contact se combinent, si bien qu'une balle vive sur un sol mort rebondit quand même. *Depuis :* 3.00. *Voir aussi :* <>, <> .Exemple [source,lua] ---- -- Une balle en caoutchouc lâchée sur la scène. ball = nodeNew() nodeSetMesh(ball, meshSphere(0.3, 32), blue) nodeSetPosition(ball, 0, 3, 0) bodyNew(ball, BODY_DYNAMIC, SHAPE_SPHERE, 0.3) bodySetMass(ball, 0.5) bodySetBounce(ball, 0.7) ---- [#bodysetbuoyancy] ==== bodySetBuoyancy [source,text] ---- bodySetBuoyancy(node, factor) ---- La façon dont le corps flotte quand il est dans un volume d'eau : `1` est neutre, plus flotte plus haut, moins coule, et `0` est une pierre. La valeur par défaut est `1.2`, qui flotte doucement. Le facteur multiplie la densité propre de l'eau donnée par `bodySetWater`, et les valeurs négatives sont ramenées à `0`. Seuls les corps dynamiques sont déplacés par l'eau, mais la valeur peut être réglée sur n'importe quel corps. *Depuis :* 3.00. *Voir aussi :* <>, <> .Exemple [source,lua] ---- -- Une caisse qui flotte haut, une ancre qui coule, une balle qui danse sur l'eau. bodyNew(crate, BODY_DYNAMIC, SHAPE_BOX, 0.8, 0.8, 0.8) bodySetMass(crate, 20) bodySetBuoyancy(crate, 1.6) bodyNew(anchor, BODY_DYNAMIC, SHAPE_BOX, 0.5, 0.5, 0.5) bodySetMass(anchor, 200) bodySetBuoyancy(anchor, 0.3) bodyNew(ball, BODY_DYNAMIC, SHAPE_SPHERE, 0.4) bodySetBuoyancy(ball, 2.0) ---- [#bodysetcurrent] ==== bodySetCurrent [source,text] ---- bodySetCurrent(node, x, y, z) ---- Règle le courant à l'intérieur d'un volume d'eau, en unités du monde par seconde le long de chaque axe du monde ; les corps dynamiques qui y flottent dérivent avec le courant et un joueur qui nage est emporté par lui. Par défaut, il n'y a pas de courant. Réglez-le sur le nœud sur lequel `bodySetWater` a été appelé ; sur tout autre corps, il est stocké et ne fait rien. *Depuis :* 3.00. *Voir aussi :* <>, <> .Exemple [source,lua] ---- -- Une rivière qui emporte vers l'aval tout ce qu'on y lâche. river = nodeNew() nodeSetMesh(river, meshBox(6, 3, 12), water) nodeSetPosition(river, 10, -1.5, 0) bodyNew(river, BODY_STATIC, SHAPE_BOX, 6, 3, 12) bodySetWater(river, 1.0, 0.8, 0.2) bodySetCurrent(river, 0, 0, 2.5) ---- [#bodysetenabled] ==== bodySetEnabled [source,text] ---- bodySetEnabled(node, enabled) ---- Retire le corps du monde avec `false` : il cesse d'entrer en collision, cesse de bouger, n'entraîne plus son nœud ni ne le suit, et compte comme au repos. `true` le remet là où il est, éveillé. Le corps garde sa forme, sa masse et ses réglages, si bien que c'est moins coûteux que de le supprimer et de le recréer pour quelque chose qui va et vient. *Depuis :* 3.00. *Voir aussi :* <>, <> .Exemple [source,lua] ---- -- Le pont-levis n'est solide que lorsqu'il est baissé. function lowerBridge() animationPlay(bridge, "Lower", false) bodySetEnabled(bridgeDeck, true) end function raiseBridge() animationPlay(bridge, "Raise", false) bodySetEnabled(bridgeDeck, false) end ---- [#bodysetfriction] ==== bodySetFriction [source,text] ---- bodySetFriction(node, friction) ---- Règle le frottement de la surface : `0` est de la glace, `0.5` (la valeur par défaut) est ordinaire, les valeurs plus élevées adhèrent davantage. Les valeurs négatives sont ramenées à `0`. Les valeurs des deux corps en contact se combinent, si bien qu'un sol glissant demande une valeur basse sur le sol, pas sur tout ce qui marche dessus. *Depuis :* 3.00. *Voir aussi :* <>, <> .Exemple [source,lua] ---- -- Un étang gelé au milieu du niveau. pond = nodeNew() nodeSetMesh(pond, meshBox(6, 0.1, 6), ice) nodeSetPosition(pond, 8, -1.95, 0) bodyNew(pond, BODY_STATIC, SHAPE_BOX, 6, 0.1, 6) bodySetFriction(pond, 0.02) ---- [#bodysetmass] ==== bodySetMass [source,text] ---- bodySetMass(node, kilograms) ---- Remet à l'échelle la masse d'un corps dynamique et, avec elle, son inertie. Faites-le pour tout ce qui doit paraître léger ou lourd : par défaut, c'est le volume de la forme à la densité de l'eau, si bien qu'une boîte de la taille d'une porte pèse environ 140 kilogrammes et qu'une légère poussée ne la fera pas bouger. Les forces et les impulsions s'échelonnent avec la masse, alors réglez la masse avant de les ajuster. Met fin au script quand le corps n'est pas dynamique ou que la masse n'est pas positive. *Depuis :* 3.00. *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- Une caisse que le héros peut pousser et une enclume que personne ne peut bouger. bodyNew(crate, BODY_DYNAMIC, SHAPE_BOX, 0.6, 0.6, 0.6) bodySetMass(crate, 5) bodyNew(anvil, BODY_DYNAMIC, SHAPE_BOX, 0.5, 0.3, 0.3) bodySetMass(anvil, 200) ---- [#bodysettrigger] ==== bodySetTrigger [source,text] ---- bodySetTrigger(node, trigger) ---- Fait du corps un déclencheur avec `true` : il ne pousse rien et n'arrête rien, mais `onTrigger` signale chaque corps ou joueur qui y entre et en sort. `false` le rend de nouveau solide : ce qui était à l'intérieur est oublié sans sortie signalée, tandis qu'un déclencheur dans lequel se trouve le corps lui-même continue de le signaler. Un déclencheur est généralement une boîte ou une sphère statique placée sur une porte, une ligne d'arrivée ou une zone dangereuse. Les rayons de `physicsRaycast` touchent les déclencheurs comme n'importe quel autre corps. *Remarques :* Un joueur est signalé dans un déclencheur quand leurs boîtes englobantes se chevauchent, si bien qu'un déclencheur rond est carré pour les joueurs. *Depuis :* 3.00. *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- Une ligne d'arrivée en travers de la piste. line = nodeNew() nodeSetPosition(line, 0, 0, -20) bodyNew(line, BODY_STATIC, SHAPE_BOX, 6, 3, 0.5) bodySetTrigger(line, true) function onTrigger(trigger, other, entered) if trigger == line and other == car and entered then lapDone() end end ---- [#bodysetvelocity] ==== bodySetVelocity [source,text] ---- bodySetVelocity(node, x, y, z) ---- Règle la vitesse du corps le long de chaque axe du monde, en unités du monde par seconde, et le réveille. Ceci remplace la vitesse qu'avait le corps, là où `bodyApplyImpulse` s'y ajoute. Les corps statiques l'ignorent, et la vitesse d'un corps cinématique est écrasée à partir du mouvement de son nœud au pas suivant. *Depuis :* 3.00. *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- Servir la balle vers le côté qui a perdu le dernier point. function serve() nodeSetPosition(ball, 0, 0.5, 0) bodyNew(ball, BODY_DYNAMIC, SHAPE_SPHERE, 0.15) bodySetMass(ball, 0.1) local dir = (lastWinner == 1) and -1 or 1 bodySetVelocity(ball, 4 * dir, 2, (math.random() - 0.5) * 2) end ---- [#bodysetwater] ==== bodySetWater [source,text] ---- bodySetWater(node, density) bodySetWater(node, density, linearDrag) bodySetWater(node, density, linearDrag, angularDrag) ---- Remplit d'eau un corps statique et en fait un déclencheur : le dessus de sa forme est la surface, et chaque corps dynamique à l'intérieur flotte, coule ou roule par flottabilité par rapport à cette surface (la partie immergée de la forme compte, si bien qu'une caisse inclinée se redresse), ralenti par les traînées. Un joueur dont le milieu est sous la surface nage. Comme le volume est un déclencheur, `onTrigger` se déclenche à l'entrée et à la sortie. L'eau n'est pas dessinée : donnez au nœud un matériau en mélange, ou aucun. Met fin au script quand le corps n'est pas statique. *Paramètres :* * `density` -- un multiplicateur, `1` pour l'eau ; le `bodySetBuoyancy` propre à chaque corps le module. Les valeurs négatives sont ramenées à `0`. * `linearDrag` -- à quelle vitesse l'eau ralentit le mouvement qui la traverse ; `0.5` par défaut. * `angularDrag` -- à quelle vitesse elle ralentit la rotation ; `0.1` par défaut. *Remarques :* Les joueurs et les bateaux voient au plus huit volumes d'eau ; au-delà, les volumes font encore flotter les corps mais on ne peut pas y nager. *Depuis :* 3.00. *Voir aussi :* <>, <>, <>, <> .Exemple [source,lua] ---- -- Une piscine creusée dans le pont ; son dessus, à Y = 0, est la surface. pool = nodeNew() nodeSetMesh(pool, meshBox(8, 3, 12), water) nodeSetPosition(pool, 0, -1.5, 0) bodyNew(pool, BODY_STATIC, SHAPE_BOX, 8, 3, 12) bodySetWater(pool, 1.0, 0.6, 0.2) function onTrigger(trigger, other, entered) if trigger == pool and entered then soundPlay(splash) end end ---- [#camera] === Caméra La caméra est un nœud comme un autre : placez-la avec `nodeSetPosition`, orientez-la avec `nodeLookAt`, rattachez-la à un objet en mouvement pour une vue de poursuite, et désignez-la avec `cameraSet`. Elle regarde le long de son propre -Z. Sans nœud caméra, la scène est vue depuis `(0, 0, 5)` en regardant l'origine. La projection est réglée à part et s'applique au nœud, quel qu'il soit, qui est la caméra : perspective avec un champ de vision vertical de 60 degrés de `0.1` à `1000` unités par défaut, ou orthographique avec `cameraSetOrthographic`. Les vues rendues (`viewNew`) partagent la projection mais regardent depuis leurs propres nœuds. Voir <>. [#cameraset] ==== cameraSet [source,text] ---- cameraSet(node) cameraSet(-1) ---- Fait d'un nœud la caméra : la scène est dessinée depuis sa position dans le monde en regardant le long de son propre -Z, si bien que le mouvement et la rotation de ses parents comptent. N'importe quel nœud convient, y compris un nœud qui porte aussi un maillage ou une lumière, et changer de caméra ne demande qu'un appel. `-1` rétablit la vue par défaut depuis `(0, 0, 5)` en regardant l'origine. Supprimer le nœud caméra ramène aussi à la vue par défaut. Met fin au script pour un handle de nœud qui n'est pas valide. *Depuis :* 3.00. *Voir aussi :* <>, <>, <>, <> .Exemple [source,lua] ---- -- Une caméra de poursuite derrière la voiture du joueur, et une caméra fixe pour la rediffusion. chaseCamera = nodeNew(car) nodeSetPosition(chaseCamera, 0, 2, 6) nodeLookAt(chaseCamera, nodeGetWorldPosition(car)) cameraSet(chaseCamera) function onReplayStart() replayCamera = nodeNew() nodeSetPosition(replayCamera, 30, 12, 30) nodeLookAt(replayCamera, 0, 0, 0) cameraSet(replayCamera) end ---- [#camerasetorthographic] ==== cameraSetOrthographic [source,text] ---- cameraSetOrthographic(height, near, far) ---- Passe à une projection parallèle montrant `height` unités du monde de haut en bas, la largeur suivant le format d'image de la couche, de `near` à `far` devant la caméra. La distance ne rapetisse pas les choses, ce qui convient aux schémas, aux vues isométriques et de profil, et aux scènes façon HUD. Le réglage s'applique au nœud caméra actuel et à tout nœud caméra ultérieur jusqu'à l'appel de `cameraSetPerspective`. L'ombre d'une lumière directionnelle utilise une seule carte sous une caméra orthographique, quoi que dise `sceneSetShadowCascades`. *Paramètres :* * `height` -- la hauteur visible, en unités du monde. * `near` -- la distance la plus proche dessinée, en unités du monde devant la caméra. * `far` -- la distance la plus lointaine dessinée. *Depuis :* 3.00. *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- Une vue isométrique du plateau : 12 unités de haut, vue de haut et de côté. boardCamera = nodeNew() nodeSetPosition(boardCamera, 20, 20, 20) nodeLookAt(boardCamera, 0, 0, 0) cameraSet(boardCamera) cameraSetOrthographic(12, 1, 100) ---- [#camerasetperspective] ==== cameraSetPerspective [source,text] ---- cameraSetPerspective(fov, near, far) ---- Passe à la projection en perspective habituelle, celle par défaut. `fov` est le champ de vision vertical en degrés (`60` par défaut) ; `near` et `far` (`0.1` et `1000` par défaut) bornent ce qui est dessiné, en unités du monde devant la caméra. La précision de la profondeur est répartie entre les deux, alors gardez `near` aussi grand que la scène le permet et `far` pas plus grand que nécessaire : `0.05` à `100` pour une pièce, `0.1` à `400` pour un paysage. Le réglage s'applique au nœud caméra actuel et à tout nœud caméra ultérieur, ainsi qu'aux vues rendues. *Paramètres :* * `fov` -- champ de vision vertical en degrés ; un large `70` donne une impression de vitesse, un étroit `40` celle d'un téléobjectif. * `near` -- la distance la plus proche dessinée. * `far` -- la distance la plus lointaine dessinée. *Depuis :* 3.00. *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- Un objectif plus serré pour la vue du cockpit, celui par défaut pour l'extérieur. function setView(inside) if inside then cameraSet(cockpitCamera) cameraSetPerspective(45, 0.05, 300) else cameraSet(chaseCamera) cameraSetPerspective(60, 0.1, 400) end end ---- [#collide] === Collisions Savoir si deux choses plates se touchent. Jolt y répond en trois dimensions pour les corps (voir <>) ; ces fonctions y répondent pour l'overlay, là où vivent les hitbox d'un jeu au pistolet optique et absolument tout d'un jeu 2D. Ce sont de simples fonctions : pas de handle, pas d'état, rien à créer ni à libérer, et rien à mettre à jour à chaque image. Les coordonnées sont celles dans lesquelles vous dessinez déjà -- des pixels de l'overlay, en général. Un rectangle est un coin et une taille, comme le prend chaque appel de dessin du moteur. Se toucher exactement sur un bord compte comme se toucher, et un rectangle sans largeur ou sans hauteur ne touche rien du tout. Aucune ne déplace quoi que ce soit. Quand vous voulez savoir non seulement *si* deux choses se sont rencontrées, mais *où finit ce qui bouge* -- glisser le long d'un mur plutôt que s'y arrêter net -- faites `require("bump")` et laissez-le s'en charger ; il est fourni exactement pour cette raison. [#collidecircles] ==== collideCircles [source,text] ---- touching = collideCircles(x1, y1, radius1, x2, y2, radius2) ---- Indique si deux cercles se chevauchent. Moins coûteux qu'il n'y paraît : rien ne calcule de racine carrée pour répondre à une question par oui ou non. *Paramètres :* * `x1`, `y1`, `radius1` -- nombres ; le premier cercle. * `x2`, `y2`, `radius2` -- nombres ; le second. *Renvoie :* `true` ou `false`. *Depuis :* 3.00. *Voir aussi :* <> [#collidepointcircle] ==== collidePointCircle [source,text] ---- inside = collidePointCircle(pointX, pointY, x, y, radius) ---- Indique si un point tombe dans un cercle. Le bord compte comme l'intérieur. *Paramètres :* * `pointX`, `pointY` -- nombres ; le point. * `x`, `y`, `radius` -- nombres ; le cercle. *Renvoie :* `true` ou `false`. *Depuis :* 3.00. [#collidepointpolygon] ==== collidePointPolygon [source,text] ---- inside = collidePointPolygon(pointX, pointY, points) ---- Indique si un point tombe dans un polygone de forme quelconque, concaves compris. La table est une liste plate de `x`, `y`, `x`, `y` -- la forme qu'a déjà une hitbox dans la plupart des jeux -- et le sens de parcours n'a pas d'importance. Avec moins de trois sommets, le polygone ne contient rien. 256 sommets au plus. *Paramètres :* * `pointX`, `pointY` -- nombres ; le point. * `points` -- table ; un nombre pair de nombres, x et y à tour de rôle. *Renvoie :* `true` ou `false`. *Depuis :* 3.00. .Exemple [source,lua] ---- -- Le tir a-t-il touché le dragon, dont le contour n'est pas un rectangle ? local dragon = { 120,40, 200,60, 210,140, 150,180, 100,120 } function onInputPressed(key) if key == SWITCH_BUTTON1 then local x, y = mouseGetPosition(0) if collidePointPolygon(x, y, dragon) then hit() end end end ---- [#collidepointrect] ==== collidePointRect [source,text] ---- inside = collidePointRect(pointX, pointY, x, y, width, height) ---- Indique si un point tombe dans un rectangle. Le bord compte comme l'intérieur. *Paramètres :* * `pointX`, `pointY` -- nombres ; le point. * `x`, `y`, `width`, `height` -- nombres ; le rectangle. *Renvoie :* `true` ou `false`. *Depuis :* 3.00. .Exemple [source,lua] ---- -- Une hitbox de la taille du sprite auquel elle appartient. local x, y = mouseGetPosition(0) if collidePointRect(x, y, enemyX, enemyY, spriteGetWidth(enemy), spriteGetHeight(enemy)) then kill(enemy) end ---- [#colliderectcircle] ==== collideRectCircle [source,text] ---- touching = collideRectCircle(x, y, width, height, circleX, circleY, radius) ---- Indique si un rectangle et un cercle se chevauchent, en cherchant le point du rectangle le plus proche du centre du cercle. *Paramètres :* * `x`, `y`, `width`, `height` -- nombres ; le rectangle. * `circleX`, `circleY`, `radius` -- nombres ; le cercle. *Renvoie :* `true` ou `false`. *Depuis :* 3.00. [#colliderects] ==== collideRects [source,text] ---- touching = collideRects(x1, y1, width1, height1, x2, y2, width2, height2) ---- Indique si deux rectangles se chevauchent. Celle dont vous vous servirez le plus. *Paramètres :* * `x1`, `y1`, `width1`, `height1` -- nombres ; le premier rectangle. * `x2`, `y2`, `width2`, `height2` -- nombres ; le second. *Renvoie :* `true` ou `false`. *Depuis :* 3.00. *Voir aussi :* <> [#collidesegments] ==== collideSegments [source,text] ---- crossing = collideSegments(ax1, ay1, ax2, ay2, bx1, by1, bx2, by2) ---- Indique si deux segments se croisent : un tir le long d'une trajectoire contre un mur, ou le pas que veut faire un objet en mouvement contre le bord qu'il ne doit pas franchir. Des segments couchés l'un sur l'autre répondent `false`, car « où se croisent-ils » n'a alors pas de réponse unique. *Paramètres :* * `ax1`, `ay1`, `ax2`, `ay2` -- nombres ; les extrémités du premier segment. * `bx1`, `by1`, `bx2`, `by2` -- nombres ; celles du second. *Renvoie :* `true` ou `false`. *Depuis :* 3.00. [#color] === Couleur Les couleurs de premier plan et de fond sont des globales du moteur plutôt que des arguments passés à chaque appel : réglez-les avec `colorForeground` et `colorBackground`, et chaque appel de dessin qui suit les lit jusqu'à ce qu'elles changent. La couleur de premier plan sert aux primitives de forme `overlay*` et au texte TrueType de `fontPrint` et `fontToSprite` ; la couleur de fond sert à `overlayClear` et au texte `FONT_QUALITY_SHADED`. `overlayPrint` n'utilise ni l'une ni l'autre. Les canaux sont des entiers de `0` à `255`, ramenés dans cette plage, et les deux couleurs acceptent un alpha facultatif. Voir les sections <> et <> pour les appels qui les utilisent. [#colorbackground] ==== colorBackground [source,text] ---- colorBackground(r, g, b) colorBackground(r, g, b, a) ---- Règle la couleur de fond : celle dont `overlayClear` remplit l'overlay, et celle que le texte `FONT_QUALITY_SHADED` de `fontPrint` et `fontToSprite` peint derrière ses glyphes. Au démarrage, c'est un noir transparent, et l'alpha vaut `0` par défaut quand il est omis, si bien que `colorBackground(0, 0, 0)` suivi de `overlayClear()` rend de nouveau l'overlay invisible. Un alpha partiel sur du noir assombrit la vidéo ; `255` la masque. Les valeurs de canal hors plage sont ramenées dans la plage plutôt que rejetées. *Paramètres :* * `r`, `g`, `b` -- rouge, vert et bleu, de `0` à `255`. * `a` -- opacité, de `0` (transparent) à `255` (opaque) ; `0` par défaut. *Depuis :* 1.x *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- Noir plein derrière l'écran de calibrage, transparent pendant la partie. function onOverlayUpdate() if calibrating then colorBackground(0, 0, 0, 255) else colorBackground(0, 0, 0, 0) end overlayClear() drawHud() return OVERLAY_UPDATED end ---- [#colorforeground] ==== colorForeground [source,text] ---- colorForeground(r, g, b) colorForeground(r, g, b, a) ---- Règle la couleur de premier plan qu'utilisent `overlayBox`, `overlayCircle`, `overlayEllipse`, `overlayLine` et `overlayPlot`, et la couleur du texte de `fontPrint` et `fontToSprite`. Au démarrage, c'est un blanc opaque, et l'alpha vaut `255` par défaut quand il est omis, à l'inverse de `colorBackground`. Les sprites et `overlayPrint` sont dessinés avec leurs propres pixels et ne sont pas teintés. Les valeurs de canal hors plage sont ramenées dans la plage plutôt que rejetées. *Paramètres :* * `r`, `g`, `b` -- rouge, vert et bleu, de `0` à `255`. * `a` -- opacité, de `0` (transparent) à `255` (opaque) ; `255` par défaut. *Depuis :* 1.x *Voir aussi :* <>, <>, <>, <> .Exemple [source,lua] ---- -- Le score en blanc, l'alerte de dernière vie en rouge, tous deux avec la même police. function onOverlayUpdate() overlayClear() fontSelect(hudFont) colorForeground(255, 255, 255) fontPrint(8, 8, "Score: " .. score) if lives == 1 then colorForeground(255, 32, 32) fontPrint(8, 32, "LAST LIFE") end return OVERLAY_UPDATED end ---- [#commandlineref] === Ligne de commande Trois appels renvoient des nombres que l'utilisateur a choisis au lancement de Singe. Ils existent pour qu'un jeu puisse être réglé depuis le lanceur plutôt qu'en modifiant le jeu. Tous trois sont des extensions d'Hypseus Singe, et Singe ne fait rien lui-même des valeurs : c'est le jeu qui décide de leur sens. Les options sont décrites dans xref:Manual.adoc#commandlineoptions[Options de ligne de commande], et elles peuvent aussi être données dans un fichier de réglages (voir xref:Manual.adoc#settings[Le fichier de réglages]). Aucun des trois ne change en cours d'exécution, alors lisez chacun une fois et gardez sa valeur. [#getfvalue] ==== getFValue [source,text] ---- value = getFValue() ---- Extension Hypseus. Renvoie le nombre donné avec `--fvalue`, ou `0` quand l'option n'a pas été utilisée. C'est un canal d'un seul nombre, de celui qui a lancé Singe vers le jeu, destiné à une valeur qu'un front-end veut faire varier d'un lancement à l'autre -- une difficulté, un nombre de crédits de départ, un indice de menu. Le moteur ne fait que la transmettre. La valeur va de `0` à `100000` et garde trois décimales, comme Hypseus, si bien qu'un jeu qui compare le nombre voit le même que là-bas. *Renvoie :* nombre. *Depuis :* 3.00 (Hypseus) *Voir aussi :* <>, <> .Exemple [source,lua] ---- -- Le lanceur passe --fvalue 2 pour la borne réglée en difficile. local difficulty = getFValue() if difficulty == 0 then difficulty = 1 end ---- [#ratiogetx] ==== ratioGetX [source,text] ---- factor = ratioGetX() ---- Extension Hypseus. Renvoie le nombre donné avec `--xratio`, ou `0` quand l'option n'a pas été utilisée. Les jeux au pistolet optique écrits pour Hypseus le lisent et étirent avec lui leurs propres coordonnées de pistolet, pour une borne dont l'écran n'a pas la forme dans laquelle la vidéo a été masterisée -- `1.33` fait correspondre une dalle 16:9 à une vidéo 4:3. Singe ne l'applique à rien ; c'est le jeu qui fait le calcul, exactement comme dans Hypseus, et c'est pourquoi chaque jeu qui le lit traite `0` et tout ce qui est inférieur à `1` comme `1`. La valeur va de `0` à `100` et garde deux décimales, comme Hypseus. *Renvoie :* nombre. *Depuis :* 3.00 (Hypseus) *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- La forme des coordonnées de pistolet que veut cette borne. local ratioX = ratioGetX() if ratioX <= 1 then ratioX = 1 end local offsetX = (ratioX - 1) * (overlayGetWidth() / 2) ---- [#ratiogety] ==== ratioGetY [source,text] ---- factor = ratioGetY() ---- Extension Hypseus. La moitié verticale de la paire : le nombre donné avec `--yratio`, ou `0` quand l'option n'a pas été utilisée. Voir <>. *Renvoie :* nombre. *Depuis :* 3.00 (Hypseus) *Voir aussi :* <>, <> .Exemple [source,lua] ---- local ratioY = ratioGetY() if ratioY <= 1 then ratioY = 1 end ---- [#controller] === Manette Singe prend en charge jusqu'à quatre manettes, d'indices `0` à `3`, ouvertes automatiquement au démarrage et chaque fois qu'une manette est branchée ou débranchée ; il n'y a pas de `controllerLoad`. Les axes analogiques sont signalés par `onControllerMoved` (voir <>) et interrogés avec `controllerGetAxis`, dans la plage brute de SDL, de `-32768` à `32767`. Les boutons numériques, et chaque direction de chaque axe, sont des contacts comme les touches : ils parviennent à `onInputPressed` et `onInputReleased` quand `controls.cfg` affecte un code des tables `GAMEPAD_0` à `GAMEPAD_3` (par exemple `INPUT_ACTION_1 = { GAMEPAD_0.BUTTON_A }`), et `controllerGetButton` interroge l'état réel d'un bouton. `SINGE_DEAD_ZONE`, tiré de l'entrée `DEAD_ZONE` de `controls.cfg`, est le seuil qu'utilise le moteur lui-même avant qu'une direction d'axe compte comme un contact pressé ; traitez les mouvements d'axe en dessous comme du bruit. Chaque axe peut recevoir son propre seuil avec `DEAD_ZONES`, et les gâchettes analogiques un seuil à elles avec `TRIGGER_THRESHOLD` (relu sous le nom `SINGE_TRIGGER_THRESHOLD`) ; voir xref:Manual.adoc#deadzones[Zones mortes et gâchettes]. Un périphérique que SDL ne reconnaît pas comme manette est tout de même ouvert comme tel, grâce à une affectation que Singe écrit pour lui, si bien qu'un encodeur d'arcade ou un joystick de vol apparaît dans ces emplacements comme n'importe quelle manette -- voir xref:Manual.adoc#devices[Tout périphérique est une manette]. [#controllerdorumble] ==== controllerDoRumble [source,text] ---- controllerDoRumble(strength, duration) controllerDoRumble(controller, strength, duration) ---- Extension Hypseus. Fait vibrer une manette avec ses deux moteurs, à l'une de quatre intensités pendant l'une de quatre durées. La forme à deux arguments s'adresse à la manette `0`. Un emplacement sans manette ne fait rien (Hypseus s'interrompt dans ce cas, si bien que ses jeux se protègent avec `controllerIsValid`) ; une intensité ou une durée hors de `1` à `4`, ou un indice hors de `0` à `3`, met fin au script. Une manette sans vibration ignore l'appel. *Paramètres :* * `controller` -- indice de la manette, de `0` à `3`. * `strength` -- de `1` à `4`, d'un quart de la puissance à la pleine puissance. * `duration` -- de `1` à `4`, soit 150, 300, 600 ou 1200 ms. *Depuis :* 3.00, comme extension d'Hypseus Singe. *Voir aussi :* <>, <> .Exemple [source,lua] ---- -- Une brève secousse à chaque coup, une longue à pleine puissance quand le joueur meurt. function onHit() if controllerIsValid(0) then controllerDoRumble(0, 2, 1) end end function onPlayerDied() if controllerIsValid(0) then controllerDoRumble(0, 4, 4) end end ---- [#controllergetaxis] ==== controllerGetAxis [source,text] ---- value = controllerGetAxis(controller, axis) ---- Renvoie la position d'un axe analogique telle que le moteur l'a vue en dernier. La valeur est mise à jour à chaque événement d'axe, y compris les mouvements dans la zone morte, et vaut `0` pour une manette non connectée ou un axe qui n'a jamais bougé. Les sticks vont de `-32768` à `32767` ; les gâchettes reposent à `0` et atteignent `32767` enfoncées à fond. Un indice hors de `0` à `3`, ou un axe hors de `0` à `5`, met fin au script. *Paramètres :* * `controller` -- indice de la manette, de `0` à `3`. * `axis` -- `GAMEPAD_AXIS_LEFT_X` (`0`), `GAMEPAD_AXIS_LEFT_Y` (`1`), `GAMEPAD_AXIS_RIGHT_X` (`2`), `GAMEPAD_AXIS_RIGHT_Y` (`3`), `GAMEPAD_AXIS_LEFT_TRIGGER` (`4`) ou `GAMEPAD_AXIS_RIGHT_TRIGGER` (`5`). *Renvoie :* entier, de `-32768` à `32767`. *Depuis :* 2.00 *Voir aussi :* <>, <> .Exemple [source,lua] ---- -- Diriger avec le stick gauche, accélérer avec la gâchette droite. function onOverlayUpdate() local steer = controllerGetAxis(0, GAMEPAD_AXIS_LEFT_X) local throttle = controllerGetAxis(0, GAMEPAD_AXIS_RIGHT_TRIGGER) if math.abs(steer) > SINGE_DEAD_ZONE then heading = heading + (steer / 32768) * turnRate end speed = speed + (throttle / 32767) * acceleration drawHud() return OVERLAY_UPDATED end ---- [#controllergetbutton] ==== controllerGetButton [source,text] ---- pressed = controllerGetButton(controller, button) ---- Indique si un bouton est maintenu à cet instant, lu directement sur la manette plutôt que dans la file d'événements. Servez-vous-en pour les charges en maintenant le bouton et les cas semblables où `onInputPressed` et `onInputReleased` sont malcommodes. Un emplacement sans manette renvoie `false`. Le code du bouton doit venir de la table `GAMEPAD_N` du même indice de manette ; un code de la table d'une autre manette, ou un code d'axe, tombe hors de la plage des boutons et met fin au script, tout comme un indice hors de `0` à `3`. *Paramètres :* * `controller` -- indice de la manette, de `0` à `3`. * `button` -- une valeur `BUTTON_*` ou `DPAD_*` de la table correspondante, comme `GAMEPAD_0.BUTTON_A.value`. *Renvoie :* booléen. *Depuis :* 2.10 *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- Charger tant que A est maintenu, tirer au relâchement. function onOverlayUpdate() if controllerGetButton(0, GAMEPAD_0.BUTTON_A.value) then charge = math.min(charge + 1, chargeMax) elseif charge > 0 then fireShot(charge) charge = 0 end drawChargeBar(charge) return OVERLAY_UPDATED end ---- [#controllergetname] ==== controllerGetName [source,text] ---- name = controllerGetName(controller) ---- Le nom du périphérique présent dans un emplacement de manette, tel que SDL le donne, ou `nil` quand l'emplacement est vide. Un périphérique que SDL ne reconnaissait pas donne son propre nom par l'affectation que Singe a écrite pour lui, si bien qu'un écran de service peut lister un encodeur d'arcade par son nom aussi facilement qu'une manette de marque. Un indice hors de `0` à `3` met fin au script. *Paramètres :* * `controller` -- indice de la manette, de `0` à `3`. *Renvoie :* chaîne, ou `nil`. *Depuis :* 3.00 *Voir aussi :* <>, <> .Exemple [source,lua] ---- -- Un écran de service qui liste ce qui est branché. for slot = 0, 3 do if controllerIsValid(slot) then fontPrint(20, 20 + slot * 18, string.format("Pad %d: %s", slot + 1, controllerGetName(slot))) else fontPrint(20, 20 + slot * 18, string.format("Pad %d: not connected", slot + 1)) end end ---- [#controllergetpadding] ==== controllerGetPadding [source,text] ---- padding = controllerGetPadding() ---- Extension Hypseus, toujours `0` dans Singe. Hypseus ajoute ce décalage au code d'un contact venu d'une manette, pour qu'un jeu sache quel périphérique a pressé quoi tout en retrouvant le contact en le soustrayant. Singe livre les entrées des manettes comme des contacts affectés ordinaires qui ne portent aucune marque de ce genre, et répond donc `0` : un jeu qui teste la plage décalée prend son autre branche et lit le contact tel quel. *Renvoie :* un entier, toujours `0`. *Depuis :* 3.00 *Voir aussi :* <> [#controllerhowmany] ==== controllerHowMany [source,text] ---- count = controllerHowMany() ---- Extension Hypseus. Le nombre de manettes ouvertes, sur les quatre emplacements dont dispose Singe. Comme tout joystick que Singe peut ouvrir devient une manette (voir xref:Manual.adoc#devices[Tout périphérique est une manette]), un encodeur d'arcade ou un spinner est compté ici aussi. Le compte change à mesure que des périphériques sont branchés et débranchés : lisez-le donc quand vous en avez besoin plutôt qu'une seule fois au démarrage. Il ne dit rien des emplacements occupés : un périphérique peut laisser l'emplacement 0 vide alors que l'emplacement 1 est utilisé, et <> reste donc le moyen d'interroger un emplacement précis. *Paramètres :* aucun. *Renvoie :* nombre, de `0` à `4`. *Depuis :* 3.00, comme extension d'Hypseus Singe. *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- Ne proposer le mode deux joueurs que si le joueur deux a quelque chose à tenir en main. local twoPlayer = controllerHowMany() >= 2 ---- [#controllerisvalid] ==== controllerIsValid [source,text] ---- valid = controllerIsValid(controller) ---- Extension Hypseus. Indique si une manette est ouverte dans l'emplacement à cet instant ; les manettes vont et viennent au gré des branchements à chaud, alors posez la question avant chaque `controllerDoRumble`. Un indice hors de `0` à `3` met fin au script. *Paramètres :* * `controller` -- indice de la manette, de `0` à `3`. *Renvoie :* booléen. *Depuis :* 3.00, comme extension d'Hypseus Singe. *Voir aussi :* <>, <> .Exemple [source,lua] ---- -- N'afficher les indications pour la manette que s'il y a une manette. function drawAttract() if controllerIsValid(0) then fontPrint(HINT_X, HINT_Y, "Press A to start") else fontPrint(HINT_X, HINT_Y, "Press 1 to start") end end ---- [#controllersetpadding] ==== controllerSetPadding [source,text] ---- controllerSetPadding(boolean) ---- Extension Hypseus, acceptée et ignorée. Voir <> pour savoir pourquoi le décalage qu'elle demande n'existe pas dans Singe. L'activer sans marquer les événements serait pire que de la laisser désactivée, car le jeu soustrairait alors un décalage de codes de contact qui n'en ont jamais porté. *Renvoie :* rien. *Depuis :* 3.00 *Voir aussi :* <> [#debug] === Débogage Deux appels pour voir ce que fait un script : `debugPrint` écrit du texte, et `lineDraw` trace une ligne par-dessus la scène 3D pendant une image (`physicsSetDebug` et `navDraw` dessinent par ces mêmes lignes). La sortie affichée va à la console depuis laquelle Singe a été lancé : elle est donc visible pendant le développement et inoffensive dans une borne, où `--noconsole` la fait taire. Quand Singe tourne avec `--trace`, chaque appel de l'API, celui-ci compris, est aussi écrit avec ses arguments dans `trace.txt`, dans le dossier de données, ce qui est le moyen d'en garder un enregistrement permanent. Les options de ligne de commande sont listées dans le chapitre sur le lancement de Singe. Quand l'affichage ne suffit pas, <> arrête le jeu sur un point d'arrêt, montre ce que contiennent ses variables et le fait avancer ligne par ligne. [[traceheader]] ==== L'en-tête de la trace Que ce soit `--program` ou `--trace` qui l'ait ouvert, `trace.txt` commence par un bloc qui décrit la machine et l'exécution, entre deux filets pour qu'on puisse le coller tel quel dans un rapport de bogue : ---- ---------------------------------------------------------------- Singe: v3.00 Command: ./Singe -w -p -d data --screen 2 MyGame OS: Linux 6.8.0-124-generic (x86_64) CPU: Intel(R) Xeon(R) CPU E5-2680 v4 @ 2.40GHz, 8 cores, 20177 MB Renderer: gpu, GPU driver vulkan Decoder: hardware vaapi or vdpau where the codec allows, software otherwise SDL: built 3.4.16, linked 3.4.16 Settings: haptic=0 screen=2 volume_vldp=80 Game: MyGame/MyGame.singe ---------------------------------------------------------------- ---- `Renderer` est le moteur de rendu effectivement créé et le backend GPU qu'il a choisi, ou une note indiquant qu'il n'y a pas de périphérique GPU et que la 3D n'est pas disponible. `Decoder` est ce qui sera essayé pour la vidéo sur cette machine ; le décodeur qu'une vidéo donnée a réellement obtenu est tracé à l'ouverture de cette vidéo, quelques lignes plus bas, car un codec peut n'offrir aucune voie matérielle. `Settings` liste ce qu'a fourni le fichier de réglages, `none` quand il n'y en avait pas. Chaque jeu d'une file d'attente a son propre fichier et son propre en-tête. [#debugprint] ==== debugPrint [source,text] ---- debugPrint(message) ---- Affiche une ligne sur la console (sortie standard). Rien n'est affiché quand Singe a été lancé avec `--noconsole` ; avec `--trace`, l'appel et son message atterrissent aussi dans `trace.txt`. L'argument doit être une chaîne ou un nombre ; tout le reste met fin au script : enveloppez donc les valeurs dans `tostring` ou utilisez `string.format`. Il coûte assez peu pour rester dans le code livré, mais il n'est pas limité en débit, si bien qu'un affichage dans `onOverlayUpdate` s'exécute à chaque image. *Paramètres :* * `message` -- le texte à afficher ; un nombre est converti. *Depuis :* 1.x *Voir aussi :* <> .Exemple [source,lua] ---- -- Noter pourquoi le tir a manqué, avec l'image où c'est arrivé. function resolveShot(x, y) local hit = targetAt(x, y) if not hit then debugPrint(string.format("miss at %d,%d on frame %d", x, y, discGetFrame())) soundPlay(missClip) return end score = score + hit.points end ---- [#linedraw] ==== lineDraw [source,text] ---- lineDraw(x0, y0, z0, x1, y1, z1) lineDraw(x0, y0, z0, x1, y1, z1, r, g, b) ---- Trace une ligne droite entre deux points de l'espace du monde, par-dessus la scène, pour l'image en cours seulement, en blanc sauf si une couleur est donnée. Appelez-la à chaque image pour garder une ligne à l'écran, comme avec l'overlay. La ligne subit le test de profondeur contre la scène mais n'écrit pas la profondeur : elle se cache donc derrière les murs comme tout le reste et ne masque jamais rien. Rien n'est dessiné tant que la scène est désactivée. Elle sert à voir ce que fait un script (un lancer de rayon, un chemin, une boîte englobante) ; `physicsSetDebug` et `navDraw` dessinent par ces mêmes lignes. *Paramètres :* * `x0, y0, z0` -- le point de départ, en unités du monde. * `x1, y1, z1` -- le point d'arrivée, en unités du monde. * `r, g, b` -- la couleur de la ligne, de `0` à `255` par canal, ramenée dans la plage ; blanc si elle est omise. *Depuis :* 3.00. *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- Montrer où vise la tourelle et si le tir porterait. function onOverlayUpdate() local tx, ty, tz = nodeGetWorldPosition(turret) local hit, hx, hy, hz = physicsRaycast(tx, ty, tz, aimX, aimY, aimZ, 40) if hit then lineDraw(tx, ty, tz, hx, hy, hz, 255, 255, 0) else lineDraw(tx, ty, tz, tx + aimX * 40, ty + aimY * 40, tz + aimZ * 40, 120, 120, 120) end return OVERLAY_UPDATED end ---- [#disc] === Disque Le *disque* est la vidéo laserdisc principale, celle que Singe a été conçu à l'origine pour émuler. Un jeu a au plus un disque, ouvert au démarrage à partir de la `VIDEO` nommée dans `games.dat` ou de `--disc` / `--framefile` sur la ligne de commande ; un jeu qui n'en nomme aucune tourne sans disque (voir <>). Toutes les fonctions `disc*` agissent sur ce disque unique et implicite et ne prennent pas de handle. Les numéros d'image sont des positions comptées à partir de zéro dans le fichier vidéo (ou dans le framefile combiné) et servent de référence temporelle à chaque décision d'embranchement d'un jeu laserdisc. Sans disque, les fonctions d'interrogation renvoient `0`, `discGetState` signale `DISC_EJECTED`, et les fonctions de transport écrivent une trace et ne font rien. Les clips supplémentaires superposés au disque sont l'affaire de la famille <>. [#discaudio] ==== discAudio [source,text] ---- discAudio(channel, enabled) ---- Active ou coupe l'un des canaux de la bande-son du disque. Le canal est réglé sur le volume laserdisc configuré (`--volume_vldp`, `100` par défaut) quand `enabled` vaut `true`, et sur `0` quand il vaut `false` ; l'autre canal reste tel quel. Beaucoup de titres d'origine portaient une narration ou une piste de données sur un seul canal, et c'est cet appel qui la fait taire. Un canal autre que `1` ou `2` met fin au script. Sans disque, l'appel ne fait rien. *Paramètres :* * `channel` -- `1` pour le canal gauche, `2` pour le droit. * `enabled` -- booléen. `true` remet le canal au volume laserdisc configuré, `false` le rend muet. *Depuis :* 1.x *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- Le canal droit de ce disque porte la piste de données de la borne d'arcade. function startGame() discAudio(1, true) discAudio(2, false) discSkipToFrame(SCENE_FIRST_FRAME) end ---- [#discaudiosuffix] ==== discAudioSuffix [source,text] ---- opened = discAudioSuffix(suffix) ---- Extension Hypseus. Fait passer le disque à un fichier audio séparé placé à côté de sa vidéo : `.ogg`, où la base est le nom du fichier vidéo sans son extension ; ainsi `movie.m2v` avec `"-es"` joue `movie-es.ogg`. Le fichier est ouvert à l'image en cours et la lecture se réaligne, comme avec `discSetAudioTrack`, en gardant le volume, l'état de lecture et l'indice de piste quand le nouveau fichier a autant de pistes. Avec un framefile, tous les segments changent. Un suffixe vide rétablit l'audio par défaut : le `.ogg` à côté d'un `.m2v`, ou l'audio contenu dans tout autre conteneur. Renvoie `true` quand le fichier s'est ouvert ; quand il manque, l'audio précédent continue de jouer, rien n'est mémorisé et `false` est renvoyé (Hypseus laisse alors le silence). Le suffixe est oublié quand le script est rechargé. Sans disque, l'appel renvoie `false`. Servez-vous-en pour un menu de langues dont les pistes sont livrées en fichiers séparés ; les pistes contenues dans un même fichier sont l'affaire de `discSetAudioTrack`. *Paramètres :* * `suffix` -- chaîne ajoutée au nom de base de la vidéo, comme `"-es"` ; `""` pour l'audio par défaut. *Renvoie :* booléen. *Depuis :* 3.00, comme extension d'Hypseus Singe. *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- Les fichiers de langue sont livrés sous les noms movie.ogg, movie-es.ogg et movie-fr.ogg. languages = { { "English", "" }, { "Spanish", "-es" }, { "French", "-fr" } } function applyLanguage(index) if not discAudioSuffix(languages[index][2]) then discAudioSuffix("") index = 1 end settings.language = index end ---- [#discchangespeed] ==== discChangeSpeed [source,text] ---- discChangeSpeed(...) ---- Ne fait rien. Les vrais lecteurs de laserdisc pouvaient tourner à une autre vitesse que 1x ; le lecteur logiciel de Singe ne le peut pas, donc l'appel accepte n'importe quels arguments, écrit une ligne de trace et rend la main. Il est conservé pour que les scripts antérieurs à 2.00 se chargent sans modification. *Remarques :* WARNING: *Non implémenté.* Conservé uniquement pour la rétrocompatibilité. Il n'y a pas de remplacement ; la vitesse de lecture est fixée à la fréquence d'images du fichier vidéo. *Depuis :* 1.x *Voir aussi :* <>, <> .Exemple [source,lua] ---- -- D'anciens scripts encadraient parfois un ralenti par des changements de vitesse. -- Ces appels sont inoffensifs aujourd'hui, mais le ralenti passe à vitesse normale. discChangeSpeed(0.5) discSkipToFrame(REPLAY_FIRST_FRAME) discChangeSpeed(1) ---- [#discgetaudiotrack] ==== discGetAudioTrack [source,text] ---- track = discGetAudioTrack() ---- Renvoie l'indice, compté à partir de zéro, de la piste audio que joue actuellement le disque. Au démarrage, le moteur choisit la piste désignée par `--audio` ou par l'entrée `AUDIO_TRACK` de `games.dat` (`0` par défaut) quand le disque a autant de pistes. Renvoie `0` sans disque. *Renvoie :* indice de piste entier. *Depuis :* 2.10 *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- Passer au doublage suivant chaque fois que le joueur appuie sur la touche de langue. function onInputPressed(what) if what == SWITCH_BUTTON2 then local next = (discGetAudioTrack() + 1) % discGetAudioTracks() discSetAudioTrack(next) languageLabel = discGetLanguageDescription(discGetLanguage(next)) end end ---- [#discgetaudiotracks] ==== discGetAudioTracks [source,text] ---- count = discGetAudioTracks() ---- Renvoie le nombre de flux audio que contient le fichier vidéo du disque. Plusieurs flux sont en général des doublages dans d'autres langues. Une vidéo muette renvoie `0`, tout comme un jeu sans disque : protégez donc les boucles et les calculs modulo contre le zéro. *Renvoie :* nombre entier, `0` ou plus. *Depuis :* 2.10 *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- Construire un menu de choix de langue à partir des pistes audio du disque. languages = {} for track = 0, discGetAudioTracks() - 1 do local code = discGetLanguage(track) local name = discGetLanguageDescription(code) table.insert(languages, { track = track, label = name }) end ---- [#discgetsubtitlelanguage] ==== discGetSubtitleLanguage [source,text] ---- language = discGetSubtitleLanguage(track) ---- La langue indiquée sur une piste de sous-titres contenue dans le conteneur même du disque, sous la forme du code ISO 639 que porte le fichier (`eng`, `fra`, `spa`). Une piste que le fichier n'étiquette pas renvoie une chaîne vide, ce qui n'est pas une erreur -- bien des fichiers n'étiquettent rien. *Paramètres :* * `track` -- nombre ; de `0` à `discGetSubtitleTracks() - 1`. *Renvoie :* une chaîne. *Depuis :* 3.00. *Voir aussi :* <>, <> [#discgetsubtitletracks] ==== discGetSubtitleTracks [source,text] ---- count = discGetSubtitleTracks() ---- Le nombre de pistes de sous-titres que contient le conteneur même du disque. `0` sans disque, et `0` pour un disque dont les sous-titres sont livrés dans un `.srt` séparé -- c'est la disposition habituelle, et ce à quoi sert <>. Les pistes qui contiennent des images plutôt que des mots (celles des DVD et des Blu-ray, VobSub et PGS) sont comptées ici pour que la numérotation corresponde à ce que montre n'importe quel autre lecteur, mais <> ne peut pas les lire : il n'y a rien à lire, seulement des bitmaps des mots. *Paramètres :* * Aucun. *Renvoie :* un nombre. *Depuis :* 3.00. *Voir aussi :* <>, <> .Exemple [source,lua] ---- -- Proposer toutes les langues que porte le disque lui-même. for track = 0, discGetSubtitleTracks() - 1 do languages[#languages + 1] = { track = track, code = discGetSubtitleLanguage(track) } end ---- [#discgetframe] ==== discGetFrame [source,text] ---- frame = discGetFrame() ---- Renvoie l'image que le disque montre en ce moment. Avec un framefile, le numéro est la position dans la séquence combinée, si bien que les limites des segments sont invisibles pour le script. Renvoie `0` tant que le disque est arrêté, et sans disque. Interrogez-la depuis `onOverlayUpdate` pour réagir quand la vidéo atteint un point de décision. *Renvoie :* numéro d'image entier. *Depuis :* 1.x *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- Boucler la séquence d'attraction et afficher une invite pendant la fenêtre où il faut se baisser. function onOverlayUpdate() local frame = discGetFrame() if frame >= ATTRACT_LAST_FRAME then discSkipToFrame(ATTRACT_FIRST_FRAME) elseif frame >= DUCK_FIRST_FRAME and frame <= DUCK_LAST_FRAME then drawPrompt("DUCK") end return OVERLAY_UPDATED end ---- [#discgetframecount] ==== discGetFrameCount [source,text] ---- frames = discGetFrameCount() ---- Le nombre d'images du disque, soit un de plus que la plus haute image que <> trouvera. Un framefile répond pour tout le disque plutôt que pour le segment en cours de lecture : le compte ne change donc pas à mesure que les segments vont et viennent. Sans disque, il répond `0`. *Renvoie :* nombre d'images entier, `0` sans disque. *Depuis :* 3.00. *Voir aussi :* <>, <> .Exemple [source,lua] ---- -- Sauter au milieu du disque, où qu'il soit. discSearch(math.floor(discGetFrameCount() / 2)) ---- [#discgetheight] ==== discGetHeight [source,text] ---- height = discGetHeight() ---- Renvoie la hauteur de la vidéo du disque, en pixels. Sans disque, elle renvoie la hauteur du canevas (`CANVAS_Y` dans `games.dat` ou `--canvas`, `480` par défaut) : c'est donc toujours la taille du fond sur lequel l'overlay est étiré. Associez-la à `discGetWidth` pour donner à l'overlay la même résolution que la vidéo. *Renvoie :* hauteur entière en pixels. *Depuis :* 2.00 *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- Dessiner à la résolution native du disque pour que les tests de pixels et le dessin partagent une même grille. overlaySetResolution(discGetWidth(), discGetHeight()) crosshairLimitX = discGetWidth() - 1 crosshairLimitY = discGetHeight() - 1 ---- [#discgetlanguage] ==== discGetLanguage [source,text] ---- code = discGetLanguage(track) ---- Renvoie l'étiquette de langue stockée dans le fichier vidéo pour l'une des pistes audio du disque. L'étiquette est un code ISO 639 de trois lettres comme `"eng"` ou `"jpn"`. Les pistes sans étiquette de trois lettres renvoient `"unk"`, comme toutes les pistes quand il n'y a pas de disque. Avec un disque, une piste hors de `0` à `discGetAudioTracks() - 1` met fin au script. *Renvoie :* code de langue sous forme de chaîne. *Depuis :* 2.10 *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- Préférer le doublage japonais quand le disque en porte un. for track = 0, discGetAudioTracks() - 1 do if discGetLanguage(track) == "jpn" then discSetAudioTrack(track) break end end ---- [#discgetstate] ==== discGetState [source,text] ---- state = discGetState() ---- Renvoie l'état de transport du disque sous la forme de l'une des constantes `DISC_*`. Un jeu sans disque signale `DISC_EJECTED` ; après `discStop`, le disque signale `DISC_STOPPED` jusqu'au prochain `discPlay`, `discSearch` ou `discSkipToFrame` ; sinon l'état est `DISC_PLAYING` tant que la vidéo avance, et `DISC_PAUSED` quand elle n'avance pas. Il n'y a pas d'état de recherche distinct ; un disque en cours de saut signale `DISC_PAUSED` ou `DISC_PLAYING` selon ce qui a été demandé. *Renvoie :* l'une des constantes suivantes, définies par le moteur et accessibles à tous les scripts. [cols="1,1,1",options="header"] |=== | Constante | Valeur | Signification | `DISC_STOPPED` | `2` | Arrêté (`discStop` a été appelé) | `DISC_PLAYING` | `3` | En lecture | `DISC_PAUSED` | `4` | En pause | `DISC_EJECTED` | `5` | Le jeu n'a pas de disque (voir <>) |=== *Depuis :* 1.x (RDG) *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- La touche pause ne doit pas relancer un arrêt sur image demandé par le script de l'histoire. function onInputPressed(what) if what == SWITCH_PAUSE then if discGetState() == DISC_PAUSED and not storyPaused then discPlay() elseif discGetState() == DISC_PLAYING then discPause() end end end ---- [#discgetwidth] ==== discGetWidth [source,text] ---- width = discGetWidth() ---- Renvoie la largeur de la vidéo du disque, en pixels. Sans disque, elle renvoie la largeur du canevas (`CANVAS_X` dans `games.dat` ou `--canvas`, `720` par défaut). Avec `discGetHeight`, c'est la taille sur laquelle l'overlay est mis à l'échelle, et c'est pourquoi le menu fourni passe les deux à `overlaySetResolution`. *Renvoie :* largeur entière en pixels. *Depuis :* 2.00 *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- Centrer un sprite de titre, quelle que soit la largeur du disque. overlaySetResolution(discGetWidth(), discGetHeight()) titleSprite = spriteLoad("graphics/title.png") titleX = (discGetWidth() - spriteGetWidth(titleSprite)) / 2 ---- [#discpause] ==== discPause [source,text] ---- discPause() ---- Met la lecture en pause sur l'image en cours, qui reste à l'écran. L'audio du disque se met en pause avec elle. L'appel est ignoré tant que le disque est arrêté et ne fait rien sans disque. Reprenez avec `discPlay`. *Depuis :* 1.x *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- Garder la dernière image de la scène pendant que le joueur choisit une porte. function onOverlayUpdate() if discGetFrame() >= SCENE_LAST_FRAME and not choosing then discPause() choosing = true end return OVERLAY_UPDATED end ---- [#discpauseatframe] ==== discPauseAtFrame [source,text] ---- discPauseAtFrame(frame) ---- Saute à `frame`, l'affiche et se met en pause. C'est la même fonction que `discSearch` sous un second nom ; les deux sont interchangeables en tout point, y compris pour les règles de bornage décrites là-bas. Préférez `discSearch` dans le nouveau code et ne gardez `discPauseAtFrame` que pour porter d'anciens scripts. *Remarques :* WARNING: *Alias hérité.* Utilisez <> dans le nouveau code. *Depuis :* 1.18 *Voir aussi :* <>, <> .Exemple [source,lua] ---- -- Code 1.x porté : rester sur la première image de la scène jusqu'à ce que le joueur soit prêt. function onInputPressed(what) if what == SWITCH_START1 then discPauseAtFrame(SCENE_FIRST_FRAME) waitingForPlayer = true end end ---- [#discplay] ==== discPlay [source,text] ---- discPlay() ---- Lance ou reprend la lecture à partir de l'image en cours et efface l'état d'arrêt posé par `discStop`. L'audio reprend avec la vidéo, et l'horloge de lecture repart de l'image en cours pour qu'une longue pause ne provoque pas de saut. Sans disque, ne fait rien d'autre qu'effacer l'indicateur d'arrêt. *Depuis :* 1.x *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- Démarrage classique du mode attraction : caler la première image, puis lancer. function attractBegin() discSearch(ATTRACT_FIRST_FRAME) overlayClear() discPlay() attractRunning = true end ---- [#discsearch] ==== discSearch [source,text] ---- discSearch(frame) ---- Fait sauter le disque à `frame`, affiche cette image et s'y met en pause, en effaçant l'état d'arrêt si le disque était arrêté. Le disque garde l'image jusqu'à `discPlay`, `discSkipToFrame` ou un autre appel de transport. Une image négative est ramenée à la première image et une image au-delà de la fin à la dernière ; avec un framefile, le segment qui contient l'image est sélectionné et la position est bornée à l'intérieur. Ne fait rien sans disque. *Paramètres :* * `frame` -- numéro de l'image visée, compté à partir de zéro. *Depuis :* 1.x *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- Le joueur a choisi le couloir de gauche : caler la scène et attendre le départ. function goLeft() discSearch(CORRIDOR_LEFT_FIRST_FRAME) soundPlay(SOUND_DOOR) waitingForPlayer = true end ---- [#discsearchblanking] ==== discSearchBlanking [source,text] ---- discSearchBlanking(...) ---- Ne fait rien. Sur le matériel laserdisc, cet appel choisissait si l'écran s'éteignait pendant que le lecteur cherchait une image ; le lecteur logiciel de Singe saute sans éteindre l'écran, donc l'appel accepte n'importe quels arguments, écrit une ligne de trace et rend la main. *Remarques :* WARNING: *Non implémenté.* Conservé uniquement pour la rétrocompatibilité. Il n'y a pas de remplacement. *Depuis :* 1.x *Voir aussi :* <>, <> .Exemple [source,lua] ---- -- Les scripts antérieurs à 2.00 réglaient souvent les modes d'extinction une fois au démarrage. Sans danger. discSearchBlanking(false) discSkipBlanking(false) discSearch(TITLE_FRAME) ---- [#discsetaudiotrack] ==== discSetAudioTrack [source,text] ---- discSetAudioTrack(track) ---- Fait passer le disque à une autre piste audio. L'audio en attente de l'ancienne piste est abandonné et la lecture se réaligne, si bien que le changement s'entend presque aussitôt. Avec un disque, une piste hors de `0` à `discGetAudioTracks() - 1` met fin au script ; sans disque, l'appel ne fait rien. Servez-vous-en pour réaliser un menu de langues ou respecter une préférence enregistrée d'une session à l'autre. *Paramètres :* * `track` -- indice de piste audio, compté à partir de zéro. *Depuis :* 2.10 *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- Appliquer la langue que le joueur a choisie dans l'écran des options. function applyLanguage(choice) if choice >= 0 and choice < discGetAudioTracks() then discSetAudioTrack(choice) settings.language = choice saveSettings() end end ---- [#discsetfps] ==== discSetFPS [source,text] ---- discSetFPS(...) ---- Ne fait rien. La fréquence d'images vient du fichier vidéo, donc l'appel accepte n'importe quels arguments, écrit une ligne de trace et rend la main. Rien dans le `Framework.singe` fourni ne l'appelle ; seuls les scripts antérieurs à 2.00 le font. *Remarques :* WARNING: *Non implémenté.* Conservé uniquement pour la rétrocompatibilité. La fréquence d'images est lue dans le fichier vidéo et ne peut pas être réglée depuis un script. *Depuis :* 1.x *Voir aussi :* <>, <> .Exemple [source,lua] ---- -- Un script 1.x porté. L'appel est ignoré ; le nombre d'images par seconde est une propriété du fichier. discSetFPS(29.97) discSearch(TITLE_FRAME) discPlay() ---- [#discskipbackward] ==== discSkipBackward [source,text] ---- discSkipBackward(frames) ---- Saute de `frames` images en arrière à partir de l'image en cours sans changer l'état de lecture ou de pause : un disque en lecture continue de jouer depuis la nouvelle position, un disque en pause y reste en pause. L'appel est ignoré tant que le disque est arrêté, et sans disque. L'écart n'est pas vérifié ; une valeur négative fait sauter en avant, et un écart plus grand que l'image en cours s'arrête à l'image `0`. Avec un framefile, l'écart s'applique au numéro d'image que renvoie `discGetFrame`, si bien qu'un saut peut passer dans un segment antérieur. *Paramètres :* * `frames` -- nombre d'images à retrancher de l'image en cours. *Depuis :* 1.x *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- Rejouer la dernière seconde (30 images) quand le joueur rate une invite. function missedPrompt() lives = lives - 1 discSkipBackward(30) promptShown = false end ---- [#discskipblanking] ==== discSkipBlanking [source,text] ---- discSkipBlanking(...) ---- Ne fait rien. Il choisissait autrefois si l'écran s'éteignait pendant un saut sur le matériel laserdisc ; le lecteur logiciel de Singe n'éteint jamais l'écran, donc l'appel accepte n'importe quels arguments, écrit une ligne de trace et rend la main. *Remarques :* WARNING: *Non implémenté.* Conservé uniquement pour la rétrocompatibilité. Il n'y a pas de remplacement. *Depuis :* 1.x *Voir aussi :* <>, <> .Exemple [source,lua] ---- -- Les scripts antérieurs à 2.00 réglaient souvent les modes d'extinction une fois au démarrage. Sans danger. discSearchBlanking(false) discSkipBlanking(false) discSearch(TITLE_FRAME) ---- [#discskipforward] ==== discSkipForward [source,text] ---- discSkipForward(frames) ---- Saute de `frames` images en avant à partir de l'image en cours sans changer l'état de lecture ou de pause. Pendant de `discSkipBackward` : ignoré à l'arrêt et sans disque, l'écart n'est pas vérifié, et une cible au-delà de la dernière image s'arrête à la dernière image. Avec un framefile, l'écart s'applique au numéro d'image que renvoie `discGetFrame`, si bien qu'un saut peut passer dans un segment ultérieur. *Paramètres :* * `frames` -- nombre d'images à ajouter à l'image en cours. *Depuis :* 1.x *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- Laisser un joueur qui revient sauter l'introduction de deux secondes de la scène. function onInputPressed(what) if what == SWITCH_BUTTON1 and inSceneIntro then discSkipForward(60) inSceneIntro = false end end ---- [#discskiptoframe] ==== discSkipToFrame [source,text] ---- discSkipToFrame(frame) ---- Saute à `frame` et lance la lecture à partir de là, quel que soit l'état du disque, en effaçant l'état d'arrêt si nécessaire. À comparer avec `discSearch`, qui saute et se met en pause. Une image avant le début ou au-delà de la fin est ramenée à la première ou à la dernière image ; avec un framefile, le segment qui contient l'image est sélectionné et la position est bornée à l'intérieur. Sans disque, ne fait rien d'autre qu'effacer l'indicateur d'arrêt. *Paramètres :* * `frame` -- numéro de l'image visée, compté à partir de zéro. *Depuis :* 1.x *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- Sauter l'introduction quand on rejoue. function startGame() if hasPlayedBefore then discSkipToFrame(GAMEPLAY_FIRST_FRAME) else discSkipToFrame(INTRO_FIRST_FRAME) end end ---- [#discstepbackward] ==== discStepBackward [source,text] ---- discStepBackward() ---- Recule le disque d'exactement une image et s'y met en pause, quel que soit l'état de lecture précédent. Reculer depuis l'image `0` reste sur l'image `0`. Ignoré tant que le disque est arrêté, comme `discSkipBackward`. Destiné au débogage à l'image près et aux écrans de service. Ne fait rien sans disque. *Depuis :* 1.x *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- Écran de service : gauche et droite déplacent le disque d'une image à la fois. function onInputPressed(what) if what == SWITCH_LEFT then discStepBackward() elseif what == SWITCH_RIGHT then discStepForward() end end ---- [#discstepforward] ==== discStepForward [source,text] ---- discStepForward() ---- Avance le disque d'exactement une image et s'y met en pause, quel que soit l'état de lecture précédent. Avancer depuis la dernière image reste sur la dernière image. Comme `discStepBackward`, il est ignoré tant que le disque est arrêté. Ne fait rien sans disque. *Depuis :* 1.x *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- Chercheur d'images : parcourir une scène image par image et afficher le numéro d'image. function onOverlayUpdate() overlayClear() overlayPrint(8, 8, "Frame " .. discGetFrame()) if stepping then discStepForward() end return OVERLAY_UPDATED end ---- [#discstop] ==== discStop [source,text] ---- discStop() ---- Arrête le disque. La lecture se met en pause, la vidéo est remplacée par l'écran bleu classique, `discGetState` signale `DISC_STOPPED` et `discGetFrame` renvoie `0` jusqu'à ce que le disque soit relancé. À l'arrêt, `discPause`, `discSkipForward`, `discSkipBackward`, `discStepForward` et `discStepBackward` sont ignorés. N'importe lequel de `discPlay`, `discSearch` ou `discSkipToFrame` efface l'état d'arrêt. Un second `discStop` est ignoré. *Depuis :* 1.x *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- Fin de partie : passer à l'écran bleu derrière le tableau des scores. function gameOver() discStop() soundPlay(SOUND_GAME_OVER) showScores = true end ---- [#emitter] === Émetteur Les émetteurs font naître, déplacent et dessinent des particules ; voir <>. Les handles sont des entiers. Un émetteur 2D vit dans les coordonnées de l'overlay, Y vers le bas, et se dessine quand `emitterDraw` le demande depuis `onOverlayUpdate` ; un émetteur 3D est posé sur un nœud, vit en unités du monde et se dessine lui-même dans la scène. Les appels de recette peuvent se faire dans n'importe quel ordre, avant ou après le démarrage de l'émetteur, et prennent effet sur les prochaines particules qui naissent (quelques-uns, signalés plus bas, agissent aussi sur les particules vivantes). Tout ce qui est donné sous forme de minimum et de maximum est tiré au hasard entre les deux pour chaque particule. Les particules avancent avec l'horloge, d'un dixième de seconde par image au plus, et s'arrêtent tant que le jeu est en pause. [#emitternew] ==== emitterNew [source,text] ---- emitter = emitterNew() emitter = emitterNew(node) ---- Crée un émetteur. Sans argument, c'est un émetteur 2D placé à la position `(0, 0)` de l'overlay, que l'on déplace avec `emitterSetPosition` ; avec un nœud, c'est un émetteur 3D dont les particules naissent à la position du nœud dans le monde, et il a besoin de la scène 3D : sur une machine qui n'en a pas, l'appel met fin au script (les émetteurs 2D fonctionnent partout). Un nouvel émetteur n'émet pas en continu ; appelez `emitterStart` ou `emitterBurst`. Supprimer le nœud supprime ses émetteurs. Valeurs par défaut : 50 particules par seconde, vivant une à deux secondes, ouverture de 30 degrés, blanc s'estompant jusqu'au transparent, mélange normal, ni gravité, ni freinage, ni rotation, ni rayon, ni texture, ni traînée, ni collision, une réserve de 1000 ; un émetteur 2D les lance vers le haut de l'écran à 50 à 100 unités de l'overlay par seconde en les réduisant de 16 à 4, un émetteur 3D vers le haut de l'axe Y à 1 à 2 unités du monde par seconde en les réduisant de 0,2 à 0,05. *Renvoie :* le handle de l'émetteur. *Depuis :* 3.00. *Voir aussi :* <>, <>, <>, <> .Exemple [source,lua] ---- -- Les gaz d'échappement du nœud moteur du vaisseau ; la recette est réglée avant le début du flux. exhaust = emitterNew(nodeFind("Engine", ship)) emitterSetBlend(exhaust, PARTICLE_ADD) emitterSetRate(exhaust, 120) emitterSetLife(exhaust, 0.3, 0.6) emitterSetDirection(exhaust, 0, 0, 1) emitterSetSpeed(exhaust, 3, 5) emitterSetSize(exhaust, 0.3, 0.05) emitterSetColor(exhaust, 120, 180, 255, 255, 30, 60, 255, 0) emitterStart(exhaust) ---- [#emitterdelete] ==== emitterDelete [source,text] ---- emitterDelete(emitter) ---- Supprime d'un coup l'émetteur, ses particules vivantes et sa copie de la texture ; rien ne s'estompe. Utilisez plutôt `emitterStop` pour laisser les dernières particules finir. Un émetteur 3D est aussi supprimé de lui-même quand son nœud est supprimé. Un handle qui n'est pas celui d'un émetteur vivant met fin au script : effacez donc la variable ensuite. *Depuis :* 3.00. *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- L'explosion est finie : libérer son émetteur une fois la dernière étincelle éteinte. function onOverlayUpdate() if explosion ~= nil and not emitterIsActive(explosion) then emitterDelete(explosion) explosion = nil end return OVERLAY_NOT_UPDATED end ---- [#emittersettexture] ==== emitterSetTexture [source,text] ---- emitterSetTexture(emitter, sprite) emitterSetTexture(emitter) ---- L'image que porte chaque particule : un sprite chargé, ou aucune (un seul argument, ou `nil`) pour le disque flou intégré. L'émetteur garde sa propre copie des pixels du sprite, toutes les images d'un sprite animé comprises, si bien que le sprite peut être déchargé ensuite. Régler la texture ramène la plage d'images à l'image `0` et place chaque particule vivante sur l'image `0` : appelez donc `emitterSetFrames` après, pas avant. Le mélange et la teinte s'appliquent à l'image comme au disque. *Depuis :* 3.00. *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- Neige : une planche de six formes de flocons, une tirée au hasard par particule. flakes = spriteLoadFrames(6, DIR .. "Sprites/flakes.png") snow = emitterNew() emitterSetTexture(snow, flakes) emitterSetFrames(snow, 0, 5) emitterSetDirection(snow, 0, 1) emitterSetSpeed(snow, 20, 40) emitterSetLife(snow, 6, 9) emitterSetSize(snow, 6, 6, 0.4) emitterStart(snow) ---- [#emittersetframes] ==== emitterSetFrames [source,text] ---- emitterSetFrames(emitter, first, last) ---- Avec un sprite animé comme texture, chaque nouvelle particule prend une image au hasard de `first` à `last` et la garde toute sa vie ; les images se comptent à partir de `0`. Les valeurs sont bornées aux images que possède la texture, et `last` est relevé à `first` quand il est plus petit. Par défaut : l'image `0` seule, qui est aussi ce à quoi `emitterSetTexture` revient. Avec le disque intégré ou un sprite d'une seule image, la plage n'a aucun effet. *Depuis :* 3.00. *Voir aussi :* <>, <> .Exemple [source,lua] ---- -- La planche de bouffées a quatre bouffées floues, puis quatre éclats de débris plus nets. emitterSetTexture(smoke, puffSheet) emitterSetFrames(smoke, 0, 3) emitterSetTexture(debris, puffSheet) emitterSetFrames(debris, 4, 7) ---- [#emittersetblend] ==== emitterSetBlend [source,text] ---- emitterSetBlend(emitter, mode) ---- La façon dont les particules se combinent avec ce qui est derrière elles. `PARTICLE_ALPHA` (par défaut) mélange normalement, pour la fumée, la poussière, la pluie et les débris ; `PARTICLE_ADD` ajoute de la lumière, pour le feu, les étincelles, les lueurs et la magie, et n'assombrit jamais. Prend effet aussitôt sur les particules vivantes. Toute autre valeur que ces deux constantes met fin au script. *Depuis :* 3.00. *Voir aussi :* <>, <> .Exemple [source,lua] ---- -- Les étincelles brillent ; la fumée au-dessus d'elles, non. emitterSetBlend(sparks, PARTICLE_ADD) emitterSetColor(sparks, 255, 220, 120, 255, 255, 80, 20, 0) emitterSetBlend(smoke, PARTICLE_ALPHA) emitterSetColor(smoke, 160, 160, 170, 120, 120, 120, 130, 0) ---- [#emittersetlayer] ==== emitterSetLayer [source,text] ---- emitterSetLayer(emitter, layer) ---- 2D seulement : où `emitterDraw` place les particules. `PARTICLE_OVER` (par défaut) dessine au-dessus de tout ce qui est sur l'overlay ; `PARTICLE_UNDER` dessine sous l'overlay et au-dessus de la vidéo et de la scène 3D, pour de la fumée derrière le score. Sur un émetteur 3D, le réglage est accepté et ignoré. Toute autre valeur que ces deux constantes met fin au script. *Depuis :* 3.00. *Voir aussi :* <>, <> .Exemple [source,lua] ---- -- La pluie tombe derrière le HUD, les étincelles d'impact devant. rain = emitterNew() emitterSetLayer(rain, PARTICLE_UNDER) hits = emitterNew() emitterSetLayer(hits, PARTICLE_OVER) function onOverlayUpdate() overlayClear() emitterDraw(rain) drawHud() emitterDraw(hits) return OVERLAY_UPDATED end ---- [#emittersetrate] ==== emitterSetRate [source,text] ---- emitterSetRate(emitter, perSecond) ---- Le nombre de particules par seconde que crée un émetteur démarré ; `50` par défaut, les valeurs négatives deviennent `0`. Les fractions s'accumulent, si bien que `0.5` en crée une toutes les deux secondes. Changez-le pendant l'émission pour moduler une flamme ou un propulseur. Une réserve pleine (`emitterSetMax`) n'en crée plus tant que certaines ne sont pas mortes, quel que soit le débit. *Depuis :* 3.00. *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- Le panache du propulseur suit la manette des gaz. function onControllerMoved(axis, value, which) if axis == GAMEPAD_AXIS_RIGHT_TRIGGER then throttle = value / 32767 emitterSetRate(plume, 30 + 300 * throttle) end end ---- [#emittersetlife] ==== emitterSetLife [source,text] ---- emitterSetLife(emitter, minSeconds, maxSeconds) ---- La durée de vie de chaque nouvelle particule, tirée au hasard entre les deux ; de `1` à `2` par défaut. Sa taille et sa couleur vont de leur début à leur fin pendant ce temps, si bien que la durée de vie donne le rythme de chaque fondu. Le minimum est maintenu à `0.001` ou plus et le maximum est relevé au minimum quand il est plus petit. Les particules vivantes gardent la durée de vie avec laquelle elles sont nées. *Depuis :* 3.00. *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- Éclair de bouche : une salve qui disparaît en un dixième de seconde. flash = emitterNew(muzzle) emitterSetBlend(flash, PARTICLE_ADD) emitterSetLife(flash, 0.05, 0.1) emitterSetSpeed(flash, 2, 6) emitterSetSize(flash, 0.15, 0.4) function onFire() emitterBurst(flash, 12) end ---- [#emittersetsoftness] ==== emitterSetSoftness [source,text] ---- emitterSetSoftness(emitter, distance) ---- 3D seulement : estompe une particule sur `distance` unités du monde là où elle rencontre la géométrie derrière elle, si bien qu'une bouffée de fumée ne trace pas de ligne nette là où elle traverse un mur et qu'une fontaine s'enfonce dans son bassin au lieu de le trancher. `0` (la valeur par défaut) le désactive ; les valeurs négatives deviennent `0`. Tant qu'un émetteur l'utilise, la profondeur de la scène est rendue une fois de plus par image : un Raspberry Pi devrait donc l'utiliser avec parcimonie. Un émetteur 2D garde la valeur et l'ignore. *Depuis :* 3.00. *Voir aussi :* <>, <> .Exemple [source,lua] ---- -- La fumée du brasero s'adoucit contre le poteau qu'elle longe en montant. smoke = emitterNew(brazierTop) emitterSetRate(smoke, 25) emitterSetLife(smoke, 1.5, 2.5) emitterSetSize(smoke, 0.2, 0.9, 0.2) emitterSetColor(smoke, 160, 160, 170, 120, 120, 120, 130, 0) emitterSetLit(smoke, true) emitterSetSoftness(smoke, 0.3) emitterStart(smoke) ---- [#emittersetspeed] ==== emitterSetSpeed [source,text] ---- emitterSetSpeed(emitter, min, max) ---- La vitesse de départ de chaque nouvelle particule, tirée au hasard entre les deux, en unités de l'overlay par seconde (2D) ou en unités du monde par seconde (3D) ; par défaut de `50` à `100` en 2D et de `1` à `2` en 3D. Le maximum est relevé au minimum quand il est plus petit. La vitesse décide jusqu'où va une particule avant que le freinage, la gravité et sa durée de vie prennent le relais. *Depuis :* 3.00. *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- Une fontaine projette ses gouttes fort et droit ; la poussière bouge à peine. emitterSetSpeed(fountain, 4.0, 5.0) emitterSetSpread(fountain, 8) emitterSetSpeed(dust, 0.05, 0.2) emitterSetSpread(dust, 180) ---- [#emittersetdirection] ==== emitterSetDirection [source,text] ---- emitterSetDirection(emitter, x, y) emitterSetDirection(emitter, x, y, z) ---- La direction moyenne dans laquelle partent les nouvelles particules ; seule la direction compte, pas la longueur. Par défaut en 2D `(0, -1)`, vers le haut de l'écran puisque le Y de l'overlay croît vers le bas ; par défaut en 3D `(0, 1, 0)`, vers le haut de l'axe Y du monde. Un émetteur 2D ignore `z` ; un émetteur 3D qui reçoit deux valeurs prend `z = 0`. Un vecteur nul est traité comme `(0, -1, 0)`. Pour un émetteur 3D, la direction s'exprime dans les axes du monde, pas dans ceux du nœud : tournez-la vous-même quand le nœud tourne. *Depuis :* 3.00. *Voir aussi :* <>, <> .Exemple [source,lua] ---- -- Les gaz s'échappent derrière le vaisseau, où qu'il soit tourné. function onOverlayUpdate() local heading = math.rad(shipYaw) emitterSetDirection(exhaust, -math.sin(heading), 0, -math.cos(heading)) return OVERLAY_NOT_UPDATED end ---- [#emittersetspread] ==== emitterSetSpread [source,text] ---- emitterSetSpread(emitter, degrees) ---- La moitié de l'angle du cône (3D) ou de l'éventail (2D), autour de la direction, dans lequel les nouvelles particules sont projetées, de `0` à `180` ; `0` est une ligne droite, `180` toutes les directions. `30` par défaut ; les valeurs hors plage sont bornées. En 3D, les directions sont réparties uniformément sur la calotte du cône, si bien qu'un cône large ne se resserre pas vers son axe. *Depuis :* 3.00. *Voir aussi :* <>, <> .Exemple [source,lua] ---- -- Le feu d'artifice part dans tous les sens ; la flamme de la torche reste une colonne serrée. emitterSetSpread(fireworks, 180) emitterSetSpread(flame, 15) ---- [#emittersetgravity] ==== emitterSetGravity [source,text] ---- emitterSetGravity(emitter, x, y) emitterSetGravity(emitter, x, y, z) ---- Une accélération constante sur chaque particule, en unités de l'overlay (2D) ou en unités du monde (3D) par seconde au carré : `(0, 500)` tire les étincelles 2D vers le bas de l'écran, `(0, -20)` laisse monter la fumée 2D, `(0, -9.8, 0)` est la Terre dans la scène. Aucune par défaut. Elle s'applique aux particules vivantes dès l'image suivante. Les particules ne sont pas des corps physiques ; donnez-leur `emitterSetCollide` pour qu'elles rebondissent sur un sol ou sur la scène. *Depuis :* 3.00. *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- Les étincelles tombent, la fumée monte, toutes deux de la même meuleuse. emitterSetGravity(sparks, 0, 500) emitterSetGravity(smoke, 0, -20) -- Sous l'eau, les bulles filent vers la surface. emitterSetGravity(bubbles, 0, 3, 0) ---- [#emittersetdrag] ==== emitterSetDrag [source,text] ---- emitterSetDrag(emitter, perSecond) ---- La vitesse à laquelle les particules perdent leur vélocité : à chaque image, la vélocité est multipliée par `1 - perSecond * dt`, jamais en dessous de `0`, si bien que `1` enlève l'essentiel de la vitesse en une seconde et que `0` (la valeur par défaut) la garde pour toujours. Les valeurs négatives deviennent `0`. La fumée, les braises et les feux d'artifice ralentissent ; les étincelles et la pluie, non. S'applique aux particules vivantes dès l'image suivante. *Depuis :* 3.00. *Voir aussi :* <>, <> .Exemple [source,lua] ---- -- Une bombe de feu d'artifice : sortie rapide, puis suspendue et retombant. fireworks = emitterNew() emitterSetBlend(fireworks, PARTICLE_ADD) emitterSetSpeed(fireworks, 120, 320) emitterSetSpread(fireworks, 180) emitterSetGravity(fireworks, 0, 220) emitterSetDrag(fireworks, 0.8) emitterSetLife(fireworks, 0.8, 1.6) ---- [#emittersetsize] ==== emitterSetSize [source,text] ---- emitterSetSize(emitter, start, finish) emitterSetSize(emitter, start, finish, variation) ---- La taille à la naissance et à la mort, en unités de l'overlay (2D) ou en unités du monde (3D), qui change régulièrement au fil de la vie de la particule ; par défaut de `16` à `4` en 2D et de `0.2` à `0.05` en 3D. `variation` (de `0` à `1`, `0` par défaut) multiplie les deux tailles de chaque nouvelle particule par un même facteur aléatoire entre `1 - variation` et `1 + variation`, si bien qu'une bouffée est un peu plus grande ou plus petite toute sa vie. Les tailles négatives deviennent `0` et `variation` est borné. Une particule texturée est dessinée comme un carré de cette taille ; une image plus large est écrasée. *Depuis :* 3.00. *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- La fumée grossit en s'éclaircissant ; les étincelles rétrécissent jusqu'à rien. emitterSetSize(smoke, 12, 48, 0.2) emitterSetSize(sparks, 5, 1) ---- [#emittersetcollide] ==== emitterSetCollide [source,text] ---- emitterSetCollide(emitter, mode) emitterSetCollide(emitter, mode, bounce) emitterSetCollide(emitter, mode, bounce, friction) emitterSetCollide(emitter, mode, bounce, friction, floor) ---- Ce sur quoi rebondissent les particules. `COLLIDE_NONE` (par défaut), c'est rien ; `COLLIDE_FLOOR`, c'est un plan horizontal à la hauteur `floor`, sous lequel (Y plus grand en 2D, Y plus petit en 3D) une particule est remise sur le plan et réfléchie ; `COLLIDE_SCENE`, c'est le monde physique, pour les émetteurs 3D seulement, par un lancer de rayon par particule le long de son mouvement à chaque image, plafonné à `200` lancers par émetteur et par image et repris là où ils s'étaient arrêtés, si bien qu'un gros émetteur répartit le coût sur quelques images. Sur un émetteur 2D, `COLLIDE_SCENE` est accepté et ne fait rien. Un mode autre que ces trois-là met fin au script. *Paramètres :* * `mode` -- `COLLIDE_NONE`, `COLLIDE_FLOOR` ou `COLLIDE_SCENE`. * `bounce` -- la part de vitesse qui survit à un choc, de `0` à `1`, `0.5` par défaut ; bornée. * `friction` -- la part de vitesse de glissement perdue à chaque choc, de `0` à `1`, `0.2` par défaut ; bornée. * `floor` -- hauteur du plan du sol, en unités du monde pour un émetteur 3D ou en unités de l'overlay pour un émetteur 2D, `0` par défaut. Utilisé par `COLLIDE_FLOOR` seulement. *Depuis :* 3.00. *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- Les étincelles rebondissent sur le sol de l'atelier (ligne 440 de l'overlay) et laissent des traînées. sparks = emitterNew() emitterSetBlend(sparks, PARTICLE_ADD) emitterSetGravity(sparks, 0, 500) emitterSetCollide(sparks, COLLIDE_FLOOR, 0.4, 0.2, 440) emitterSetTrail(sparks, 6, 3) -- Les braises de la scène rebondissent sur tout ce que contient le monde physique. emitterSetCollide(embers, COLLIDE_SCENE, 0.3, 0.5) ---- [#emittersetcolor] ==== emitterSetColor [source,text] ---- emitterSetColor(emitter, r, g, b, a, r2, g2, b2, a2) ---- La couleur et l'opacité à la naissance (`r, g, b, a`) et à la mort (`r2, g2, b2, a2`), chacune de `0` à `255`, qui changent régulièrement au fil de la vie de la particule ; les valeurs hors plage sont bornées. Par défaut, du blanc opaque au blanc transparent. La teinte multiplie la texture ou le disque intégré, si bien qu'une image blanche prend exactement la couleur. Un fondu en entrée et en sortie demande deux émetteurs ou une texture qui contient la forme ; une recette suit une seule rampe droite. *Depuis :* 3.00. *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- Flamme : jaune vif à la base, qui meurt en rouge terne et disparaît. emitterSetColor(flame, 255, 200, 90, 255, 255, 40, 0, 0) -- Les grains de poussière apparaissent à partir de rien et restent suspendus ; leur durée de vie les achève. emitterSetColor(dust, 255, 240, 200, 0, 255, 240, 200, 160) ---- [#emittersetspin] ==== emitterSetSpin [source,text] ---- emitterSetSpin(emitter, min, max) ---- La vitesse de rotation de chaque nouvelle particule en degrés par seconde, tirée au hasard entre les deux ; les valeurs négatives tournent dans l'autre sens, et une plage qui chevauche `0` donne les deux. Chaque particule part d'un angle aléatoire, qu'elle tourne ou non. Aucune par défaut. Le maximum est relevé au minimum quand il est plus petit. La rotation se voit sur une particule texturée ou sur une traînée ; le disque intégré est rond et paraît identique sous tout angle. *Depuis :* 3.00. *Voir aussi :* <>, <> .Exemple [source,lua] ---- -- Les feuilles virevoltent ; les braises tournent à peine. emitterSetTexture(leaves, leafSprite) emitterSetSpin(leaves, -180, 180) emitterSetSpin(embers, -20, 20) ---- [#emittersetradius] ==== emitterSetRadius [source,text] ---- emitterSetRadius(emitter, radius) ---- Les nouvelles particules naissent n'importe où à cette distance au plus de l'origine de l'émetteur, uniformément sur un disque (2D) ou dans une boule (3D), en unités de l'overlay ou du monde. `0` par défaut, un point ; les valeurs négatives deviennent `0`. Un rayon remplit un volume, une tête de torche ou une nappe de poussière dans l'air, là où un point donnerait une aiguille. *Depuis :* 3.00. *Voir aussi :* <>, <> .Exemple [source,lua] ---- -- De la poussière dans une boule d'air de la taille d'une pièce, presque toute déjà là au début du niveau. air = nodeNew() nodeSetPosition(air, 0, 2, 0) dust = emitterNew(air) emitterSetRadius(dust, 2.5) emitterSetSpread(dust, 180) emitterSetSpeed(dust, 0.05, 0.2) emitterSetLife(dust, 4, 6) emitterStart(dust) emitterBurst(dust, 120) ---- [#emittersetlit] ==== emitterSetLit [source,text] ---- emitterSetLit(emitter, lit) ---- 3D seulement : éclaire chaque particule avec les lumières et le ciel de la scène comme s'il s'agissait d'une petite sphère tournée vers la caméra, si bien que la fumée prend la couleur d'un brasero et que la poussière scintille au soleil. Désactivé par défaut ; les ombres ne sont pas consultées. Avec `PARTICLE_ADD`, l'éclairage s'applique quand même à la teinte avant qu'elle soit ajoutée. Un émetteur 2D garde la valeur et l'ignore. *Depuis :* 3.00. *Voir aussi :* <>, <> .Exemple [source,lua] ---- -- La fumée au-dessus de chaque brasero prend la lueur de son feu. for _, brazier in ipairs(braziers) do local smoke = emitterNew(brazier.top) emitterSetRate(smoke, 12) emitterSetLife(smoke, 2.5, 4) emitterSetColor(smoke, 90, 80, 75, 90, 50, 45, 45, 0) emitterSetLit(smoke, true) emitterStart(smoke) end ---- [#emittersetlocal] ==== emitterSetLocal [source,text] ---- emitterSetLocal(emitter, local) ---- Indique si les particules vivantes suivent l'émetteur. `false` (par défaut) laisse chacune là où elle est née, si bien qu'une torche portée à travers une pièce laisse de la fumée derrière elle ; `true` garde tout le nuage relatif à l'origine de l'émetteur, si bien que le panache d'un propulseur se déplace avec le vaisseau. Basculer le réglage fait passer les particules vivantes d'un référentiel à l'autre sans saut. Sur un émetteur 3D, l'origine est la position du nœud dans le monde ; sur un émetteur 2D, c'est le point donné par `emitterSetPosition`, si bien qu'un nuage 2D local glisse lui aussi avec ce point. Les traînées suivent la même règle. *Depuis :* 3.00. *Voir aussi :* <>, <> .Exemple [source,lua] ---- -- Une aura magique qui reste enroulée autour de la magicienne pendant qu'elle marche. aura = emitterNew(nodeFind("Chest", wizard)) emitterSetLocal(aura, true) emitterSetRadius(aura, 0.6) emitterSetSpeed(aura, 0.05, 0.15) emitterSetSpread(aura, 180) emitterSetBlend(aura, PARTICLE_ADD) emitterStart(aura) ---- [#emittersetmax] ==== emitterSetMax [source,text] ---- emitterSetMax(emitter, count) ---- La réserve : le nombre de particules qui peuvent être vivantes en même temps, `1000` par défaut. Une réserve pleine n'en crée plus, ni par le flux ni par une salve, tant que certaines ne sont pas mortes. Changer ce nombre reconstruit la réserve et tue chaque particule vivante : réglez-le donc avant de démarrer. Dimensionnez la réserve au débit multiplié par la plus longue durée de vie, plus la plus grosse salve. Un nombre inférieur à `1` met fin au script. *Depuis :* 3.00. *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- 300 gouttes par seconde vivant jusqu'à 1,6 seconde demandent environ 480 ; gardez de la marge. fountain = emitterNew(spout) emitterSetRate(fountain, 300) emitterSetLife(fountain, 1.0, 1.6) emitterSetMax(fountain, 600) emitterStart(fountain) ---- [#emittersetposition] ==== emitterSetPosition [source,text] ---- emitterSetPosition(emitter, x, y) ---- 2D seulement : où naissent les nouvelles particules, en coordonnées de l'overlay ; `(0, 0)` par défaut. Réglez-la à chaque image pour suivre un sprite ou un corps physique 2D. Les particules vivantes restent où elles sont, sauf si `emitterSetLocal` est activé. Un émetteur 3D suit son nœud et met fin au script ici ; déplacez plutôt le nœud. *Depuis :* 3.00. *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- Les étincelles jaillissent de là où se trouve le sprite de la meuleuse à cette image. function onOverlayUpdate() overlayClear() spriteDraw(grinder, grinderX, grinderY) emitterSetPosition(sparks, grinderX + 24, grinderY + 8) emitterDraw(sparks) return OVERLAY_UPDATED end ---- [#emittersettrail] ==== emitterSetTrail [source,text] ---- emitterSetTrail(emitter, length, width) ---- Un ruban derrière chaque particule, passant par ses `length` dernières positions, une enregistrée par image (de `2` à `16` ; `0` ou `1` pour aucun, la valeur par défaut), large de `width` en unités du monde ou de l'overlay, en 2D comme en 3D. Le ruban porte la teinte actuelle de la particule, s'estompe jusqu'au transparent vers sa queue, et prend pour texture la colonne du milieu de l'image de la particule, si bien que le bord flou du disque devient celui du ruban. Les valeurs sont bornées à la plage. Changer la longueur reconstruit le stockage des traînées : les particules vivantes recommencent donc leurs rubans. Étincelles, braises, balles traçantes, comètes. *Depuis :* 3.00. *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- Balles traçantes : minuscules, rapides, et rien que des traînées. tracers = emitterNew(gunMuzzle) emitterSetBlend(tracers, PARTICLE_ADD) emitterSetSpeed(tracers, 40, 50) emitterSetSpread(tracers, 2) emitterSetSize(tracers, 0.04, 0.04) emitterSetLife(tracers, 0.8, 1.0) emitterSetTrail(tracers, 8, 0.03) ---- [#emitterstart] ==== emitterStart [source,text] ---- emitterStart(emitter) ---- Lance le flux continu au débit réglé ; dès lors, l'émetteur crée des particules à chaque image jusqu'à `emitterStop`. Démarrer un émetteur qui émet déjà ne change rien. Le flux n'a besoin d'aucun appel de dessin pour tourner ; un émetteur 2D qui émet sans être dessiné consomme quand même sa réserve. *Depuis :* 3.00. *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- Le moteur s'allume quand le joueur met les gaz et s'éteint quand il les coupe. function onThrottle(open) if open then emitterStart(exhaust) else emitterStop(exhaust) end end ---- [#emitterstop] ==== emitterStop [source,text] ---- emitterStop(emitter) ---- Met fin au flux ; les particules vivantes finissent leur vie et toute fraction de particule due est abandonnée. `emitterBurst` fonctionne encore sur un émetteur arrêté, et `emitterIsActive` reste à `true` jusqu'à la mort de la dernière particule. Utilisez `emitterClear` pour supprimer aussi les particules d'un coup. *Depuis :* 3.00. *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- La torche s'éteint : arrêter la flamme, laisser la dernière fumée s'éloigner. function onTorchDoused() emitterStop(flame) emitterStop(smoke) soundPlay(hiss) end ---- [#emitterburst] ==== emitterBurst [source,text] ---- emitterBurst(emitter, count) ---- Crée d'un coup `count` particules selon la recette actuelle, flux ou pas : une explosion, une éclaboussure, une bouffée. Seules sont créées celles pour lesquelles la réserve a de la place ; `emitterSetMax` règle cette place. Un émetteur 2D lâche sa salve à son point `emitterSetPosition`, un émetteur 3D à son nœud, répartie sur `emitterSetRadius`. *Depuis :* 3.00. *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- Feu d'artifice : une bombe éclate quelque part au-dessus du disque toutes les secondes environ. function onOverlayUpdate() if frames % 60 == 0 then emitterSetPosition(fireworks, 120 + (frames * 7) % (width - 240), 80 + (frames * 13) % 160) emitterBurst(fireworks, 260) end overlayClear() emitterDraw(fireworks) frames = frames + 1 return OVERLAY_UPDATED end ---- [#emitterclear] ==== emitterClear [source,text] ---- emitterClear(emitter) ---- Tue sur-le-champ chaque particule vivante et oublie toute fraction de particule due. Le flux n'est pas arrêté : un émetteur démarré continue de créer de nouvelles particules à l'image suivante, alors appelez aussi `emitterStop` pour le vider pour de bon. Servez-vous-en quand une scène change et que les anciennes étincelles ne doivent pas traîner. *Depuis :* 3.00. *Voir aussi :* <>, <> .Exemple [source,lua] ---- -- On recommence le niveau : les émetteurs restent, les anciennes particules s'en vont. function resetLevel() for _, emitter in ipairs(levelEmitters) do emitterStop(emitter) emitterClear(emitter) end placePlayer() end ---- [#emitterdraw] ==== emitterDraw [source,text] ---- emitterDraw(emitter) ---- 2D seulement, depuis `onOverlayUpdate` : met en file d'attente les particules de l'émetteur pour qu'elles soient dessinées à cette image, au-dessus ou au-dessous de l'overlay selon `emitterSetLayer`, en coordonnées de l'overlay projetées sur la fenêtre. Les émetteurs d'une même couche se dessinent dans l'ordre où ils ont été mis en file. La file se vide à chaque image : l'appel a donc sa place dans chaque image où les particules doivent se voir ; ne pas dessiner un émetteur ne l'arrête pas. Jusqu'à `64` émetteurs par couche se dessinent dans une image. Un émetteur 3D se dessine lui-même dans la scène et met fin au script ici. *Depuis :* 3.00. *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- function onOverlayUpdate() overlayClear() emitterSetPosition(smoke, chimneyX, chimneyY) emitterDraw(smoke) emitterDraw(sparks) fontPrint(20, 20, "Score " .. score) return OVERLAY_UPDATED end ---- [#emittergetcount] ==== emitterGetCount [source,text] ---- count = emitterGetCount(emitter) ---- Le nombre de particules de l'émetteur vivantes à cette image, de `0` à la taille de la réserve. Utile pour régler une réserve, pour un budget à l'écran, ou pour savoir quand une salve s'est éteinte. *Renvoie :* le nombre de particules vivantes, un entier. *Depuis :* 3.00. *Voir aussi :* <>, <> .Exemple [source,lua] ---- -- Overlay de développement : quelle part de chaque réserve est utilisée. function onOverlayUpdate() overlayClear() fontPrint(20, 20, string.format("live %d / %d", emitterGetCount(fireworks), fireworksPool)) return OVERLAY_UPDATED end ---- [#emitterisactive] ==== emitterIsActive [source,text] ---- active = emitterIsActive(emitter) ---- Indique si l'émetteur émet en continu ou a encore des particules vivantes : `true` depuis `emitterStart` ou `emitterBurst` jusqu'à ce que le flux soit arrêté et que la dernière particule soit morte, `false` une fois l'explosion finie. Un émetteur en émission continue dont la réserve se trouve vide est toujours actif. *Renvoie :* booléen. *Depuis :* 3.00. *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- Les explosions ponctuelles vivent dans une liste et sont libérées quand elles s'éteignent. function onOverlayUpdate() for i = #explosions, 1, -1 do if not emitterIsActive(explosions[i]) then emitterDelete(explosions[i]) table.remove(explosions, i) end end return OVERLAY_NOT_UPDATED end ---- [#font] === Police Singe a deux moteurs de rendu de texte. La police console intégrée est dessinée par <> et n'a besoin d'aucune ressource ; la famille `font*` charge des fichiers TrueType à une taille en points fixe et les rend soit directement sur l'overlay avec `fontPrint`, soit dans un sprite réutilisable avec `fontToSprite`. Les handles de police sont des entiers renvoyés par `fontLoad`, et le moteur garde une police sélectionnée globale, que `fontLoad` règle sur la police qu'il vient de charger et que `fontSelect` change. Le texte est rendu dans la couleur de premier plan actuelle, à la qualité choisie par `fontQuality`, et les positions du texte sont des coordonnées de l'overlay. Libérez les polices avec `fontUnload` ; `Singe/FreeSansBold.ttf` est fourni avec le moteur et toujours disponible. [#fontload] ==== fontLoad [source,text] ---- result = fontLoad(filename, pointSize) ---- Ouvre une police TrueType à une taille en points et en fait la police sélectionnée. La taille est fixée pour toute la vie du handle : chargez donc le même fichier une fois par taille dont vous avez besoin. Le nom passe par le système de fichiers virtuel comme toute autre ressource ; préfixez-le de `DIR` pour les fichiers livrés avec votre jeu, ou utilisez `Singe/FreeSansBold.ttf` pour la police fournie. Un fichier qui ne peut pas être ouvert met fin au script avec `Unable to open` et le nom. *Paramètres :* * `filename` -- chemin d'un fichier `.ttf`. * `pointSize` -- taille en points, entière. *Renvoie :* handle de police entier. *Depuis :* 1.x *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- Deux tailles de la police fournie : grande pour le titre, petite pour le HUD. fontQuality(FONT_QUALITY_BLENDED) titleFont = fontLoad("Singe/FreeSansBold.ttf", 32) hudFont = fontLoad("Singe/FreeSansBold.ttf", 18) fontSelect(titleFont) titleSprite = fontToSprite("GAME OVER") fontSelect(hudFont) ---- [#fontprint] ==== fontPrint [source,text] ---- fontPrint(x, y, text) ---- Rend une chaîne avec la police sélectionnée, la qualité actuelle et la couleur de premier plan actuelle (plus la couleur de fond pour `FONT_QUALITY_SHADED`), puis la copie sur l'overlay avec son coin supérieur gauche en `(x, y)`. Appelez-la depuis `onOverlayUpdate`, à chaque image s'il le faut : le moteur garde chaque chaîne qu'il rend, indexée par la police, la qualité, les couleurs et le texte, et un appel ultérieur qui demande la même ne fait que la copier. Les chaînes qui ne sont pas affichées pendant environ une seconde sont oubliées, et celles d'une police disparaissent quand elle est déchargée : le texte qui change ne coûte donc pas plus qu'avant, et celui qui ne change pas est presque gratuit. Une page entière de meilleurs scores réaffichée à chaque image ne pose aucun problème. Sans police sélectionnée, une erreur met fin au script. *Paramètres :* * `x`, `y` -- coin supérieur gauche en coordonnées de l'overlay ; les fractions sont tronquées. * `text` -- la chaîne à rendre. *Depuis :* 1.x *Voir aussi :* <>, <>, <>, <> .Exemple [source,lua] ---- -- Affichage en direct du calibrage audio ; le texte change à chaque image, donc on le rend directement. function onOverlayUpdate() overlayClear() colorForeground(255, 255, 255, 255) fontSelect(hudFont) fontPrint(MARGIN_X, MARGIN_Y, "AUDIO DELAY CALIBRATION") fontPrint(MARGIN_X, MARGIN_Y + 30, "Delay: " .. calibrationDelay .. " ms") fontPrint(MARGIN_X, MARGIN_Y + 60, "Left / Right: 10 ms Up / Down: 1 ms") return OVERLAY_UPDATED end ---- [#fontquality] ==== fontQuality [source,text] ---- fontQuality(mode) ---- Choisit comment `fontPrint` et `fontToSprite` rastérisent le texte à partir de maintenant. Le réglage est global, pas propre à chaque police, et commence à `FONT_QUALITY_SOLID`. Les sprites déjà créés par `fontToSprite` gardent la qualité avec laquelle ils ont été rendus. Une valeur autre que les trois constantes met fin au script sur une erreur. *Paramètres :* * `mode` -- `FONT_QUALITY_SOLID` (`1`) : le plus rapide, bords sur un bit, sans anticrénelage. `FONT_QUALITY_SHADED` (`2`) : glyphes anticrénelés sur un rectangle plein de la couleur de fond. `FONT_QUALITY_BLENDED` (`3`) : glyphes anticrénelés avec un alpha par pixel, qui se mélangent par-dessus n'importe quoi ; le choix habituel. *Depuis :* 1.x *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- Texte mélangé sur la vidéo, mais une étiquette sur fond plein pour l'affichage de débogage. fontQuality(FONT_QUALITY_BLENDED) titleSprite = fontToSprite("HIGH SCORES") colorBackground(0, 0, 0, 255) fontQuality(FONT_QUALITY_SHADED) debugLabel = fontToSprite("FRAME") fontQuality(FONT_QUALITY_BLENDED) ---- [#fontselect] ==== fontSelect [source,text] ---- fontSelect(id) ---- Fait d'une police chargée celle qu'utilisent `fontPrint` et `fontToSprite`. La sélection persiste jusqu'au prochain `fontSelect` ou `fontLoad`, puisque charger sélectionne aussi. Elle peut changer aussi souvent que nécessaire, y compris plusieurs fois dans un même `onOverlayUpdate`. Un handle inconnu ou déchargé (ou pas de nombre du tout) laisse la police actuelle sélectionnée et affiche un avertissement, car Hypseus Singe le laisse passer. *Depuis :* 1.x *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- Le titre dans la grande police, le score dans la petite, à chaque image. function onOverlayUpdate() overlayClear() fontSelect(titleFont) fontPrint(TITLE_X, 40, "HIGH SCORES") fontSelect(hudFont) fontPrint(8, 8, "Score: " .. score) return OVERLAY_UPDATED end ---- [#fonttosprite] ==== fontToSprite [source,text] ---- result = fontToSprite(text) ---- Rend une chaîne avec la police, la qualité et les couleurs sélectionnées dans un nouveau sprite et renvoie son handle. Le sprite est dès lors indépendant de la police : il survit donc à `fontUnload`, et il fonctionne avec tous les appels `sprite*` : dessinez-le, mesurez-le, mettez-le à l'échelle ou faites-le tourner, et libérez-le avec `spriteUnload`. Pour une chaîne qui change, rendez un nouveau sprite et déchargez l'ancien. Sans police sélectionnée, une erreur met fin au script. NOTE: Rendre du texte dans un sprite pour les performances est déconseillé. `fontPrint` garde désormais ce qu'il rend, si bien qu'afficher la même chaîne à chaque image coûte à peu près autant que dessiner un sprite fait à partir d'elle, sans sprite à gérer. Utilisez `fontToSprite` quand le texte doit être un sprite : pour le mettre à l'échelle, le faire tourner, le retourner ou le mesurer avec `spriteGetWidth` et `spriteGetHeight`. *Renvoie :* handle de sprite entier. *Depuis :* 1.x *Voir aussi :* <>, <>, <>, <> .Exemple [source,lua] ---- -- Un titre qui tourne et grandit en apparaissant : du texte qui doit être un sprite, pour être tourné et mis à l'échelle. titleSprite = fontToSprite("GAME OVER") titleFrames = 0 function onOverlayUpdate() titleFrames = titleFrames + 1 spriteRotate(titleSprite, (titleFrames * 12) % 360) spriteScale(titleSprite, math.min(titleFrames / 30, 1)) spriteDraw(titleSprite, overlayGetWidth() / 2, overlayGetHeight() / 2, true) return OVERLAY_UPDATED end ---- [#fontunload] ==== fontUnload [source,text] ---- fontUnload(id) ---- Ferme une police et invalide son handle. Les sprites faits à partir d'elle avec `fontToSprite` ne sont pas touchés. Si la police déchargée était la police sélectionnée, aucune police n'est plus sélectionnée ensuite et le prochain `fontPrint` ou `fontToSprite` met fin au script sur une erreur, jusqu'à ce que `fontSelect` ou `fontLoad` en choisisse une autre. *Depuis :* 2.00 *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- Libérer les polices et les sprites de texte qu'elles ont produits. function onShutdown() spriteUnload(titleSprite) for _, line in ipairs(descriptionLines) do spriteUnload(line) end fontUnload(titleFont) fontUnload(hudFont) end ---- [#guiref] === Gui Une GUI est un arbre de documents RmlUi de taille fixe, rendu dans sa propre texture à chaque image : `guiNew` en crée une et renvoie un handle entier, `guiLoad` y place un document RML et renvoie un handle de document, `guiDraw` la compose par-dessus l'overlay et `materialSetGui` l'affiche sur un matériau de la scène. Les documents sont du RML proche du HTML, mis en forme par du RCSS proche du CSS, et chargés par le système de fichiers virtuel comme les sprites ; `Singe/gui.rcss` est le thème fourni et `Singe/FreeSansBold.ttf` la police de repli. Les aides par id `guiGetValue`, `guiSetValue` et `guiSetHandler` atteignent les éléments sans passer par le modèle objet ; tout le reste est l'API Lua de RmlUi sur la globale `rmlui`, où une GUI est le contexte nommé `gui` suivi de son handle. Seize GUI au plus existent à la fois, chacune avec trente-deux documents. Une GUI a besoin du périphérique GPU, si bien que `guiNew` met fin au script sur une machine qui n'en a pas ; un mauvais handle de GUI lève `No GUI N`, et un mauvais handle de document `No document N in GUI G`. Voir <> dans Développement de jeux. [#guiclose] ==== guiClose [source,text] ---- guiClose(gui, document) ---- Décharge le document de la GUI, en abandonnant ses éléments, ses gestionnaires et ses scripts. Le handle du document n'est plus valide ensuite. Un document fermé par son propre script via l'API de RmlUi (`document:Close()`) est déchargé de la même façon. Lève `No document N in GUI G` pour un handle non valide. *Depuis :* 3.00. *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- La page de saisie du meilleur score ne sert qu'une fois ; on la ferme une fois le nom saisi. guiSetHandler(gui, entryPage, "enter", "click", function(g, d) scores[#scores + 1] = { name = guiGetValue(g, d, "name"), score = score } guiClose(g, d) entryPage = nil end) ---- [#guidelete] ==== guiDelete [source,text] ---- guiDelete(gui) ---- Libère la GUI, tous ses documents et sa texture, et met fin à la mise à jour et au rendu qu'elle coûtait à chaque image. Les matériaux qui l'affichaient par `materialSetGui` reviennent à leur propre texture. Le handle n'est plus valide ensuite et met fin au script s'il est réutilisé ; comme une GUI continue d'être rendue, qu'elle soit dessinée ou non, supprimez celles dont un jeu n'a plus besoin plutôt que de les masquer. *Depuis :* 3.00. *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- La page des options ne vit que tant que le joueur s'y trouve. function closeOptions() guiDelete(optionsGui) optionsGui = nil discPlay() end ---- [#guidraw] ==== guiDraw [source,text] ---- guiDraw(gui) guiDraw(gui, x, y) guiDraw(gui, x, y, width, height) ---- Compose la texture de la GUI par-dessus l'overlay pour cette image, en coordonnées de l'overlay, si bien qu'elle respecte la bordure Sinden et l'échelle d'overscan comme tout le reste de l'overlay : sur tout l'overlay avec un seul argument, à sa propre taille avec un coin en `x, y`, ou mise à l'échelle dans le rectangle de `width` sur `height`. La place de l'appel dans le dessin du script décide de ce qui est sous la GUI et de ce qui est dessus : tout ce qui est dessiné sur l'overlay avant lui reste dessous, et tout ce qui est dessiné après lui -- jusqu'au prochain `guiDraw` ou au prochain tour du script -- va sur une couche à part, composée par-dessus cette GUI. Un pointeur dessiné après le HUD est dessiné par-dessus le HUD ; un second document dessiné après cela se place par-dessus le pointeur, avec sa propre couche au-dessus. Seize couches de ce genre au plus par image ; un appel au-delà dessine sa GUI sans rien par-dessus. `overlayClear` efface la couche sur laquelle on est en train de dessiner, et `overlaySetMonochrome` ne passe en gris que l'overlay de base. L'appel dure une image ; faites-le depuis `onOverlayUpdate` (ou `singeMain`) à chaque image où la GUI doit être visible. Les GUI se dessinent au-dessus de l'overlay dans l'ordre des appels (la dernière au-dessus), sous les particules `PARTICLE_OVER` et l'indicateur de pause, et la dernière GUI dessinée sous le pointeur est celle qui reçoit la souris. Seize appels au plus sont pris en compte par image ; les suivants sont ignorés. Tout autre nombre d'arguments lève `Expected 1, 3 or 5 arguments`. *Paramètres :* * `x`, `y` -- le coin supérieur gauche, en coordonnées de l'overlay. * `width`, `height` -- la taille de dessin, en pixels de l'overlay ; la taille propre de la GUI s'ils sont omis. *Depuis :* 3.00. *Voir aussi :* <>, <>, <> .Exemple [source,lua] ---- -- Le HUD occupe le coin inférieur droit sur un tiers de la largeur de l'overlay, et le viseur -- du pistolet est dessiné après lui, si bien que le panneau ne le cache jamais. function onOverlayUpdate() overlayClear() guiSetValue(hud, hudPage, "lives", tostring(lives)) guiDraw(hud, overlayGetWidth() * 0.66, overlayGetHeight() * 0.8, overlayGetWidth() * 0.33, overlayGetHeight() * 0.2) local x, y = mouseGetPosition(0) spriteDraw(sight, x, y, true) return OVERLAY_UPDATED end ---- [#guidrawscreen] ==== guiDrawScreen [source,text] ---- guiDrawScreen(gui) guiDrawScreen(gui, x, y) guiDrawScreen(gui, x, y, width, height) ---- Identique à <>, sauf que le rectangle se mesure sur toute la fenêtre et non sur l'image. Les coordonnées restent des coordonnées de l'overlay -- `0, 0` est le coin supérieur gauche de la fenêtre et `overlayGetWidth(), overlayGetHeight()` le coin inférieur droit -- si bien qu'une GUI peut se placer sur l'illustration du bezel, à côté de l'image, au-dessus ou au-dessous. Sans bezel, et sans `--scalefactor`, `--shiftx` ni `--shifty`, l'image occupe toute la fenêtre et cet appel est exactement `guiDraw`. Les GUI en espace écran se dessinent après tout le reste, illustration du bezel comprise, et partagent avec `guiDraw` les seize dessins par image. Un clic les trouve de la même façon : le pointeur est ramené dans l'espace écran avant d'être testé, si bien qu'une GUI en espace écran qui accepte les entrées reçoit la souris où qu'elle ait été dessinée. *Paramètres :* * `x`, `y` -- le coin supérieur gauche, en coordonnées de l'overlay mesurées sur la fenêtre. * `width`, `height` -- la taille de dessin ; la taille propre de la GUI s'ils sont omis. *Depuis :* 3.00. *Voir aussi :* <>, xref:Manual.adoc#bezels[Bezels], <> .Exemple [source,lua] ---- -- Une carte sur le montant gauche de l'illustration de la borne, hors de l'image. function onOverlayUpdate() overlayClear() guiDrawScreen(mapGui, 8, 44, 38, 114) return OVERLAY_UPDATED end ---- [#guigetheight] ==== guiGetHeight [source,text] ---- height = guiGetHeight(gui) ---- La hauteur de la GUI dans ses propres pixels, telle que donnée à `guiNew`. Met fin au script pour un handle non valide. *Renvoie :* un entier. *Depuis :* 3.00. *Voir aussi :* <>, <> .Exemple [source,lua] ---- -- Centrer une petite boîte de dialogue sur l'overlay. local x = (overlayGetWidth() - guiGetWidth(dialog)) / 2 local y = (overlayGetHeight() - guiGetHeight(dialog)) / 2 guiDraw(dialog, x, y) ---- [#guigetstats] ==== guiGetStats [source,text] ---- stats = guiGetStats() ---- Ce qu'a coûté le rendu de toutes les GUI lors de la dernière image terminée, pour régler les documents qui utilisent des filtres, des masques et des ombres (chacun d'eux dessinant à travers une couche de la taille de la GUI). Les compteurs couvrent toutes les GUI ensemble. Ils valent tous zéro pour une image où aucune GUI n'a changé d'aspect, puisque rien n'a été redessiné (voir <>). *Renvoie :* une table avec les champs `drawCalls` (dessins indexés, y compris les écritures de masque, les compositions et les passes de filtre), `layerPushes` (couches ouvertes pour les filtres, les images de masque et les ombres de boîte), `filterPasses` (passes pleine taille et copies dans les chaînes de filtres ; un flou en coûte plusieurs), `maskWrites` (dessins dans le masque de découpe du stencil), `layerBytes` (octets de textures de couche actuellement alloués ; chacune a la taille de sa GUI, à quatre octets par pixel) et `passes` (passes de rendu commencées ; chaque changement de couche en commence une). *Depuis :* 3.00. *Voir aussi :* <>, <> .Exemple [source,lua] ---- -- Surveiller ce que coûte un écran rempli d'effets. local s = guiGetStats() debugPrint(string.format("gui draws %d layers %d filters %d passes %d, %d KB of layers", s.drawCalls, s.layerPushes, s.filterPasses, s.passes, s.layerBytes // 1024)) ---- [#guigetvalue] ==== guiGetValue [source,text] ---- value = guiGetValue(gui, document, id) ---- La valeur de l'élément qui porte cet `id` dans le document, sous forme de chaîne : la valeur d'un contrôle de formulaire (le texte d'un champ de texte, la `value` d'une option de `select`, un curseur dans le format propre à RmlUi comme `"85.000000"`, l'attribut `value` d'une case à cocher ou d'un bouton radio, `"on"` s'il n'en a pas, qu'il soit coché ou non ; son état est son attribut `checked`, lu par l'API de RmlUi), ou, pour tout autre élément, son RML intérieur. Renvoie `nil` quand aucun élément n'a cet id, si bien qu'on peut sonder une page sans mettre fin au script ; un mauvais handle de GUI ou de document y met fin. Les valeurs de plus de 4095 caractères sont coupées. *Renvoie :* une chaîne, ou `nil`. *Depuis :* 3.00. *Voir aussi :* <>, <> .Exemple [source,lua] ---- -- Lire tout le formulaire quand on appuie sur Start. guiSetHandler(gui, doc, "go", "click", function(g, d) player.name = guiGetValue(g, d, "name") player.hard = guiGetValue(g, d, "difficulty") == "hard" player.volume = math.floor(tonumber(guiGetValue(g, d, "volume"))) end) ---- [#guigetwidth] ==== guiGetWidth [source,text] ---- width = guiGetWidth(gui) ---- La largeur de la GUI dans ses propres pixels, telle que donnée à `guiNew`. Met fin au script pour un handle non valide. *Renvoie :* un entier. *Depuis :* 3.00. *Voir aussi :* <>, <> .Exemple [source,lua] ---- -- Le point sondé est-il dans la moitié droite de la page du moniteur ? local g, px, py = sceneProbeGui(mouseX, mouseY) if g ~= nil and px > guiGetWidth(g) / 2 then showRightTooltip() end ---- [#guihide] ==== guiHide [source,text] ---- guiHide(gui, document) ---- Masque le document : il n'est pas dessiné et ne reçoit aucune entrée, mais il garde ses éléments et ses valeurs, et `guiShow` le fait revenir tel qu'il était. Lève `No document N in GUI G` pour un handle non valide. *Depuis :* 3.00. *Voir aussi :* <>, <> .Exemple [source,lua] ---- -- Deux pages dans une GUI : la page des options se masque pendant que celle des commandes s'affiche. guiSetHandler(gui, options, "controls", "click", function() guiHide(gui, options) guiShow(gui, controls) end) ---- [#guiload] ==== guiLoad [source,text] ---- document = guiLoad(gui, filename) ---- Charge un document RML dans la GUI, l'affiche et renvoie un handle de document entier pour les autres appels. Le nom passe par le système de fichiers virtuel comme celui d'un sprite, résolu par rapport au dossier depuis lequel Singe a été lancé : préfixez-le donc de `DIR` pour les fichiers livrés avec votre jeu ; les feuilles de style, les images et les polices nommées dans le document se résolvent par rapport au fichier qui les nomme, puis par la même recherche, si bien qu'un jeu empaqueté les trouve. Les blocs `