D'après le Template de Enguerrand Desmet.
🎓 DE SANTIS Léo + DOUBLAL Anass + DUPUIS Maxence.
📚 Librairie / Langage : OpenGL / C++
💻 Développé sur : macOS / Windows
➡️ Sujet du projet
➡️ Guide du jeu (PDF)
Sommaire
Dans ce projet de fin d'année, nous avions comme exercice de réaliser un jeu de Tower Defense en OpenGL/C++.
Nous sommes trois à avoir travaillés sur ce projet.
L'objectif principal était de concevoir un jeu fonctionnel. Cependant, nous avions à cœur de rendre ce projet plus ouvert et accessible, permettant à chacun de personnaliser le jeu et adapter la difficulté.
Nous souhaitons vous offrir la possibilité de jouer facilement vous-même à votre propre version d'Imac Wars Tower Defense. Le jeu a été conçu de manière à ce que, muni d'une carte, d'un fichier d'informations, et de quelques fonctions, vous puissiez créer votre propre carte et adapter les niveaux en jouant sur les paramètres des vagues.
Ainsi, notre projet vise non seulement à fournir une expérience de jeu captivante, mais également à donner aux utilisateurs les outils nécessaires pour personnaliser et enrichir leur propre version du jeu, rendant l'aventure encore plus engageante et personnelle.
Avant de nous lancer dans le développement du code, nous avons d'abord réfléchi à un thème qui se prêterait au mieux au style du Tower Defense. Nous avons choisi d'adapter la scène finale de l'épisode IV de Star Wars "Un Nouvel Espoir". Cette scène emblématique montre des escadrons de l'Alliance Rebelle tentant désespérément de détruire l'Étoile de la Mort.
Dans notre adaptation, le joueur incarne les forces de l'Empire. Sa mission sera de défendre l'Étoile de la Mort contre les vagues de rebelles en plaçant des tourelles de différents types pour empêcher les vaisseaux ennemis d'atteindre le réacteur et de le faire exploser.
Ce projet nous semblait extrêmement ambitieux au départ. Heureusement, la Force était avec nous !
Ce rapport a pour objectif de développer et d'illustrer notre raisonnement dans la conception du Tower Defense. Il ne s'agit pas de décrire dans les moindres détails le code du jeu, mais de comprendre le raisonnement et les principaux éléments implémentés. Si vous souhaitez des détails supplémentaires, n'hésitez pas à regarder le code !
Un guide d'utilisation est à votre disposition pour vous permettre d'accéder aux règles, caractéristiques et commandes du jeu.
2️⃣ Nous expliquerons dans un premier temps l'implémentation des différents éléments essentiels au bon déroulement du jeu.
3️⃣ Ensuite, nous présenterons les diverses améliorations apportées pour rendre le jeu plus immersif et captivant.
4️⃣ Enfin, nous conclurons en portant un regard critique sur le travail accompli et sur les potentielles améliorations à apporter.
Arborescence simplifiée des principaux fichiers et dépendances.
Game
└── TowerDefense
├── Map
| ├── Cell
| └── Graph
├── Base
├── Tower
| └── Bullet
├── Enemy
├── Wave
└── UI
⚠️ Ce rapport a été fait en parallèle de l'implémentation du jeu, il se peut que certains éléments du code n'apparaissent pas dans les captures. N'hésitez pas à vous référez au code si besoin.
Game
représente le fichier principal du jeu. Toutes ses fonctions possèdent un TD
(structure TowerDefense).
Seules les fonctions de Game
sont utilisées dans App
(Application).
Les 4 premières fonctions sont celles qui permettent de faire fonctionner l'affichage des éléments du jeu.
1. LOAD : Charge toutes les textures une seule fois dans un tableau de texture appelé au tout début de l'application. Charge aussi tous les sons.
2. SETUP : Appels successifs et ordonnés de méthodes permettant de récupérer et de traiter l'information pour :
- Afficher la carte.
- Stocker différentes données telles que les tours, ennemis et vagues.
- Afficher l'interface utilisateur.
Elle prend le chemin vers le fichier de la map en pixel, ainsi que la taille de la map en pixel (width ou height).
3. UPDATE : Permet de gérer les déplacements d'ennemis, l'enchainement des différentes vagues et de mettre à jour l'état du jeu. C'est là que le temps devient important !
- elapsedTime : essentiel pour rendre les déplacements fluides et uniformes.
- currentTime : c'est le temps qui s'écoule en secondes au lancement du jeu.
4. RENDER : C'est la fonction qui permet l'affichage des éléments de notre jeu !
Les 3 fonctions restantes permettent de détecter l'intéraction avec l'utilisateur (Clavier, mouvement, clic de la souris ou du pad).
⚠️ Pour les transformations et affichages d'objets : OpenGL utilise des coordonnées normalisées. C'est à dire que notre fenêtre en pixel, doit être transformée de sorte à ce que ses coordonnées se trouvent sur la plage [-1,1].
Ci-dessous, une partie du code permettant de passer des coordonnées de pixels en coordonnées normalisées, puis en coordonnées de carte.
Nous ne rentrerons pas dans le détail des fonctions d'interactions. Retenez simplement l'utilitée des 3 fonctions :
- active_KEY_CALLBACK(...) : Déclenche des évênements grâce aux touches du clavier.
- active_MOUSE_CLICK_CALLBACK(...) : Déclenche des évênements lorsque le joueur clique sur sa souris (ou pad).
- active_MOUSE_POSITION_CALLBACK(...) : Permet de garder en mémoire la position de la souris à chaque instant.
Pour référencer toutes les données importantes du jeu, nous utilisons des fichiers txt avec l'extension .itd
pour (Imac Tower Defense).
Un fichier nous était imposé pour représenter les différents éléments de notre carte (expliqué plus loin dans le rapport). Ce fichier est lu et analysé par notre application. Ainsi, les fonctions implémentées sont capables de lire et retranscrire à l'écran une importante quantité de données.
Il faut cependant garder en tête que l'utilisateur peut effectuer des erreurs de saisies et donc rendre la lecture impossible ou incorrecte.
Des contrôles ont été effectuées pour permettre la bonne lecture du fichier de map.
Voilà les contrôles du sujet qui étaient nécessaires d'effectuer :
- Toutes les lignes nécessaires sont présentes et dans le bon ordre.
- Triplet RGB valide pour les couleurs (compris entre 0 et 255).
- Fichier image existant.
- Les coordonnées des nœuds sont valides (dans l'image).
- Existence d'au moins une zone d'entrée et de sortie (cette vérification pourra se faire implicitement lors de la recherche du chemin des ennemis).
- Existence d'au moins un chemin entre la zone d'entrée et de sortie (cette vérification pourra se faire implicitement lors de la recherche du chemin des ennemis).
- Les vérifications 1, 2, 3 ont été effectuées dans le fichier
ITD.cpp
. - Les vérifications 4, 5, 6 ont été effectuées dans le fichier
Map.cpp
.
Nous avons réutilisé cette logique sur d'autres éléments de notre jeu tels que : les ennemis, vagues, tours, images animées. La volonté est de permettre à l'utilisateur de pouvoir entrer ses données en gérant simplement les fichiers itd ; notre application se charge dynamiquement de récupérer, traiter et afficher l'information. Voici deux exemples de fichiers itd implémentés.
ITD
#NOMBRE D'ENNEMIS
Enemies 4
#ENEMIES (name, id, health, speed, damage)
type X-Wing 0 40 10 0.5
type Y-Wing 1 70 12 0.75
type A-Wing 2 50 17 1
type Falcon 3 100 20 2
ITD
#NOMBRE DE VAGUES
Waves 2
#VAGUES (niveau, nb d'entrée, nb d'ennemi,
temps btw spawn (s), types ennemis)
level 0 2 5 3 0 1
level 1 4 10 2 0 1 2 3
Ces fichiers représentent les différents types d'ennemis créés et le nombre de vagues que contient le jeu. On remarque que les vagues prennent une clé secondaire liée à la clé primaire des ennemis, permettant ainsi de faire le lien.
Les fichiers itd optionnels implémentés n'ont cependant pas été "sécurisés" dans la saisie de données.
Schema : 15x15 px | Carte : 240x240 px |
---|---|
➡️ Zones :
⚪ = Chemin (path)
🔴 = Entrée (in)
🔵 = Sortie (out)
🟣 = Interdit (forbidden)
⚫ = Libre (void)
⚠️ La carte finale est bien découpée en tiles. Nous avons fait le choix d'un point de vu esthétique, de rendre transparente les 🟣 & ⚫ pour pouvoir afficher une map totalement designé en fonction de ces zones, et donc d'avoir un environnement plus détaillé et varié.
La logique de la carte est implémentée dans une structure Map
.
Cette structure est d'une grande importance dans la suite du développement de notre jeu. En effet, le découpage des tiles sur la carte représente une bonne base pour l'implémentation des déplacements des ennemis et le positionnement des tours. En d'autres termes, une structure Map
solide et maintenable est indispensable.
Notre carte se base sur une image de référence appelée par la suite schema
. Cette image est composée de pixels de couleurs différentes, représentant chacun une information déterminante pour la suite.
Par exemple : si vous voulez construire une Map
de 15x15 cases, alors le schema
à analyser sera de 15x15 (véritables) pixels.
Ce schema
est lu dans un fichier ITD (Image Tower Defense) du type : "mon_schema_15x15.itd". Ce fichier contient :
- Le chemin menant à l'image du
schema
. - Les couleurs présentes sur l'image.
- Tous les nœuds des chemins (node) avec leur indice, position et connexions.
L'objectif est maintenant de déterminer comment analyser ce fichier pour obtenir une véritable carte !
Une cellule ou case de la carte possède un "squelette" déterminé par Pixel
.
On détermine une structure Pixel
qui possède :
- Une position (x, y).
- Une couleur (structure qui prend le triplet de couleur R, G, B).
- Un ou plusieurs types ou états (booléens).
- Des connexions (pointeurs sur les 4 voisins du pixel).
Après avoir déterminé nos structures de base, on va analyser et attribuer à chaque pixel sur le schema
(présent dans l'ITD) une structure Pixel
. Pour ce faire, on va récupérer les données concernant les couleurs et les nodes de l'ITD, pour déterminer le type du pixel !
Toute cette analyse se fait dans le Game::SETUP(...)
et plus précisemment dans le setup_MAP(...)
qui prend en paramètre le nom du fichier ITD et le nombre de pixels sur la largeur ou hauteur (peu importe car notre map est carré).
Nous ne rentrerons pas dans le détail de ces fonctions, retenez juste que toutes les données sont stockées dans les vector
ci-dessous grâce aux appels ordonnés et successifs des méthodes.
struct Map {
std::vector<Tile> TILES;
std::vector<Pixel> PIXELS;
std::vector<Node> NODES;
Graph::WeightedGraph GRAPH;
std::vector<std::vector<Node>> SHORTER_PATH_LIST;
...
}
A partir de là, nous possédons toutes les Tile
de notre carte.
Le prochain objectif est de déterminer comment afficher correctement les tiles pour obtenir la carte.
Si on essaye de dessiner un quad
en (0,0), on obtient une tile dessinée au centre de notre fenêtre. Or, dans la logique de notre carte, on veut que la case (0,0) soit celle tout en bas à gauche.
Ainsi, comment passer d'un repère à un autre ?
Comment convertir les 4 coins de notre quad
?
Méthode & illustration :
Une fois les formules de conversions obtenues, on peut utiliser glVertex
et dessiner la tile en bouclant sur le tableau TILES
contenu par notre structure Map
.
On obtient finalement notre carte !
Il existe dans notre jeu, 4 types d'ennemis différents :
Un ennemi est caractérisé par une structure : Enemy
Celui-ci a pour objectif d'infliger des dégâts à la base impériale défendue par le joueur. Les ennemis ont des caractéristiques différentes en fonction de leur type (vitesse, dégâts, points de vie, récompense).
Chaque ennemi parcourt le plus court chemin entre son point de spawn et la base à attaquer. Le spawn est déterminé aléatoirement.
Nous allons nous intéresser à l'implémentation du déplacement de l'ennemi.
L'idée, c'est de définir une current position
qui représente le point de départ de l'ennemi (au début). Ce point de départ représente le premier noeud du chemin. On définit également une target position
qui sera la position ciblée par l'ennemi. Au départ, la position cible est le noeud qui suit le current
.
On définit également un step
selon x
et y
d'une valeur de +1 ou -1. Ce step
détermine si on se déplace vers la droite (step_x
= 1) ou vers la gauche (step_x
= -1), mais aussi si on se déplace vers le bas (step_y
= -1) ou vers le haut (step_y
= 1). Ces valeurs permettent également de déclencher la texture
correspondant à l'orientation de l'ennemi.
On doit garder à l'esprit que la fonction ci-dessus est appelée en boucle dans App
. Ainsi, nous n'avons pas besoin d'utiliser de boucle, les conditions suffisent car le rappel successif de notre fonction nous donne un effet de récursion. Si on arrive à mettre à jour nos positions current
et target
, c'est gagné.
En effet, quand la position relative de l'ennemi n'est pas égale à la target
, on incrémente la position en fonction du step
, de sa vitesse
et du temps
.
⚠️ this->TIME
=elapsedTime
, mesure le temps écoulé entre chaque frame ➡️ permet la fluidité de l'animation et fixe l'indépendance du framerate.
Si l'ennemi atteint la target
, alors il est au noeud suivant, current
devient target
et on incrémente l'id
de la target
pour aller chercher le prochain noeud cible.
Ainsi, notre ennemi arrive à se déplacer correctement !
L'ennemi inflige des dégats à la Base
s'il arrive à l'atteindre. Il suffit simplement de comparer si les positions respectives de l'ennemi et de la base coincident. On décrémente alors la vie de la base en fonction des dégats causés par l'ennemi et on fait disparaitre ce dernier.
Une vague possède un certain nombre d'ennemis. Plus les vagues s'enchaînent et plus la difficulté doit augmenter. Ainsi, le nombre d'ennemi augmente, le temps écoulé entre chaque spawn ennemi diminue. Ces paramètres sont évidemment ajustables dans le fichier ITD
.
On utilise une structure : Wave
C'est une petite structure. Elle prend :
- un nombre de spawn
- un nombre d'ennemi
- un temps de spawn entre chaque ennemi
- un tableau contenant le type des ennemis contenu dans la vague
Comme vous pouvez le constater, ce n'est pas dans la structure Wave
que nous récupérons les Enemy
dans un tableau. Ce processus est effectué plus haut, dans la structure TowerDefense
.
C'est ici que sont stockés les différentes vagues et les différents ennemis dépendants eux-mêmes d'une vague.
setup_WAVE()
permet de récupérer lacurrent Wave
grâce aucurrent_WAVE_id
initialisé à 0. Cetid
est envoyé dans un tableauWAVES_checked
permettant de stocker les vagues qui ont été chargées.get_ENEMIES_into_WAVE()
permet de récupérer les ennemis de la vague en court grâce au tableaunumber_of_ENEMIES
. On récupère aléatoirement l'id des ennemis. L'id permet de récupérer la structure de l'ennemi correspondante grâce auENEMIES_ITD
qui détient toutes les propriétés de chaque type d'ennemi. On insert ensuite dans lecurrent_ENEMIES_in_WAVE
la structure de l'ennemi.setup_ENEMIES_in_WAVE()
permet d'initialiser toutes les caractéristiques propres aux ennemis comme leur texture, vitesse, chemin suivi (déterminé aléatoirement).
update_ENEMIES_in_WAVE()
permet comme son nom l'indique de mettre à jour l'état des ennemis dans la vague (spawn, inflammabilité), cette partie du code n'est pas développé dans le rapport. On se concentre ici sur le passage d'une vague à la suivante. Les 3 fonctions développées précédemment sont utilisées dans cette fonction de mise à jour. On vérifie si l'id
de la Wave
est présent dans le WAVES_Checked
, si ce n'est pas le cas, on appelle les fonctions 1,2,3. Si current_ENEMIES_in_WAVE
est vide, alors on donne la possibilité au joueur de passer à la vague suivante en appuyant sur Entrée.
Il y a 3 types de tours que le joueur peut placer dans les endroits alloués :
- Tour de base : pas chère, peu puissante mais avec une bonne cadence.
- Tour de ralentissement : moyennement chère, ralentit les ennemis.
- Tour destructrice : chère, puissante mais avec une cadence inférieure.
Dans la structure Tower
, on retrouve une autre structure membre Bullet
. En effet, chaque tour a une balle qui reçoit les propriétés de la tour pour adopter un certain comportement.
Mais à quel moment tirer les balles ?
On boucle une fois sur les ennemis (grâce au booléen lockedEnemy
) pour calculer la distance entre la tour et chaque ennemi (en Distance de Chebyshev). La variable closest_enemy_dist
stockera la plus petite distance et closest_enemy
l'ennemi associé (donc le plus proche) sous la forme d'un pointeur.
if(!lockedEnemy)
{
lockedEnemy = true;
for (auto &enemy : TD->current_ENEMIES_in_WAVE)
{
if (enemy.second.isTarget)
{
// Distance de Chebyshev
float dist_to_enemy = std::max(std::abs(pos.x - enemy.second.pos.x), std::abs(pos.y - enemy.second.pos.y));
if (dist_to_enemy < this->portee)
{
if(dist_to_enemy < closest_enemy_dist)
{
closest_enemy_dist = dist_to_enemy;
closest_enemy = &enemy.second;
}
}
}
}
}
Plus bas, on update la balle en lui donnant en paramètre la valeur déréférencée de closest_enemy
, alias l'ennemi le plus proche.
if(closest_enemy_dist < this->portee)
{
if(closest_enemy->isMoving && closest_enemy != nullptr)
{
this->bullet.update(*closest_enemy, elapsedTime, currentTime, this);
this->bullet.isBeingShot = true;
}
else
this->bullet.isBeingShot = false;
}
À la fin des n secondes relatives à la cadence de tir, on réinitialise toutes les variables à l'état initial pour pouvoir tirer une nouvelle balle sur l'ennemi le plus proche recalculé.
if (cadence < 0)
{
this->bullet.pos = this->pos;
cadence = 3;
this->bullet.fixedDirection = false;
this->bullet.hitEnemy = false;
this->bullet.isBeingShot = false;
lockedEnemy = false;
closest_enemy_dist = 100;
}
Le comportement des balles est principalement régi par les vec2 direction
et pos
.
Expliquons leur utilisation :
direction
: calcule les coordonnées du vecteur entre la tour mère et l'ennemi le plus proche. Pour cela, on fait simplement {x2-x1, y2-y1}. On a aussi un booléenfixedDirection
qui régule le fait de ne calculer ladirection
que toutes les n secondes relatives à la cadence de tir de la tour.
if (!fixedDirection)
{
direction = {enemy.pos.x - pos.x, enemy.pos.y - pos.y};
fixedDirection = true;
}
pos
: une fois ladirection
calculée, on update la position de la balle en lui ajoutant les coordonnées du vec2direction
et en lui multipliant une constante qui représente la vitesse.
this->pos.x += direction.x * elapsedTime * 4;
this->pos.y += direction.y * elapsedTime * 4;
Par ailleurs, quand la balle touche un ennemi, sa valeur pos
devient quelque part hors écran, en attendant d'être réinitialisée dans la position de la tour et tirée à nouveau.
pos = {1000, 1000};
PS : parce qu'on avait besoin d'inclure les structures Tower
et Bullet
les unes dans les autres, nous avons utilisé la méthode de la déclaration anticipée pour pallier les problèmes de link.
struct Tower;
struct Bullet
{
// GLuint texture;
SpriteSheet sprite;
glm::vec2 pos;
glm::vec2 direction;
bool fixedDirection{false};
bool isBeingShot{false};
bool hitEnemy{false};
void setup(std::unordered_map<std::string, SpriteSheet> &SPRITE_SHEETS_ITD, Tower*);
void update(Enemy&, const double &elapsedTime, const double ¤tTime, Tower*);
void render(Map &);
};
Tower.hpp
est ensuite inclus dans Bullet.cpp
(implicitement derrière TowerDefense.hpp
).
#include "Bullet.hpp"
#include "TowerDefense.hpp"
Le joueur a la possibilité d'intéragir avec l'application, grâce notamment aux 3 fonctions de callback mentionnées au début du rapport. Il a aussi de nombreuses informations à sa disposition lui permettant de comprendre le fonctionnement et déroulement du jeu.
Elements visibles à l'écran :
- Argent disponible.
- Vague en court.
- Point de vie de la base.
- Curseurs intuitifs.
Exemples d'intéractions :
- Possibilité de lancer le jeu, de mettre en pause et de quitter.
- Accès lors de la mise en pause aux caractéristiques des ennemis et tours.
- Possibilité de sélectionner une tour spécifique en fonction de l'argent disponible et de placer celle-ci sur la carte si la case est valide.
- Cliquer sur une tour ou un ennemi pour voir les caractéristiques.
Afin d'avoir la meilleure expérience utilisateur, il est nécessaire de manipuler l'ambiance sonore du jeu. Pour cela, voici comment on a procédé :
# ---miniaudio---
FetchContent_Declare(
miniaudio
GIT_REPOSITORY https://github.com/mackron/miniaudio
)
FetchContent_MakeAvailable(miniaudio)
add_library(miniaudio INTERFACE)
target_include_directories(miniaudio SYSTEM INTERFACE ${miniaudio_SOURCE_DIR})
target_link_libraries(${PROJECT_NAME} PRIVATE miniaudio)
- Pour gérer l'intégration de la bibliothèque Mini-Audio dans notre projet, nous avons décidé de passer par CMake en téléchargeant la librairie depuis le dépôt GitHub et en configurant les directives d'inclusion et de liaison nécessaires.
#pragma once
#include "miniaudio.h"
#include <vector>
class SoundEngine {
public:
static SoundEngine& GetInstance();
static ma_engine& GetEngine();
SoundEngine(SoundEngine const& other) = delete;
SoundEngine& operator=(const SoundEngine &) = delete;
private:
SoundEngine();
~SoundEngine();
ma_engine _sound_engine;
};
- Utilisation d'un fichier SoundEngine.hpp qui définit la classe
SoudEngine
sous forme de singleton. Grâce à l'aide de notre professeur, nous avons pu utiliser ce singleton comme un outil. En effet, un singleton est un design pattern qui garantit qu'une classe n'aura qu'une seule instance à tout moment, offrant ainsi un point d'accès global à cette instance. Cela a été particulièrement utile pour les composants du moteur sonore qui ne nécessitent qu'une seule instance partagée dans tout le programme.
#define MINIAUDIO_IMPLEMENTATION
#include "SoundEngine.hpp"
#include <iostream>
SoundEngine::SoundEngine() {
ma_result const result { ma_engine_init(NULL, &_sound_engine) };
if (result != MA_SUCCESS) {
std::cerr << "Unable to init sound engine" << std::endl;
}
}
SoundEngine::~SoundEngine() {
ma_engine_uninit(&_sound_engine);
}
SoundEngine& SoundEngine::GetInstance() {
static SoundEngine instance;
return instance;
}
ma_engine& SoundEngine::GetEngine() {
return GetInstance()._sound_engine;
}
- Utilisation d'un fichier SoundEngine.cpp qui implémente les méthodes de la classe. C'est lui qui permet l'instance du moteur sonore
ma_engine
de Mini-Audio lors de l'initialisation. De plus, il définit le rôle du destructeur du singleton qui se charge de désinitialiser correctement le moteur sonore.
ma_sound mainThemeSound;
// Chargement et stockage des sons dans un vecteur
std::vector<std::pair<std::string, ma_sound *>> sounds = {
{"../../sound/Main_Theme.mp3", &mainThemeSound}
};
// Initialisation du volume sonore
ma_engine_set_volume(&SoundEngine::GetEngine(), 0.1f);
// Démarrage de la lecture du son
ma_sound_start(&mainThemeSound);
- Voici un exemple concret d'utilisation présent dans le fichier Game.cpp. Ici, on remarque que les sons sont lancés grâce aux méthodes déjà présentes dans la librairie, en manipulant particulièrement :
ma_sound
qui permet de gérer directement les sons sans toucher le moteur sonore qui s'initialise directement à l'appel du fichier.
Cette partie a pour objectif de mentionner les améliorations effectuées par rapport aux règles imposées par le sujet du projet.
✅ Zones constructibles pour les tours.
✅ Placement intelligent des sprites de chemin.
✅ Sprites animées.
✅ Différents types d'ennemis avec des caractéristiques différentes.
✅ Différents types de tours avec des caractéristiques différentes.
✅ Visualisation des tirs des tours sur les ennemis.
✅ Créer une zone de sortie ayant des points de vie, encaissant les dégâts des ennemis avant de perdre la partie.
➕ Créations de toutes les textures du jeu.
➕ Créations d'ITD supplémentaires (enemy, wave, tower, sprite_sheets).
➕ Utilisations de musiques et effets sonores pour renforcer l'ambiance.
Nous sommes tout d'abord fiers de ce projet.
Ce jeu a été réalisé avec passion et nous avons tous les trois énormément appris. Nous ne pensions pas aller aussi loin dans l'implémentation, mais nous avions réellement envie de développer davantage ce projet. Nous tenons à remercier notre professeur de programmation Enguerrand Desmet, qui a été là quand nous avions besoin d'aide, notamment pour l'utilisation de la librairie miniaudio, ainsi que pour les problèmes d'affichage liés à la librairie de texte (le jeu ayant principalement été développé sur macOS avec un écran Retina).
Évidemment, beaucoup de choses peuvent encore être améliorées. On peut notamment noter l'absence de vérifications sur les ITD des vagues, des ennemis, des tours et des spritesheets, bien que ces derniers soient une amélioration de notre part. Certaines fonctions mériteraient d'être optimisées, ou certains choix, comme le fait de ne pas intégrer directement un tableau d'ennemis dans la structure Wave
, pourraient être repensés.
Les principales difficultés rencontrées ont été liées à l'organisation des différents fichiers. Comment s'organiser pour que tout soit bien interconnecté et que la structure du projet reste cohérente ? Gérer l'affichage n'a pas été simple, car entre macOS et Windows, ce dernier était différent (lié à la densité de pixels).
Notre objectif a été de produire le code le plus propre possible dans le temps qui nous était imparti, mais surtout de créer un jeu agréable visuellement auquel on prend plaisir à jouer !
Tout n'a pas été expliqué dans ce rapport car cela aurait pris une éternité. N'hésitez pas à explorer par vous-même ! ;)
Que la force soit avec vous !