phy : le moteur de jeu¶
Un acteur est une boîte qui se déplace et bute. Il ne sait pas se dessiner : c'est le jeu qui appelle gfx.sprite, l'ordre de peinture lui appartenant.
Le moteur est le même code sur la carte et dans le simulateur, et il donne les mêmes positions au bit près. C'est ce qui rend une trace rejouable (voir les règles).
Les identifiants sont 1-based. Un slot libéré laisse un trou, et l'allocation reprend le plus petit index libre : le résultat d'un pas ne dépend donc pas de l'ordre dans lequel le jeu a créé ses acteurs.
Toutes ces fonctions refusent les flottants. phy.PX vaut 65536 : écrivez 3 * phy.PX pour trois pixels, et // là où vous auriez écrit / (voir entiers et flottants).
Le motif de collision¶
phy.map(nom | nil) → w, h¶
Charge le motif de collision du niveau — sa géométrie : ce sur quoi on marche, ce contre quoi on bute. Rend ses dimensions en pixels. phy.map(nil) retire toute géométrie.
Deux formes, et la première est celle qu'on écrit. nom.mask est un dessin ASCII, un caractère par case de 8 × 8 px — 1:1 avec les tuiles du fond : soixante lignes de quatre-vingts signes pour un écran, un signe par case (table ci-dessous). ~4,9 Kio, qui s'éditent dans l'atelier de l'App comme un script, se relisent dans un diff — et que la compilation d'un niveau dans l'atelier génère depuis le calque de collision de sa carte (prérempli par les tuiles taguées « solide ») et les masques de ses décors posés.
Les dimensions se lisent dans le texte — le nombre de lignes, la plus longue ligne — avec l'écran pour plancher : un monde plus grand que l'écran (jusqu'à 256 × 128 tuiles) écrit simplement un fichier plus grand. nom.msk est le format binaire, un bit par pixel, produit par un outil — pour ce qui se génère, et le jour où un motif demandera la précision du pixel. Le .mask est essayé d'abord.
Une case a des faces, et une face ne bloque que le bord qui la franchit (core v4). La face du haut arrête des pieds qui descendent dans la case, et c'est ce sur quoi on se tient ; la face du bas arrête une tête qui monte ; la face gauche arrête un bord droit qui va vers la droite ; la face droite arrête un bord gauche qui va vers la gauche. Un acteur déjà à cheval sur une case n'en est jamais repoussé. C'est ce qui donne une plateforme qu'on traverse en sautant par le bas, sans aucun cas particulier.
| Signe | Valeur | Case |
|---|---|---|
. |
0 | de l'air (tout ce que le lecteur ne connaît pas en est aussi) |
# |
15 | solide : les quatre faces |
= |
1 | plateforme : la face du haut seule — on s'y pose, on la traverse par le bas |
H |
16 | échelle : de l'air avec des barreaux (phy.LADDER). La case du sommet reçoit une face du haut au chargement : c'est un sol ; jamais écrite dans le fichier |
a..o |
1..15 | des faces quelconques, ` + valeur : e = haut + gauche, un mur à sens unique |
Les lignes manquantes sont vides, les caractères manquants aussi. Le format binaire .msk est solide (15) partout où un bit est à 1. Le dehors est solide — un acteur ne sort pas du monde. Changer de motif vide le monde. Un acteur posé par phy.pos à l'intérieur d'une case ne se tient pas dessus : il s'enfonce jusqu'à la frontière de case suivante et s'y pose. L'atelier pose les pieds d'un objet sur une frontière.
phy.solid(x, y) → booléen¶
Quelque chose bloque à ce pixel : la case a une face au moins (une échelle seule est de l'air, pas son sommet). C'est ce qui permet à un jeu de dessiner son niveau depuis sa géométrie au lieu d'en tenir une seconde copie en Lua. Le jeu de plateforme de Votre premier jeu construit sa liste de blocs comme ça, une fois au chargement. Hors carte, elle rend ce que le motif dit du dehors. Sans phy.map(), c'est une erreur.
phy.cell(x, y) → drapeaux¶
La case sous ce pixel, en drapeaux : phy.FACE_TOP (1), phy.FACE_BOTTOM (2), phy.FACE_LEFT (4), phy.FACE_RIGHT (8), phy.LADDER (16) ; un solide vaut 15, le sommet d'une échelle phy.LADDER | phy.FACE_TOP. Hors carte : 15 si le dehors est solide, 0 sinon. phy.cell(x, y) & phy.FACE_TOP ~= 0 dit « un sol ici », & phy.LADDER ~= 0 « un barreau ici ». Ce ne sont pas phy.LEFT…phy.DOWN, qui sont les côtés de phy.touch.
Le monde et les acteurs¶
phy.gravity(gx, gy)¶
Le vecteur de gravité du monde, en pixels par trame au carré. Seuls les acteurs portant phy.GRAVITY le subissent.
phy.new(w, h) → id / phy.free(id)¶
Crée un acteur dont la boîte de collision fait w × h pixels ; sans drapeau, il ne percute rien et ne tombe pas. La boîte ne dérive pas du bitmap : une frame qui change de parité ferait vibrer l'acteur contre le sol. Le lien entre la boîte et le dessin est l'ancre. phy.free libère le slot, rien n'est déplacé.
phy.pos(id [, x, y]) → x, y / phy.vel(id [, vx, vy]) → vx, vy¶
Lisent, ou posent puis rendent. En Q16.16 : phy.pos(a, 320 * phy.PX, 0).
phy.vel écrit les deux composantes
Un axe piloté par la physique doit être relu avant d'être reposé, sinon la gravité est annulée à chaque trame :
phy.px(id) → x, y¶
La position de la boîte en pixels entiers, au plancher.
phy.box(id [, w, h]) → w, h / phy.anchor(id [, ax, ay]) → ax, ay¶
L'ancre est le décalage entre le coin de la boîte et le coin du bitmap. phy.anchor(a, W // 2, H) fait que la position de l'acteur désigne le milieu du bas du sprite — son pied.
phy.flags(id [, drapeaux]) → drapeaux¶
phy.MASK : bute contre le motif de collision. phy.GRAVITY : subit la gravité du monde. phy.DROP : en descendant, ignore les faces du haut des cases qui n'ont pas de face du bas (les plateformes, le sommet d'une échelle), jamais un solide : posez-le pour descendre une échelle ou traverser une plateforme, retirez-le une fois passé. Un drapeau ne crée rien : il dit à quoi l'acteur est sensible.
phy.layer(id [, couche, masque]) → couche, masque¶
couche est un numéro de 0 à 31 ; masque est l'ensemble des couches que cet acteur percute — 1 << autre_couche. La détection est un OU : deux acteurs se signalent si l'un des deux le demande.
Une trame de monde¶
phy.step() → franchissements, saturés¶
Une trame de monde, appelée explicitement depuis _update(). Dans l'ordre : gravité, écrêtage de vitesse, déplacement en x contre le motif puis en y, sonde de sol, détection des recouvrements. Rend le nombre de franchissements de pixels consommés — à comparer aux 4 096 du budget — et le nombre d'acteurs déplacés sans collision parce que ce budget était épuisé. Le déplacement visite chaque pixel franchi : un tir à 100 px/trame ne traverse pas un mur.
phy.contacts() → n, perdus / phy.contact(k) → a, b¶
Les recouvrements détectés au dernier phy.step(), à parcourir de 1 à n. a < b toujours, et l'ordre est stable d'une exécution à l'autre. Les contacts se drainent, ils ne rappellent pas. Le test porte sur la boîte balayée — l'union du départ et de l'arrivée — sinon une balle rapide passerait au travers d'un ennemi lent.
phy.ground(id) → trames, au_sol / phy.touch(id) → masque¶
phy.ground rend le nombre de trames depuis le dernier contact avec le sol et le verdict du coyote time : vrai jusqu'à quatre trames après avoir quitté une corniche. phy.touch rend ce que l'acteur a heurté pendant cette trame : phy.LEFT, phy.RIGHT, phy.UP, phy.DOWN.
Les animations¶
phy.anim_load(prefixe [, cadence, boucle]) → id, n¶
Charge prefixe0, prefixe1, … dans l'atlas du bundle ('game/flamme' → game/flamme0, game/flamme1…) jusqu'à ce qu'il n'y en ait plus — et si rien ne vient, réessaie avec un soulignement : 'game/marche' trouve game/marche_0, game/marche_1…, la forme que la compilation d'un niveau produit ('niveau2/torche_4' : un état de modèle, y compris à une seule frame, niveau2/torche_4_0). Le séparateur n'est jamais à écrire. Rend l'identifiant de l'animation et son nombre d'images. cadence est en trames par image (6 par défaut) ; boucle vaut true par défaut, à false l'animation se fige sur sa dernière image. La console tient 64 animations à la fois : une 65e est refusée tant qu'aucune n'est libérée.
phy.anim_free(anim)¶
Libère une animation : sa case sert au prochain phy.anim_load. Les 64 se comptent par niveau chargé, pas par jeu : un jeu à plusieurs niveaux libère les animations de l'un avant de charger l'autre, ce que fait M.decharger() d'un <niveau>.lua généré. Un acteur qui portait l'animation libérée la perd (phy.anim(a) rend 0 et phy.sprite(a) refuse) plutôt que de prendre en silence les images de ce qui reprend la case. Libérer deux fois est une erreur. gfx.bundle() les libère toutes, puisqu'il renumérote les images.
phy.anim(id [, anim [, phase]]) → anim / phy.anim_done(id) → booléen / phy.sprite(id) → sprite, x, y¶
Changer d'animation la reprend au début. phy.anim_done n'est vrai que pour une animation à coup unique terminée. phy.sprite rend les trois valeurs qui alimentent exactement gfx.sprite :
Chaque acteur a son propre compteur, remis à zéro à sa création — mais des acteurs créés à la même trame partent tous de zéro au même instant, et six torches partageant une animation battent ensemble. phase, en trames, est l'endroit où l'animation commence pour cet acteur : phy.anim(t, flamme, math.random(0, 6 * 8 - 1)) étale six torches sur un cycle entier de huit images à six trames chacune. Une phase seule, avec la même animation, la décale sans la reprendre au début.
Le niveau que l'App génère¶
Compiler un niveau dans l'App écrit <niveau>.lua dans le projet, avec papyrun.lua, le runtime des comportements : les dimensions, le fond, la gravité, les modèles placés avec leurs animations, les objets avec leurs propriétés, les comportements compilés, et six fonctions — charger() (fond, phy.map, gravité, animations chargées une fois), creer(modele, x, y) (un acteur : boîte, ancre, position, état par défaut), instancier(fabriques) (les objets visibles, par modèle, leurs comportements démarrés), update() (tous les comportements, puis phy.step() et les contacts), dessiner() (tous les objets, caméra appliquée) et decharger() (les animations du niveau libérées, avant le niveau suivant). Le jeu écrit local niveau = require("<niveau>"), appelle niveau.update() et niveau.dessiner() à chaque trame, et lit la table partagée pot ; voir Travailler dans l'App.