(Disclaimer : je n'ai jamais programmé de jeux en Java, seulement en C++. Mais l'idée générale devrait être applicable en Java également. Les idées que je présente ne sont pas les miennes, mais un mélange de solutions que j'ai trouvées dans des livres ou "sur internet", voir la section des références. J'emploie tout cela moi-même et jusqu'à présent il en résulte un design propre où je sais exactement où placer les nouvelles fonctionnalités que j'ajoute).
Je crains que ce ne soit une longue réponse, elle pourrait ne pas être claire lors de la première lecture, car je ne peux pas le décrire de haut en bas très bien, donc il y aura des références en avant et en arrière, c'est dû à mon manque de compétence d'explication, pas parce que la conception est défectueuse. Rétrospectivement, je suis allé trop loin et je suis peut-être même hors sujet. Mais maintenant que j'ai écrit tout cela, je ne peux pas me résoudre à le jeter. Il suffit de demander si quelque chose n'est pas clair.
Avant de commencer à concevoir l'un des paquets et des classes, commencez par une analyse. Quelles sont les fonctionnalités que vous voulez avoir dans le jeu. Ne prévoyez pas un "peut-être que j'ajouterai ceci plus tard", car il est presque certain que les décisions de conception que vous prendrez d'emblée avant de commencer à ajouter cette fonctionnalité pour de bon, le stub que vous avez prévu pour elle sera insuffisant.
Et pour la motivation, je parle d'expérience ici, ne pensez pas que votre tâche consiste à écrire un moteur de jeu, écrivez un jeu ! Quoi que vous pensiez de ce qui serait cool d'avoir pour un projet futur, rejetez-le à moins de le mettre dans le jeu que vous écrivez en ce moment. Pas de code mort non testé, pas de problèmes de motivation dus au fait de ne pas pouvoir résoudre un problème qui n'est même pas un problème pour le projet immédiat à venir. Il n'y a pas de conception parfaite, mais il y en a une suffisamment bonne. Cela vaut la peine de garder cela à l'esprit.
Comme dit plus haut, je ne crois pas que MVC soit d'une quelconque utilité dans la conception d'un jeu. La séparation modèle/vue n'est pas un problème, et le contrôleur est assez compliqué, trop pour être simplement appelé "contrôleur". Si vous voulez avoir des sous-paquets nommés modèle, vue, contrôle, allez-y. Les éléments suivants peuvent être intégrés dans ce schéma d'emballage, bien que d'autres soient au moins aussi judicieux.
Il est difficile de trouver un point de départ dans ma solution, alors je commence simplement par le haut :
Dans le programme principal, je crée simplement l'objet Application, je l'init et je le démarre. L'objet de l'application init()
va créer les serveurs de fonctionnalités (voir ci-dessous) et les initialiser. Le premier état de jeu est également créé et poussé au sommet (voir aussi ci-dessous).
Les serveurs de fonctionnalités encapsulent les fonctionnalités orthogonales du jeu. Ils peuvent être mis en œuvre indépendamment et sont faiblement couplés par des messages. Exemples de fonctionnalités : Son, représentation visuelle, détection des collisions, intelligence artificielle/décision, physique, etc. La façon dont les fonctionnalités elles-mêmes sont organisées est décrite ci-dessous.
Entrée, flux de contrôle et boucle de jeu
Les états de jeu constituent un moyen d'organiser le contrôle des entrées. J'ai généralement une seule classe qui recueille les événements d'entrée ou qui capture l'état d'entrée et l'interroge plus tard (InputServer/InputManager). Si l'on utilise l'approche basée sur les événements, les événements sont donnés à l'état de jeu actif enregistré.
Au démarrage du jeu, ce sera l'état de jeu du menu principal. Un état de jeu a init/destroy
y resume/suspend
fonction. Init()
initialisera l'état du jeu, dans le cas du menu principal, il affichera le niveau le plus élevé du menu. Resume()
donnera le contrôle à cet état, il prend maintenant l'entrée du InputServer. Suspend()
effacera la vue du menu de l'écran et destroy()
libérera toutes les ressources dont le menu principal a besoin.
Les états de jeu peuvent être empilés. Lorsqu'un utilisateur lance le jeu en utilisant l'option "nouveau jeu", l'état de jeu MainMenu est suspendu et l'état de jeu PlayerControlGameState est placé sur la pile et reçoit désormais les événements d'entrée. De cette façon, vous pouvez gérer les entrées en fonction de l'état de votre jeu. Avec un seul contrôleur actif à un moment donné, vous simplifiez énormément le flux de contrôle.
La collecte des données est déclenchée par la boucle de jeu. La boucle de jeu détermine essentiellement le temps de trame de la boucle en cours, met à jour les serveurs de fonctionnalités, collecte les données et met à jour l'état du jeu. Le temps de trame est soit donné à une fonction de mise à jour de chacun de ces éléments, soit fourni par un singleton Timer. Il s'agit du temps canonique utilisé pour déterminer la durée depuis le dernier appel de mise à jour.
Objets et caractéristiques du jeu
Le cœur de cette conception est l'interaction des objets et des fonctions du jeu. Comme nous l'avons vu plus haut, une caractéristique est, dans ce sens, un élément de la fonctionnalité du jeu qui peut être mis en œuvre indépendamment les uns des autres. Un objet de jeu est tout ce qui interagit avec le joueur ou tout autre objet de jeu de quelque manière que ce soit. Exemples : L'avatar du joueur lui-même est un objet de jeu. Une torche est un objet de jeu, les PNJ sont des objets de jeu tout comme les zones d'éclairage et les sources sonores ou toute combinaison de ces éléments.
Traditionnellement, les objets de jeu RPG sont la classe supérieure d'une hiérarchie de classes sophistiquée, mais cette approche est tout simplement erronée. Beaucoup d'aspects orthogonaux ne peuvent pas être mis dans une hiérarchie et même en utilisant des interfaces, vous devez avoir des classes concrètes. Un objet est un objet de jeu, un objet pouvant être ramassé est un objet de jeu, un coffre est un conteneur est un objet, mais faire en sorte qu'un coffre puisse être ramassé ou non est une décision à prendre ou non avec cette approche, car vous devez avoir une hiérarchie unique. Et cela se complique lorsque vous voulez avoir un coffre magique parlant qui ne s'ouvre que lorsqu'on répond à une énigme. Il n'existe tout simplement pas de hiérarchie unique et adaptée.
Une meilleure approche consiste à n'avoir qu'une seule classe d'objet de jeu et à placer chaque aspect orthogonal, qui est généralement exprimé dans la hiérarchie des classes, dans sa propre classe de composant/fonctionnalité. L'objet de jeu peut-il contenir d'autres éléments ? Ajoutez-lui la ContainerFeature, s'il peut parler, ajoutez-lui la TalkTargetFeature et ainsi de suite.
Dans ma conception, un GameObject n'a qu'un identifiant unique intrinsèque, un nom et une propriété de localisation, tout le reste est ajouté en tant que composant de fonctionnalité. Les composants peuvent être ajoutés à l'exécution via l'interface GameObject en appelant addComponent(), removeComponent(). Ainsi, pour le rendre visible, ajoutez un VisibleComponent, pour qu'il émette des sons, ajoutez un AudableComponent, pour qu'il soit un conteneur, ajoutez un ContainerComponent.
Le VisibleComponent est important pour votre question, car il s'agit de la classe qui assure le lien entre le modèle et la vue. Tout n'a pas besoin d'une vue au sens classique du terme. Une zone de déclenchement ne sera pas visible, une zone de son ambiant ne le sera pas non plus. Seuls les objets du jeu ayant le VisibleComponent seront visibles. La représentation visuelle est mise à jour dans la boucle principale, lorsque le VisibleFeatureServer est mis à jour. Il met alors à jour la vue en fonction des VisibleComponents qui lui sont enregistrés. Le fait qu'il interroge l'état de chacun d'entre eux ou qu'il mette simplement en file d'attente les messages reçus de ces derniers dépend de votre application et de la bibliothèque de visualisation sous-jacente.
Dans mon cas, j'utilise Ogre3D. Ici, quand un VisibleComponent est attaché à un objet de jeu, il crée un SceneNode qui est attaché au graphe de scène et au noeud de scène une Entity (représentation d'un mesh 3d). Chaque TransformMessage (voir ci-dessous) est traité immédiatement. Le VisibleFeatureServer fait ensuite en sorte qu'Ogre3d redessine la scène vers la RenderWindow (en substance, les détails sont plus compliqués, comme toujours).
Messages
Comment ces fonctions, états de jeu et objets de jeu communiquent-ils entre eux ? Par le biais de messages. Dans cette conception, un message est simplement une sous-classe de la classe Message. Chaque message concret peut avoir sa propre interface qui convient à sa tâche.
Les messages peuvent être envoyés d'un objet de jeu à d'autres objets de jeu, d'un objet de jeu à ses composants et des FeatureServers aux composants dont ils sont responsables.
Lorsqu'un FeatureComponent est créé et ajouté à un objet de jeu, il s'enregistre auprès de l'objet de jeu en appelant myGameObject.registerMessageHandler(this, MessageID) pour chaque message qu'il souhaite recevoir. Il s'enregistre également auprès de son serveur de fonctionnalités pour chaque message qu'il souhaite recevoir de celui-ci.
Si le joueur essaie de parler à un personnage qu'il a dans son focus, alors l'utilisateur déclenchera d'une manière ou d'une autre l'action parler. Par exemple : Si le personnage dans le focus est un NPC amical, alors en appuyant sur le bouton de la souris, l'interaction standard est déclenchée. L'action standard de l'objet de jeu cible est demandée en lui envoyant un message GetStandardActionMessage. L'objet de jeu cible reçoit le message et, en commençant par le premier enregistré, notifie ses composants fonctionnels qui veulent connaître le message. Le premier composant pour ce message définira alors l'action standard à celle qui se déclenchera (TalkTargetComponent définira l'action standard à Talk, qu'il recevra aussi en premier.) et marquera ensuite le message comme consommé. Le GameObject testera la consommation et verra que le message est effectivement consommé et retournera à l'appelant. Le message maintenant modifié est alors évalué et l'action résultante est invoquée.
Oui, cet exemple semble compliqué, mais c'est déjà l'un des plus compliqués. D'autres comme TransformMessage pour notifier un changement de position et d'orientation sont plus faciles à traiter. Un TransformMassage est intéressant pour de nombreux serveurs de fonctionnalités. VisualisationServer en a besoin pour mettre à jour la représentation visuelle du GameObject à l'écran. SoundServer pour mettre à jour la position du son 3d et ainsi de suite.
L'avantage d'utiliser des messages plutôt que d'invoquer des méthodes devrait être clair. Le couplage entre les composants est plus faible. Lorsqu'il invoque une méthode, l'appelant doit connaître le destinataire. En revanche, l'utilisation de messages permet un découplage total. S'il n'y a pas de récepteur, cela n'a pas d'importance. De même, la façon dont le récepteur traite le message, si tant est qu'il le fasse, ne concerne pas l'appelant. Les délégués sont peut-être un bon choix ici, mais Java n'a pas d'implémentation propre pour ceux-ci et dans le cas du jeu en réseau, vous devez utiliser une sorte de RPC, qui a une latence plutôt élevée. Or, une faible latence est cruciale pour les jeux interactifs.
Persistence et marshalling
Cela nous amène à la manière de faire passer des messages sur le réseau. En encapsulant l'interaction GameObject/Feature dans des messages, nous n'avons plus qu'à nous préoccuper de la manière de faire passer les messages sur le réseau. Dans l'idéal, il faut donner aux messages une forme universelle, les placer dans un paquet UDP et l'envoyer. Le récepteur décompose le message en une instance de la classe appropriée et le canalise vers le récepteur ou le diffuse, en fonction du message. Je ne sais pas si la sérialisation intégrée de Java est à la hauteur de la tâche. Mais même si ce n'est pas le cas, il existe de nombreuses librairies qui peuvent le faire.
Les GameObjects et les composants rendent leur état persistant disponible via des propriétés (le C++ ne dispose pas de la sérialisation intégrée). Ils ont une interface similaire à un PropertyBag en Java avec laquelle leur état peut être récupéré et restauré.
Références
-
Le vidage de cerveau : Le blog d'un développeur de jeux professionnel. Également auteurs du moteur open source Nebula, un moteur de jeu utilisé dans des jeux à succès commerciaux. La plupart du design que je présente ici est tiré de la couche d'application de Nebula.
-
Article à noter sur le blog ci-dessus, il expose la couche d'application du moteur. Un autre angle à ce que j'ai essayé de décrire ci-dessus.
-
Une longue discussion sur la façon de concevoir l'architecture d'un jeu. Principalement spécifique à Ogre, mais suffisamment général pour être utile à d'autres.
-
Un autre argument en faveur des conceptions basées sur les composants avec des références utiles en bas de page.