tutorial-03: map conversion (first half)

This commit is contained in:
Lephenixnoir 2020-12-22 17:04:05 +01:00
parent 80167969fe
commit 0a9a3a9fae
Signed by: Lephenixnoir
GPG Key ID: 1BBA026E13FC0495
3 changed files with 311 additions and 0 deletions

View File

@ -0,0 +1,311 @@
[center][big][brown][b]03 : Des portes... [i]beaucoup[/i] de portes.[/b][/brown][/big][/center]
Dans ce tutoriel, on va passer le jeu au niveau supérieur. Jusqu'ici, on a codé un menu principal et un petit moteur de déplacement avec des animations. Pour l'instant, on a laissé la map de côté et on a hardcodé les positions des murs pour éviter que le joueur ne sorte de la map. Tout cela change aujourd'hui, parce qu'on va ajouter une map animée et les premières mécaniques de jeu ! Plus précisément :
• On va voir comment convertir des objets personnalisés (ici une map) avec [brown][b]fxconv[/b][/brown] ;
• Et on va étendre le moteur de jeu pour intégrer des changements sur la map.
Le final ressemblera à ça !
TODO
Il y a un peu moins de gint ici, et un peu plus de code du jeu. Si vous programmez votre propre jeu en lisant ce tutoriel, faites bien attention à la structure du code. Comme j'en ai parlé dans le tutoriel précédent, il est [i]très important[/i] de séparer le rendu, la simulation du jeu et la gestion des entrées avant que le programme ne devienne monstrueux et impossible à maintenir (... parce que c'est ce qui arrivera si vous ne faites pas attention ^^).
[brown][b]Format de la map en tant qu'asset[/b][/brown]
Pour l'instant, c'est parti ! La map du premier niveau ressemblera à ça. Ça n'inclut pas toutes les mécaniques, mais les grandes idées y sont. On a la zone de départ en bas à gauche, la zone d'arrivée en bas à droite, et le but c'est de passer de l'un à l'autre en utilisant l'ouverture et la fermeture automatique des portes, dont il existe deux types : horizontaux et verticaux.
[center][img]https://gitea.planet-casio.com/Lephenixnoir/mystnb/raw/branch/master/tutorials/gint-tuto-03-map1.png[/img]
[i]Image 1 : Le titre a pas menti, y'a [b]beaucoup[/b] de portes.[/i][/center]
Je pourrais tenter de générer la map directement à partir de l'image, mais ça serait assez compliqué. Pour ce projet tout simple qui n'a que 8 maps toutes de la même taille, je vais simplement fournir une image « squelette » du niveau (juste les murs en gros) et le programme affichera les points de départ et d'arrivée, les portes, et autres items par-dessus. Le rôle et la position des objets seront décrits dans un fichier texte qu'on va convertir avec fxconv parce que ce serait quand même dommage d'avoir à décrire une map en C.
Un vrai projet super ambitieux éditerait certainement les maps dans un outil spécialisé comme [url=https://www.mapeditor.org/]Tiled[/url], auquel cas le fichier texte serait remplacé par le fichier de sauvegarde de Tiled. Le principe serait le même, je vais simplement me contenter du fichier texte par simplicité pour ce tutoriel. Il ressemblera à ça :
[code]##### #####
# ##### #
# a a #
##A### ###A##
# a a #
# ~ ##### @ #
##### #####
a: #.
A: #.[/code]
Les `#` représentent des murs, les lettres minuscules représentent des portes verticales et les lettres minuscules des portes horizontales. `~` et `@` sont le point de départ et le point d'arrivée, respectivement.
À la fin, il y a des informations qui décrivent le cycle d'ouverture/fermeture de chaque porte (ici les portes sont synchronisées donc j'ai utilisé qu'une seule lettre). Je ne voudrais pas vous gâcher les mécaniques donc pour l'instant je n'en dis pas plus. :p
[brown][b]Format de la map en tant qu'objet du programme[/b][/brown]
Pour obtenir ces informations dans le jeu, on va commencer par ajouter dans le code (plus précisément dans `engine.h`) la structure qu'on veut obtenir à la fin. La conversion de la map devra prendre en entrée le fichier texte ci-dessus et produire en sortie structure de map, donc c'est important de bien définir le format tout de suite. J'avais donné un début dans le tutoriel précédent, qu'on peut maintenant compléter avec les détails en plus.
[code]/* struct map: A map with moving doors, collectibles, and fog */
struct map
{
/* Width and height */
int w, h;
/* Whether fog is enabled */
int fog;
/* Door cycle string */
char door_cycle[128];
/* Mapping of door types to cycle string index */
uint8_t door_cycle_index[16];
/* Background image */
bopti_image_t *img;
/* Array of tiles in row-major order */
uint8_t *tiles;
};[/code]
Je stocke les cycles à la suite les uns des autres dans une seule chaîne `door_cycle`, et je référence chaque portion dans le tableau `door_cycle_index` (il y a un exemple plus bas). Le plus important ici reste la map, et pour la map on a besoin de donner une valeur à chaque case :
[code]/* enum map_tile: Single cell in the map */
enum map_tile
{
TILE_AIR = 0,
TILE_WALL = 1,
TILE_START = 2,
TILE_END = 3,
/* 8 keys in interval 16..23 */
TILE_KEY = 16,
/* 8 vertical doors in interval 24..31 */
TILE_VDOOR = 24,
/* 8 horizontal doors in interval 32..39 */
TILE_HDOOR = 32,
};[/code]
Je numérote l'air, les murs, les points de départ et d'arrivée de 0 à 3. Les clés et les portes sont pas toutes identiques donc je me donne 8 numéros de clés et 8 numéros de chaque direction de porte pour avoir de la variété.
Moralement `tiles` dans la `struct map` c'est un tableau de `enum map_tile`. Mais comme les énumérations sont des `int` par défaut ça voudrait dire que chaque case de la map occupe 4 octets en mémoire, et jeux avoir un type qui occupe juste 1 octet. Donc j'ai mis `uint8_t` à la place ; ça revient au même vu que tout ça sont des entiers. ;)
Donc voilà ce qu'on doit produire : une structure comprenant 156 octets de données plus un pointeur de 4 octets pointan vers l'image de fond, et un autre pointeur de 4 octets pointant vers `w*h` octets de données. Voyons voir comment faire ça avec fxconv. ;)
[brown][b]Conversion de la map avec fxconv[/b][/brown]
On ne dirait pas de loin, mais fxconv n'est pas juste un outil en ligne de commande. En fait, il est programmable. On peut ajouter dans chaque projet des conversions personnalisées programmées en Python. ;)
D'abord, on va enregistrer la map dans un autre sous-dossier de `assets-fx` pour ne pas trop mélanger nos assets. Prenons par exemple `assets-fx/map/lv1.txt`. Pour les métadonnées, on va utiliser `custom-type` au lieu de `type` pour indiquer à fxconv qu'on utilise une conversion personnalisée. Comme j'ai mis un sous-dossier, je crée bien un nouveau `fxconv-metadata.txt` dans `assets-fx/map`.
[code]*.txt:
custom-type: map
name_regex: (.*)\.txt map_\1[/code]
Le type est donc un type personnalisé "`map`" ; la regex est la même que dans le tutoriel précédent, mais avec l'extension `.txt` pour les noms de fichiers et le préfixe `map_` pour les noms de variables.
Maintenant, on peut coder le convertisseur. Je vais le mettre dans `assets-fx/converters.py` ; vous pouvez le mettre où vous voulez. Voilà à quoi doit ressembler le fichier :
[code]import fxconv
def convert(input, output, params, target):
if params["custom-type"] == "map":
convert_map(input, output, params, target)
return 0
else:
return 1
def convert_map(input, output, params, target):
# Generate the data here...
data = b"<Placeholder>"
fxconv.elf(data, output, "_" + params["name"], **target)[/code]
Il y a plusieurs choses à noter.
• D'abord il y a un module `fxconv` qui fournit quelques fonctions utilitaires et l'indispensable fonction `elf` dont je reparle dans un instant. Votre boulot se réduit (essentiellement) à lire le fichier d'entrée et à produire un objets `bytes()` avec les octets de la variable finale, ici la `struct map`.
• La fonction `convert` est appelée avec 4 paramètres : `input` et `output` sont les noms du fichier d'entrée et du fichier de sortie, respectivement. `params` est un dictionnaire avec les paramètres indiqués dans `fxconv-metadata.txt` (plus quelques paramètres toujours présents), et `target` indique la calculatrice cible (Graph mono ou Graph 90+E), le compilateur à utiliser, et d'autres détails. `params["name"]` est toujours défini même si vous avez mis `name_regex` (le nom est calculé plus tôt).
• `params["custom-type"]` est le type personnalisé spécifié dans `fxconv-metadata.txt`. J'aime bien faire un if/else ici et avoir une sous-fonction par type de données pour ne pas tout mélanger. Si vous reconnaissez le type, vous devez renvoyer 0 ; sinon, vous devez renvoyer 1 auquel cas fxconv tentera le convertisseur personnalisé suivant (et si tous renvoient 1, il indiquera que le type est inconnu).
• À la fin de la génération, la fonction `fxconv.elf()` est utilisée pour générer le fichier de sortie. Sauf si vous faites des trucs très extravagants, les trois derniers paramètres ne changeront jamais, et vous n'aurez besoin de `output` et `target` que là. Avec ces simplifications en tête, finalement le procédé de conversion prend en entrée `input` (fichier d'entrée) et `params` (infos de `fxconv-metadata.txt)` et doit générer `data`. ;)
Ici vous pouvez voir que les octets que je génère sont juste ceux du texte `"<Placeholder>"` encodé en ASCII. Ça n'a rien à voir avec une `struct map`, c'était juste pour garder le code court pendant l'explication. Maintenant au passe à la conversion à proprement parler ! ^^
[code]def convert_map(input, output, params, target):
TILE_AIR = 0
TILE_WALL = 1
TILE_START = 2
TILE_END = 3
TILE_KEY = 16
TILE_VDOOR = 24
TILE_HDOOR = 32
# Read input file
with open(input, "r") as fp:
tiles, cycles = fp.read().split("\n\n")
tiles = tiles.split("\n")
cycle_texts = [c for c in cycles.split("\n") if c]
w = max(len(t) for t in tiles)
h = len(tiles)
fog = 0
filename = os.path.splitext(os.path.basename(input))[0]
# Generate map contents
encoded_tiles = bytearray(w * h)
for (y, t) in enumerate(tiles):
for (x, c) in enumerate(t):
if c == " ":
tile = TILE_AIR
elif c == "#":
tile = TILE_WALL
elif c == "~":
tile = TILE_START
elif c == "@":
tile = TILE_END
elif ord("0") <= ord(c) <= ord("9"):
tile = TILE_KEY + int(c)
elif ord("a") <= ord(c) <= ord("z"):
tile = TILE_VDOOR + (ord(c) - ord("a"))
elif ord("A") <= ord(c) <= ord("Z"):
tile = TILE_HDOOR + (ord(c) - ord("A"))
else:
raise fxconv.FxconvError(f"unknown tile character {c}")
encoded_tiles[y*w + x] = tile
# Parse door cycles
RE_CYCLE = re.compile(r'^([a-zA-Z]):\s*([#.]+)$')
cycles = dict()
for c in cycle_texts:
m = re.match(RE_CYCLE, c)
if not m:
raise fxconv.FxconvError(f"cannot parse door cycle '{c}'")
cycles[m[1]] = m[2].encode("utf-8")
# Generate door cycle data
door_cycle = b""
door_cycle_index = bytearray(16)
for index, letter in enumerate("abcdefghABCDEFGH"):
door_cycle_index[index] = len(door_cycle)
if letter in cycles:
door_cycle += cycles[letter] + b" "
door_cycle = door_cycle + bytes(128 - len(door_cycle))
# Generate the structure
o = fxconv.ObjectData()
o += fxconv.u32(w) + fxconv.u32(h) + fxconv.u32(fog)
o += door_cycle + door_cycle_index
o += fxconv.ref(f"img_{filename}")
o += fxconv.ref(encoded_tiles)
fxconv.elf(o, output, "_" + params["name"], **target)[/code]
C'est un peu long, mais il y a de tout dans cette conversion donc c'est une bonne référence. On va donc prendre le temps de tout bien détailler. :)
On commence par lire le fichier source `input`. Ici c'est un fichier texte, je le lis directement avec `open()`. Quand c'est une image, vous pouvez utiliser `PIL` pour la charger directement et avoir un joli objet idiomatique Python pour faire toutes les opérations dont vous avez besoin. Si vous voulez des exemples de ça, il en y en a plusieurs dans [url=https://gitea.planet-casio.com/Lephenixnoir/fxsdk/src/branch/master/fxconv/fxconv.py]les sources de fxconv[/url] (le plus simple étant `convert_libimg_fx()`).
La suite est spécifique à mon format de map. Un ligne blanche ("`\n\n"`) sépare les contenus de la map (`tiles`) des infos sur les cycles de portes (`cycle_texts`), et je sépare encore les deux en lignes. Je devine la taille de la map à partir des tiles qui sont saisies, et pour l'instant je fixe `fog` à 0. Je récupère dans le nom de fichier (`"lv1.txt"`) la partie sans extension ("`lv1"`) comme ça je pourrai automatiquement mettre un pointeur vers l'image du même nom (qui sera "`img_lv1`").
Enfin arrive la conversion de la map à proprement parler. Vous pouvez voir que je commence avec un tableau de `w*h` octets, qui sont tous 0 (`TILE_AIR`) initialement. Ensuite je remplis en lisant les caractères de chaque ligne. Je note les clés 0...9, les portes verticales a...z et les portes horizontales A...Z.
Si je tombe sur quelque chose d'imprévu, je lève une exception de type `fxconv.FxconvError`, qui s'affiche à l'écran et arrête la compilation. Je vous conseille de vraiment blinder vos conversions contre les imprévus. Si une erreur se glisse mais n'est pas détectée c'est comme si votre programme contient une erreur mais le compilateur ne s'en rend pas compte : le résultat n'aura pas de sens et il sera très difficile de réaliser que le problème vient de là.
L'étape suivante consiste à analyser le texte des cycles pour obtenir une représentation structurée. Si vous ne connaissez pas les regex ce n'est pas grave, vous pouvez admettre qu'à la fin `cycles` est un dictionnaire du type `{"a": b"#.", "A": b"#."}`. Tout ou presque est en `bytes` au lieu de chaînes de caractères parce que tout doit être des `bytes` lors de la génération du fichier converti. ^^
La troisième étape consiste à encoder ces informations en une paire de variables `door_cycle` et `door_cycle_index` de la structure. En gros `door_cycle` c'est la concaténation de tous les cycles ensemble avec un espace entre chaque (ici `b"#. #. "`), et `door_cycle_index` indique où commence chaque cycle (ici celui de `"a"` commence à la position 0 et celui de `"A"` commence à la position 3). Ce format est pratique parce que toutes les informations tiennent dans deux variables de taille fixe.
Remarquez que dans la structure, `door_cycle` fait 128 octets et `door_cycle_index` fait 16 octets donc je suis obligé de respecter ces tailles. Dans le premier cas, j'ai une ligne qui rajoute des 0 à `door_cycle` pour amener la taille à 128. Dans le second cas, je crée le tableau directement avec 16 octets dans le constructeur `bytearray(16)` et ensuite je ne touche plus à la taille.
Ensuite, on génère la map. Ce code-là est super important donc je vous le remets ici. ;)
[code]o = fxconv.ObjectData()
o += fxconv.u32(w) + fxconv.u32(h) + fxconv.u32(fog)
o += door_cycle + door_cycle_index
o += fxconv.ref(f"img_{filename}")
o += fxconv.ref(encoded_tiles)
fxconv.elf(o, output, "_" + params["name"], **target)[/code]
La génération du fichier final c'est la toute dernière ligne qui appelle `fxconv.elf()`. Les trois derniers paramètres sont toujours les mêmes, le seul qui importe vraiment c'est le premier, qui représente les données à mettre dans le fichier (c'est-à-dire les octets de la `struct map` qu'on est en train de convertir).
Si tous les octets de la structure sont connus au moment de la conversion, vous pouvez passer un objet `bytes` à `fxconv.elf()`, comme ceci :
[code]# N'importe quoi de type bytes
data = b"..." + bytes([0x00, 0x10, 0x42, ...]) + ...
# On génère exactement ces octets-là
fxconv.elf(data, output, "_" + params["name"], **target)[/code]
Le problème c'est que notre structure contient des pointeurs :
• Il y a un pointeur `img` vers une variable externe.
• Il y a un pointeur `tiles` vers un tableau qu'on veut générer en même temps que la structure.
Et ça on ne peut pas connaître leur valeur durant la conversion (l'explication complète dans la prochaine section). Il y a un mécanisme pour délayer leur calcul, mais c'est un peu compliqué et donc fxconv fournit une classe `fxconv.ObjectData()` qui fait tout ça automatiquement. Pour créer une structure qui contient des pointeurs, vous commencez par créer un objet vide type `ObjectData()`, et ensuite vous pouvez lui ajouter successivement des composants.
[b]Octets fixes.[/b] Si vous ajoutez un objet de type `bytes` ou `bytearray`, les octets sont ajoutés directement au résultat de la conversion. Par exemple cette ligne crée les trois premiers `int` de la `struct map`.
[code]o += fxconv.u32(w) + fxconv.u32(h) + fxconv.u32(fog)[/code]
Il y a trois fonctions `fxconv.u8`, `fxconv.u16` et `fxconv.u32` pour créer des entiers non-signés de 1, 2 et 4 octets (ce qui correspond aux types `char`, `short` et `int`, ou si vous préférez les types de `<stdint.h>`, `uint8_t`, `uint16_t` et `uint32_t`). Si vous n'êtes pas familier·e avec cets types entiers et leurs tailles, vous pouvez voir le [url=https://www.planet-casio.com/Fr/forums/topic16574-1-tdm-18-comprendre-les-donnees-et-leurs-representations.html]TDM 18[/url] qui aborde précisément ce sujet.
[b]Pointeurs vers des variables externes.[/b] Si vous ajoutez `ref("variable")`, un pointeur vers la variable est ajouté au résultat de la conversion. Un pointeur fait toujours 4 octets. Par exemple cette ligne ajoute un pointeurs vers l'image de fond (ici c'est `"img_lv1"`).
[code]o += fxconv.ref(f"img_{filename}")[/code]
La commande exacte est `ref(<variable> [,offset=<offset>])`, ça ajoute un pointeur vers `(void*)&variable + offset`. L'offset est en octets (et pas en taille d'objets comme en C !).
[b]Pointeurs vers des données annexes.[/b] Si vous ajoutez `ref(octets)` où `octets` est soit un `bytes` soit un `bytearray`, alors les octets sont ajoutés en annexe du résultat et un pointeur vers le résultat est ajouté à la structure. Par exemple, cette ligne ajoute les octets de `encoded_tiles` en annexe de la `struct map` et un pointeur vers cette annexe à la structure.
[code]o += fxconv.ref(encoded_tiles)[/code]
La commande complète est `ref(<bytes_like> [,padding=<padding>])`. Si vous spécifiez `padding` alors `ref()` rajoutera quelques octets nuls à la fin pour s'assurer que la taille du résultat est un multiple de `padding`. C'est utile pour préserver l'alignement !
Et ensuite, on termine avec `fxconv.elf()` qui déplie tout, génère les octets et les références pour les pointeurs, et produit le fichier de sortie avec la structure nouvellement convertie. :D
[brown][b]Parenthèse technique : pourquoi gérer les pointeurs différemment ?[/b][/brown]
On vient de voir que les pointeurs doivent être gérés spécialement, et qu'on ne peut pas connaître leur valeur du tout pendant la conversion.
La raison de tout ça c'est que les pointeurs ce sont des adresses dans la mémoire (voir [url=https://www.planet-casio.com/Fr/forums/topic16588-1-tdm-19-apprehender-la-memoire-pour-eclairer-le-bas-niveau.html#adresses]cette section du TDM 19[/url] pour les détails), et donc pour connaître leur valeur il faut savoir où seront chargées les données pointées (l'image de fond et le tableau qui accompagne la `struct map`) dans la mémoire.
Sauf que cette information n'existe pas du tout à ce stade ! Choisir des adresses de chargement est le rôle de l'éditeur de liens, qui n'intervient que plus tard dans le processus (qui est détaillé dans le [url=https://www.planet-casio.com/Fr/forums/topic15930-1-tdm-n016-grands-principes-de-compilation.html]TDM 16[/url]). Il est impossible de connaître les adresses durant la conversion et c'est parfaitement normal.
En fait, le même problème se pose lorsque vous écrivez du code C avec des variables globales de ce genre :
[code]char x = 2;
char *px = &x;
char *px8 = &x + 8;[/code]
Lorsque ce fichier est compilé, le compilateur génère les octets qui serviront à initialiser ces trois variables. Pour `x` c'est facile, il y a un seul octet qui est 02. Mais pour `px` et `px8` le compilateur ne peut pas déterminer la valeur initiale, puisqu'elle dépend de `&x` et que c'est l'éditeur de liens qui fixe `&x` bien plus tard.
Pour résoudre ce problème, le compilateur utilise un mécanisme de [i]références[/i] conçu spécifiquement pour ça. Le compilateur génère les octets 00 00 00 00 pour `px` et 00 00 00 08 pour `px8`, et note quelque part dans le fichier `.o` (le résultat de la compilation) que ces deux valeurs sont [i]relatives à `&x`[/i]. L'éditeur de liens, lorsqu'il choisit l'adresse dans la RAM où `x` sera chargé, ajoute `&x` aux deux valeurs, qui deviennent alors correctes.
La capacité de l'éditeur de liens de modifier à la volée des valeurs [i]relatives à des adresses de variables[/i] est très puissante et permet sur un ordinateur de compiler efficacement des programmes immenses, d'avoir des bibliothèques dynamiques, et même de charger du code à l'exécution (par exemple des mods pour votre jeu préféré). ^^
Je pense que vous voyez où je veux en venir. Durant notre conversion de map, on ne spécifie pas nous-mêmes les valeurs des pointeurs `img` et `tiles`, on se contente de dire que c'est des valeurs relatives à des adresses de variables, et l'éditeur de liens se charge de déterminer la valeur pour nous bien plus tard juste avant de produire le fichier g1a/g3a. :)
J'ai brièvement mentionné que le compilateur ajoute à son fichier de sortie (un fichier objet `.o`) une sorte d'« annotation » indiquant quelles valeurs sont relatives et relatives à qui. fxconv produit aussi des fichiers `.o` (durant l'appel à `fxconv.elf` ; ELF est le nom du format des fichiers `.o`), et bénéficie donc de ce mécanismes.
Par contre, créer les annotations c'est laborieux surtout à la main. Du coup fxconv génère un morceau de code assembleur et l'assemble immédiatement, parce que l'assembleur `as` sait très bien produire ce genre d'annotations. Donc en fait ce que `ObjectData()` vous cachez c'est qu'il génère du texte assembleur pour référencer les symboles, et ensuite `fxconv.elf()` invoque l'assembleur pour produire les annotations. ^^
[brown][b]Utilisation de la map dans le programme[/b][/brown]
On peut tout de suite intégrer les nouvelles informations dans le moteur du programme. Au début de la fonction `engine_draw()`, je rajoute un `dimage()` pour afficher l'image de fond du niveau :
[code]void engine_draw(struct game const *game)
{
dclear(C_WHITE);
dimage(0, 0, game->map->img);
/* ... */
}[/code]
Dans `map_walkable()`, on peut retirer le test qui hardcodait les bords de l'écran comme murs et lire les données qu'on vient de générer dans la map.
[code]/* Check whether a cell of the map is walkable */
static int map_walkable(struct map const *map, int x, int y)
{
int tile = map->tiles[y * map->w + x];
return (tile != TILE_WALL);
}[/code]
Pour l'instant j'ignore les portes, on verra ça après !
Et enfin, on peut arrêter d'utiliser un map factice dans `main()` et véritablement charger la map du niveau 1.
[code]extern struct map map_lv1;
struct game game = {
.map = &map_lv1,
.players = { &singleplayer, NULL },
.time = 0,
};[/code]
Et voilà, on peut maintenant se déplacer sur la map et les collisions sont fonctionnelles ! :D
[center][img]https://gitea.planet-casio.com/Lephenixnoir/mystnb/raw/branch/master/tutorials/gint-tuto-03-colliding-map.gif[/img]
[i]Image 2 : Toujours pas de gameplay mais pour ma défense on s'en approche.[/i][/center]

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB