Développement de jeux sur LibGDX à l'aide du modèle Entity Component System

Salut Habr! Je m'appelle Andrey Shilo, je suis développeur Android chez FINCH . Aujourd'hui, je vais vous dire quelles erreurs ne devraient pas être commises lors de l'écriture même du jeu le plus simple et pourquoi l'approche architecturale ECS ( Entity Component System ) est cool.

La première fois est toujours douloureuse


Nous avons un projet amusant pour une grande entreprise médiatique, qui est un réseau social non standard. réseau avec des messages, des commentaires, des likes et des vidéos. Une fois, ils nous ont donné une tâche - introduire la mécanique du jeu comme promotion. Le jeu ressemblait à de simples mini-courses, où avec un coup sur la gauche / droite, la voiture se déplaçait sur la voie gauche / droite. Donc, en esquivant les obstacles et en récupérant des boosters, vous deviez arriver à la ligne d'arrivée, dans l'ensemble, le joueur avait trois vies.

Le jeu était censé être implémenté directement dans l'application, bien sûr, sur un écran séparé. Nous avons choisi inconditionnellement LibGDX comme moteur, car vous pouvez coder le jeu sur kotiln et déboguer sur le bureau, en lançant le jeu en tant qu'application java . Dans le même temps, nous n'avions pas de gens qui connaissaient d'autres moteurs pouvant être implémentés dans l'application (si vous le savez, alors partagez-le).

Le jeu ressemble à ceci:



Étant donné que le jeu selon les savoirs traditionnels d'origine semblait simple, nous ne nous sommes pas plongés dans les approches architecturales. De plus, les promotions elles-mêmes passent rapidement - en moyenne, une action prend un mois et demi. En conséquence, plus tard, le code du jeu sera simplement supprimé et ne sera pas nécessaire avant la prochaine promo, à condition que quelqu'un veuille répéter quelque chose comme ça.

Tous les facteurs décrits ci-dessus et les gestionnaires bien-aimés et pressants nous ont poussés à écrire un mécanisme de jeu sans aucune architecture.

Description du jeu résultant


La plupart du code a été compilé dans les classes: MyGdxGame: Game , GameScreen: Screen et TrafficGame: Actor .

MyGdxGame - est le point d'entrée au début du jeu, ici les paramètres sont transférés au constructeur sous forme de chaînes. GameScreen et les paramètres de jeu sont également créés ici, qui sont transmis à cette classe, mais sous une forme différente.

GameScreen - crée un acteur du jeu TrafficGame, l'ajoute à la scène, lui passe les paramètres déjà mentionnés, et «écoute» les clics de l'utilisateur sur l'écran et appelle les méthodes correspondantes de l'acteur TrafficGame.

TrafficGame - l'acteur principal de la scène dans laquelle se déroule tout le mouvement du jeu: rendu et logique de travail.

Bien que l'utilisation de scene2d permette de construire des arbres d'imbrication d'acteurs, ce n'est pas la meilleure solution architecturale. Cependant, pour implémenter un jeu UI / UX (sur LibGDX), scene2d est une excellente option.

Dans notre cas, TrafficGame a une énorme collection d'instances mixtes et toutes sortes d'indicateurs de comportement qui étaient autorisés dans les méthodes avec de grandes constructions lorsque . Un exemple:

var isGameActive: Boolean = true set(value) { backgroundActor?.isGameActive = value boostersMap.values.filterNotNull().forEach { it.isGameActive = value } obstaclesMap.values.filterNotNull().forEach { it.isGameActive = value } finishActor.isGameActive = value field = value } private var backgroundActoolbarActor private val pauseButtonActor: PauseButtonActor private val finishActor: FinishActor private var isQuizWon = falser: BackgroundActor? = null private var playerCarActor: PlayerCarActor private var toolbarActor: To private var pointsTime = 0f private var totalTimeElapsed = 0 private val timeToFinishTheGame = 50 private var lastQuizBoosterTime = 0.0f private var lastBoosterTime = 0.0f private val boostersMap = hashMapOf<Long?, BoosterActor?>() private var boosterSpawnedCount = 0 private var totalBoostersEatenCount = 0 private val boosterLimit = 20 private var lastBoosterYPos = 0.0f private var toGenerateBooster = false private var lastObstacleTime = 0.0f private var obstaclesMap = hashMapOf<Long?, ObstacleActor?>() 

Naturellement, vous ne devriez pas écrire comme ça. Mais en défense, je dirai que c'est arrivé parce que:

  • Vous devez commencer maintenant, eh bien, et nous montrerons le TK final avec le design plus tard. Classique
  • Les architectures déjà familières (MVP / MVC / MVVM, etc.) ne conviennent pas à la mise en œuvre du processus de jeu, car elles sont conçues uniquement pour l'interface utilisateur, dans le jeu tout se passe en temps réel.
  • Initialement, le jeu semblait simple, mais en fait il nécessitait beaucoup de code, prenant en compte un grand nombre de nuances, dont la majeure partie a fait surface lors de l'écriture du jeu.



