Les règles¶
Référence des fonctions et constantes qu'un jeu voit. La même API tourne sur la carte : si le simulateur et la carte divergent, c'est un bug du simulateur.
Quatre tables, gfx, pad, sys, phy, et require. Rien d'autre n'est accessible : la VM est sandboxée, il n'y a ni système de fichiers, ni horloge murale, ni chargement arbitraire. Les tables ont chacune leur page : gfx, phy, pad, sys et require ; les limites sont dans Constantes et limites.
Cinq règles¶
1. Des tables, pas des noms plats. gfx.sprite, pas gfx_spr. Le plat n'économisait qu'un GETFIELD sur chaîne internée, ~0,1 % d'une trame à 200 sprites, et un jeu qui écrit local spr = ... ne masque plus rien. Pour une boucle chaude, local sprite = gfx.sprite donne un GETUPVAL et vaut dans les deux schémas.
2. La trame logique est l'appel à _update(), pas l'horloge. Il n'existe aucun dt. Les vitesses sont en pixels par trame, les cadences en trames par image. La console ralentit, elle ne saute jamais.
3. Une partie doit se rejouer au bit près. Le serveur du classement rejoue la trace des entrées avec le même moteur et doit retrouver le même score, sur trois machines différentes. D'où l'arithmétique entière du moteur, l'absence des fonctions transcendantes, et le refus des flottants à la frontière.
4. L'ordre des appels à gfx.sprite() EST l'ordre de peinture, la dernière entrée est devant. Il n'existe aucun autre mécanisme de priorité entre sprites.
5. Le dossier du jeu. Il contient tout son art, en groupes de palette ; gfx.bundle() l'installe d'un coup, une fois. Tout ce qui suit est un choix en jeu : la palette active, le fond, un sprite.
Le squelette¶
gfx.bundle() -- game.pal, game.atlas, game-*.png, game-*.map
local ship = gfx.id('game/ship') -- l'id d'une region : <groupe>/<nom>
local flamme = phy.anim_load('game/flamme', 6)
function _update() -- la logique, une fois par trame
local p = pad.get() -- n'importe quel pad
...
phy.step()
end
function _draw() -- DECRIT la trame : rien n'est dessine ici
gfx.sprite(ship, x, y)
gfx.print(8, 8, 'SCORE ' .. score, 2)
end
_update() puis _draw() sont appelés à chaque trame ; la display list recommence entre les deux, seuls les gfx.sprite de _draw() s'affichent. _draw() ne modifie pas l'état : sous charge la console peut le sauter. Le pas à pas est dans un premier jeu.
Le bac à sable¶
Bibliothèques ouvertes : base, table, string, math, et rien d'autre. io, os, package et debug ne sont jamais ouvertes, plutôt que retirées après coup.
Effacés : dofile, loadfile, load, et le require standard, remplacé par celui décrit dans sys et require.
Retirées de math : sin, cos, tan, asin, acos, atan, exp, log. Elles appellent la libm de la plateforme, et sinf de musl ne rend pas les mêmes bits que celui de la glibc ; il suffit d'un bit pour qu'une partie ne se rejoue plus. Employez sys.sin et sys.cos.
Restent : sqrt, fmod, floor, ceil, abs, min, max, tointeger, type, modf, ult, random, randomseed. Toutes exactes au bit près : math.random est un xoshiro256** entièrement entier depuis Lua 5.4.
math.randomseed() sans argument est refusé
C'est la forme qui rend un jeu inclassable : une graine que personne n'a choisie ne se rejoue pas. Semez explicitement.
Deux trous connus, qu'on ne peut pas fermer sans forker l'interprète : l'opérateur ^ passe par pow() dès que l'exposant n'est pas 2, et tostring() d'un flottant passe par le %.14g de la libc. Un jeu qui branche sur l'un des deux n'est pas garanti rejouable.
Entiers et flottants¶
Lua 5.4 distingue les deux, et la console est compilée en 32 bits : integer est un int32, float un float32, 24 bits de mantisse seulement.
| expression | type | pourquoi |
|---|---|---|
3 |
integer | |
3.0 |
float | le point suffit |
4 / 2 |
float | / rend toujours un flottant |
7 // 2 |
integer | division entière |
2 ^ 3 |
float | ^ aussi |
math.floor(3.7) |
integer | |
math.sqrt(4) |
float |
math.type(x) rend "integer", "float" ou nil. En cas de doute, c'est lui qui tranche.
Au-delà de 16 777 216, un entier sur deux n'existe plus en flottant. Une position de 400,5 px vaut 26 247 168 en Q16.16, bien au-dessus : la faire transiter par un flottant lui coûterait ses bits bas, en silence. C'est pour cela que toutes les fonctions phy.* refusent un flottant, même celui qui tombe juste, avec un message qui nomme le remède.