En plus de toutes ces difficultés, il existe un autre problème commun avec l'héritage. Si vous rendez un jeu plus difficile, par exemple un jeu de plateforme, alors la question se pose - "Comment répartir le code réutilisable entre les objets du jeu?". L'option la plus couramment choisie est l'héritage, où le code réutilisable est placé dans les classes parentes. Mais cette solution crée de nombreux problèmes si des conditions n'apparaissent pas dans l'arbre d'héritage:



Et ils résolvent généralement ces problèmes en réécrivant la structure de l'arbre d'héritage à partir de zéro (enfin, cette fois, ce sera certainement mieux), ou les classes parentes sont cassées avec des béquilles.



ECS est notre tout


Une toute autre histoire est notre deuxième jeu promo. Elle était comme Flappy Bird , mais avec des différences: le personnage était contrôlé par la voix, et le plafond et le sol n'étaient pas des obstacles - on pouvait glisser dessus.
Un exemple de gameplay et à titre de comparaison, le processus de jeu de Flappy Bird:




Pour plus de clarté, dans l'exemple, la caméra est retirée pour voir les coulisses du jeu. Le sol et le plafond sont des blocs carrés qui, atteignant le bord, sont réarrangés au début, et les obstacles sont générés selon un motif donné qui vient de l'arrière. Le design du jeu a été choisi par les clients, alors ne soyez pas surpris.

J'aime le développement de jeux pour appareils mobiles et pendant les heures creuses, pour des raisons d'expérimentation, j'explorerai les schémas de jeu et tout ce qui concerne le développement de jeux. J'ai lu un livre sur les modèles de conception de jeux , mais je n'ai pas compris quelle devrait être la véritable architecture de la logique de jeu, jusqu'à ce que je tombe sur ECS.

Entity Component System - le modèle de conception le plus souvent utilisé dans le développement de jeux. L'idée principale du modèle est la composition plutôt que l'héritage . La composition vous permet de mélanger différentes mécaniques sur des objets de jeu, ce qui vous permettra à l'avenir de déléguer la définition des propriétés des objets à un concepteur de jeu, par exemple, par le biais d'un constructeur écrit. Comme je connaissais déjà ce modèle, nous avons décidé de l'appliquer dans le deuxième match.

Considérez les composants du modèle:

  • Composant - objets avec une structure de données simple qui ne contient aucune logique ou qui agissent comme un raccourci. Les composants sont divisés par objectif et déterminent toutes les propriétés des objets du jeu. Vitesse, position, texture, corps, etc. etc. tout cela est décrit dans les composants puis ajouté aux objets du jeu.

     class VelocityComponent: Component { val velocity = Vector2() } 
  • Entité - objets de jeu: obstacles / boosters / héros contrôlés et même arrière-plan. Ils n'ont pas de classes spécialisées par type: UltraMegaSuperman: GameUnit, mais sont simplement des conteneurs pour l'ensemble de composants. Le fait qu'une certaine entité soit ainsi UltraMegaSuperman définit son ensemble de composants et leurs paramètres.
    Par exemple, dans notre cas, le personnage principal avait les composants suivants:

    • TextureComponent - définit ce qui doit être dessiné à l'écran
    • TransformComponent - la position de l'objet dans l'espace de jeu
    • VelocityComponent - vitesse de l'objet dans l'espace de jeu
    • HeroControllerComponent - contient des valeurs qui affectent le mouvement du héros
    • ImmortalityTimeComponent - contient le temps restant d'immortalité
    • DynamicComponent - indique que l'objet n'est pas statique et est soumis à la gravité
    • BodyComponent - définit le corps physique du héros 2D nécessaire pour calculer les collisions
  • Système - contient le code de traitement des données des composants de chaque entité. Ils ne doivent pas stocker d'objets Entité et / ou Composant , car cela contredirait le modèle. Idéalement, ils devraient être propres du tout.

    Les systèmes font tout le sale boulot: dessinez tous les objets du jeu, déplacez l'objet par sa vitesse, vérifiez les collisions, changez la vitesse à partir du contrôle entrant, etc. Par exemple, l'effet de la gravité ressemble à ceci:

     override fun processEntity(entity: Entity, deltaTime: Float) { entity.getComponent(VelocityComponent::class.java) .velocity .add(0f, -GRAVITY * deltaTime) } 

    La spécialisation de chaque système détermine les exigences pour les entités qu'il doit traiter. Autrement dit, dans l'exemple ci-dessus, l'entité doit avoir les composants de vitesse VelocityComponent et DynamicComponent pour que cette entité puisse être traitée, sinon cette entité n'est pas intéressante pour le système, et donc pour les autres. Pour dessiner une texture, par exemple, vous devez savoir ce qu'est la texture TextureComponent et où dessiner le TransformComponent. Pour déterminer les exigences de chaque système, la famille est écrite dans le constructeur dans lequel les classes de composants sont indiquées.

     Family.all(TransformComponent::class.java, TextureComponent::class.java).get() 

    De plus, l'ordre des entités de traitement dans le système peut être ajusté par un comparateur. De plus, l'ordre d'exécution des systèmes dans le moteur est également régulé par la valeur de priorité.

Le moteur combine trois composants. Il contient tous les systèmes et toutes les entités du jeu. Au début du jeu, tous les systèmes nécessaires dans le jeu

 engine.apply { addSystem(ControlSystem()) addSystem(GravitySystem()) addSystem(renderingSystem) addSystem(MovementSystem()) addSystem(EnemyGeneratorSystem()) } 
ainsi que des entités de départ sont ajoutées au moteur,
 val hero: Entity = engine.createEntity() engine.addEntity(hero) 

PooledEngine :: createEntity - récupère un objet entité du pool , puisque les entités peuvent être créées pendant le jeu, afin de ne pas salir la mémoire. Si nécessaire, ils sont extraits du pool d'objets et lorsqu'ils sont supprimés, ils sont replacés. De même pour les composants. Lors de la réception de composants du pool, il est nécessaire d'initialiser tous les champs, car ils peuvent contenir des informations sur l'utilisation précédente de ce composant.

La relation entre les principales parties du modèle est présentée ci-dessous:



Le moteur contient une collection de systèmes et une collection d'entités. Chaque système reçoit du moteur un lien vers une collection d'entités, qui est une sélection de la collection générale en fonction des exigences du système; il sera mis à jour pendant le jeu lorsque les entités et les composants changent. Chaque entité contient une collection de ses composants qui la définissent dans le jeu.

Le cycle de jeu est structuré comme suit:

  1. En utilisant l'implémentation du modèle «Game Cycle» de LibGDX, dans la méthode de mise à jour, nous obtenons son incrément à chaque pas de temps - deltaTime.
  2. Ensuite, nous passons le temps au moteur. Et lui, à son tour, itère à travers le système dans un cycle, les distribue deltaTime.
     for (i in 0 until systems.size()) { val system = systems[i] if (system.checkProcessing()) { system.update(deltaTime) } } 
  3. Les systèmes recevant deltaTime trient leurs entités et leur appliquent des modifications en tenant compte de deltaTime.
     for (i in 0 until entities.size()) { processEntity(entities[i], deltaTime) } 

Cela se produit à chaque mesure du jeu.

Avantages sociaux chez ECS


  1. Les données viennent en premier . Étant donné que les systèmes ne traitent que les entités qui leur conviennent, en l'absence de telles entités, le système ne fera tout simplement rien, cela permet de tester et de déboguer de nouvelles fonctionnalités, en créant uniquement les entités nécessaires pour cela.

    Par exemple, vous avez créé le jeu "Tanks". Après un certain temps, vous avez décidé d'ajouter un nouveau type de terrain - «lave». Si le char essaie de le traverser, il échouera. Mais la technologie futuriste vient à la rescousse, en installant que vous pouvez traverser la lave.

    Pour déboguer un tel cas, vous n'avez pas besoin de créer des modèles complets de réservoirs et de construire des cartes complètes avec des emplacements de lave supplémentaires - il suffit de penser aux composants minimum nécessaires sur le réservoir et d'ajouter une entité au moteur de jeu pour tester. Tout cela semble évident, mais en fait, vous rencontrez la classe TheTank qui, dans le constructeur, demande une liste de paramètres: calibre, vitesse, sprite, vitesse de tir, noms d'équipage, etc. bien que cela ne soit pas nécessaire pour tester les intersections de lave.
  2. En outre, en suivant l'exemple du paragraphe précédent, nous notons une grande flexibilité , car avec cette approche, il est beaucoup plus facile d'ajouter et de supprimer des fonctionnalités.

    Un vrai exemple. Le scénario de jeu de notre deuxième promo était que l'utilisateur, après avoir exécuté la chanson pendant environ 2 minutes, s'est écrasé dans la ligne d'arrivée, et le jeu a recommencé le compte à rebours en redémarrant le niveau, mais avec des points de sauvegarde, donnant ainsi au joueur une pause.

    Quelques jours avant la sortie prévue vient la tâche "de supprimer la ligne d'arrivée et de faire un rapport pendant une demi-heure de jeu continu avec des obstacles et des chansons en boucle". Un changement global, mais c'était très facile à faire - il suffisait de supprimer l'essence de la ligne d'arrivée et d'ajouter un système de référence pour la fin de la partie.
  3. Tous les développements sont faciles à tester . Sachant que les données en premier lieu, vous pouvez simuler tous les cas de test, les exécuter et voir le résultat.
  4. À l'avenir, pour valider l'état du jeu , vous pouvez connecter un serveur au processus de jeu. Il exécutera à travers le même code la même entrée client et comparera son résultat avec le résultat sur le client. Si les données ne convergent pas, alors le client est un tricheur ou nous avons des erreurs dans le jeu.


ECS dans le grand monde de gamedev


Les grandes entreprises comme Unity, Epic ou Crytek utilisent ce modèle dans leurs cadres pour fournir aux développeurs un outil avec une tonne de fonctionnalités. Je vous conseille de voir le rapport sur la façon dont la logique de gameplay a été implémentée dans Overwatch

Pour une meilleure compréhension, j'ai fait un petit exemple sur github .
Merci de votre attention!

Également sur le sujet:


Source: https://habr.com/ru/post/fr482374/


All Articles