Plant est un jeu de référence où les joueurs plantent et arrosent des graines, afin de pouvoir récolter et vendre les plantes résultantes.

Le projet se concentre sur des cas d'utilisation courants que vous pourriez rencontrer lors du développement d'un jeu sur Roblox. Lorsque cela est applicable, vous trouverez des notes sur les compromis, les choix difficiles et la logique derrière divers choix d'implémentation, afin que vous puissiez prendre la meilleure décision pour vos propres jeux.
Obtenir le fichier
- Naviguez vers la page du jeu Plant.
- Cliquez sur le bouton ⋯ et Modifier dans Studio.
Cas d'utilisation
Plant couvre les cas d'utilisation suivants :
- Persistance des données de session et des données des joueurs
- Gestion de la vue de l'interface utilisateur
- Réseau client-serveur
- Expérience utilisateur pour les nouveaux utilisateurs (FTUE)
- Achats en monnaie dure et douce
De plus, ce projet résout des ensembles de problèmes plus restreints qui sont applicables à de nombreux jeux, y compris :
- Personnalisation d'une zone dans le lieu associée à un joueur
- Gestion de la vitesse de mouvement du personnage du joueur
- Création d'un objet qui suit les personnages
- Détection de la partie du monde dans laquelle se trouve un personnage
Notez qu'il y a plusieurs cas d'utilisation dans ce jeu qui sont trop petits, trop de niche, ou ne démontrent pas une solution à un défi de conception intéressant ; ceux-ci ne sont pas couverts.
Structure du projet
La première décision lors de la création d'un jeu est de décider comment structurer le projet, qui inclut principalement où placer des instances spécifiques dans le modèle de données et comment organiser et structurer les points d'entrée pour le code client et serveur.
Modèle de données
Le tableau suivant décrit dans quels services de conteneurs dans le modèle de données les instances sont placées.
| Service | Types d'instances |
|---|---|
| Workspace | Contient des modèles statiques représentant le monde 3D, spécifiquement des parties du monde qui n'appartiennent à aucun joueur. Vous n'avez pas besoin de créer, modifier ou détruire dynamiquement ces instances à l'exécution, donc il est acceptable de les laisser ici. Il y a aussi un Folder vide, auquel les modèles de ferme des joueurs seront ajoutés à l'exécution. |
| Lighting | Effets atmosphériques et d'éclairage. |
| ReplicatedFirst | Contient le plus petit sous-ensemble possible d'instances nécessaires pour afficher l'écran de chargement et initialiser le jeu. Plus d'instances sont placées dans ReplicatedFirst, plus l'attente pour qu'elles se répliquent avant que le code dans ReplicatedFirst puisse s'exécuter sera longue.
|
| ReplicatedStorage | Servit de conteneur de stockage pour toutes les instances pour lesquelles un accès est requis à la fois côté client et côté serveur.
|
| ServerScriptService | Contient un Script servant de point d'entrée pour tout le code côté serveur dans le projet. |
| ServerStorage | Servit de conteneur de stockage pour toutes les instances qui n'ont pas besoin d'être répliquées au client.
|
| SoundService | Contient les objets Sound utilisés pour les effets sonores dans le jeu. Sous SoundService, ces objets Sound n'ont pas de position et ne sont pas simulés dans l'espace 3D. |
Points d'entrée
La plupart des projets organisent le code à l'intérieur de ModuleScripts réutilisables qui peuvent être importés dans l'ensemble de la base de code. ModuleScripts sont réutilisables mais ne s'exécutent pas d'eux-mêmes ; ils doivent être importés par un Script ou LocalScript. De nombreux projets Roblox auront un grand nombre d'objets Script et LocalScript, chacun se rapportant à un comportement ou un système particulier dans le jeu, créant ainsi plusieurs points d'entrée.
Pour le microjeu Plant, une approche différente est mise en œuvre à travers un seul LocalScript qui est le point d'entrée pour tout le code client, et un seul Script qui est le point d'entrée pour tout le code serveur. L'approche correcte pour votre projet dépend de vos exigences, mais un point d'entrée unique offre un meilleur contrôle sur l'ordre dans lequel les systèmes sont exécutés.
Les listes suivantes décrivent les compromis des deux approches :
- Un seul Script et un seul LocalScript couvrent respectivement le code serveur et client.
- Un meilleur contrôle sur l'ordre dans lequel différents systèmes sont démarrés car tout le code est initialisé à partir d'un seul script.
- Peut passer des objets par référence entre les systèmes.
Architecture des systèmes de haut niveau
Les systèmes de haut niveau dans le projet sont détaillés ci-dessous. Certains de ces systèmes sont substantiellement plus complexes que d'autres, et dans de nombreux cas, leur fonctionnalité est abstraite à travers une hiérarchie d'autres classes.

Chacun de ces systèmes est un "singleton", en ce sens qu'il s'agit d'une classe non instanciable qui est plutôt initialisée par le script start client ou serveur pertinent. Vous pouvez en savoir plus sur le modèle singleton plus loin dans ce guide.
Serveur
Les systèmes suivants sont associés au serveur.
| Système | Description |
|---|---|
| Réseau |
|
| PlayerDataServer |
|
| Marché |
|
| CollisionGroupManager |
|
| FarmManagerServer |
|
| PlayerObjectsContainer |
|
| TagPlayers |
|
| FtueManagerServer |
|
| CharacterSpawner |
|
Client
Les systèmes suivants sont associés au client.
| Système | Description |
|---|---|
| Réseau |
|
| PlayerDataClient |
|
| MarketClient |
|
| LocalWalkJumpManager |
|
| FarmManagerClient |
|
| UISetup |
|
| FtueManagerClient |
|
| CharacterSprint |
|
Communication client-serveur
La plupart des jeux Roblox impliquent un certain élément de communication entre le client et le serveur. Cela peut inclure la demande du client au serveur d'effectuer une certaine action et le serveur répliquant des mises à jour au client.
Dans ce projet, la communication client-serveur est maintenue aussi générique que possible en limitant l'utilisation des objets RemoteEvent et RemoteFunction afin de diminuer le nombre de règles spéciales à suivre. Ce projet utilise les méthodes suivantes, par ordre de préférence :
- Réplication via le système de données des joueurs.
- Réplication via attributs.
- Réplication via tags.
- Messaging directement via le module Réseau.
Réplication via le système de données des joueurs
Le système de données des joueurs permet d'associer des données au joueur qui persistent entre les sessions de sauvegarde. Ce système fournit une réplication du client au serveur et un ensemble d'API qui peuvent être utilisées pour interroger des données et s'abonner à des changements, ce qui le rend idéal pour répliquer les changements d'état du joueur du serveur au client.
Par exemple, plutôt que de déclencher un UpdateCoins RemoteEvent sur mesure pour dire au client combien de pièces il a, vous pouvez appeler ce qui suit et laisser le client s'y abonner via l'événement PlayerDataClient.updated.
PlayerDataServer.setValue(player, "coins", 5)Bien sûr, cela n'est utile que pour la réplication du serveur au client et pour les valeurs que vous souhaitez conserver entre les sessions, mais cela s'applique à un nombre surprenant de cas dans le projet, y compris :
- L'étape FTUE actuelle
- L'inventaire du joueur
- Le nombre de pièces que le joueur a
- L'état de la ferme du joueur
Réplication via attributs
Dans les situations où le serveur doit répliquer une valeur personnalisée au client qui est spécifique à une Instance donnée, vous pouvez utiliser attributs. Roblox réplique automatiquement les valeurs d'attributs, donc vous n'avez pas besoin de maintenir des chemins de code pour répliquer l'état associé à un objet. Un autre avantage est que cette réplication se produit en même temps que l'instance elle-même.
Ceci est particulièrement utile pour les instances créées à l'exécution, car les attributs définis sur une nouvelle instance avant qu'elle ne soit parentée au modèle de données se répliqueront de manière atomique avec l'instance elle-même. Cela contourne tout besoin d'écrire du code pour "attendre" que des données supplémentaires soient répliquées via un RemoteEvent ou StringValue.
Vous pouvez également lire directement les attributs du modèle de données, que ce soit du côté client ou serveur, avec la méthode GetAttribute(), et vous abonner aux changements avec la méthode GetAttributeChangedSignal(). Dans le projet Plant, cette approche est utilisée pour, entre autres choses, répliquer l'état actuel des plantes aux clients.
Réplication via tags
CollectionService vous permet d'appliquer un tag de chaîne à une Instance. Cela est utile pour catégoriser les instances et répliquer cette catégorisation au client.
Par exemple, le tag CanPlant est appliqué sur le serveur pour signifier au client qu'un pot donné est capable de recevoir une plante.
Messaging directement via le module réseau
Pour les situations où aucune des options précédentes ne s'applique, vous pouvez utiliser des appels réseau personnalisés via le module Réseau. C'est la seule option dans le projet qui permet la communication client-serveur et est donc la plus utile pour transmettre des demandes du client et recevoir une réponse du serveur.
Plant utilise des appels réseau directs pour une variété de demandes du client, y compris :
- Arroser une plante
- Planter une graine
- Acheter un article
L'inconvénient de cette approche est que chaque message individuel nécessite une configuration sur mesure qui peut augmenter la complexité du projet, bien que cela ait été évité autant que possible, en particulier pour la communication serveur-client.
Classes et singletons
Les classes dans le projet Plant, comme les instances sur Roblox, peuvent être créées et détruites. Sa syntaxe de classe est inspirée de l'approche idiomatique Lua pour la programmation orientée objet avec un certain nombre de changements pour permettre le support de vérification de type stricte.
Instanciation
De nombreuses classes dans le projet sont associées à une ou plusieurs Instances. Les objets d'une classe donnée sont créés en utilisant une méthode new(), conforme à la façon dont les instances sont créées dans Roblox en utilisant Instance.new().
Ce modèle est généralement utilisé pour les objets où la classe a une représentation physique dans le modèle de données, et la classe étend sa fonctionnalité. Un bon exemple est BeamBetween qui crée un objet Beam entre deux objets Attachment donnés et maintient ces attachements orientés de sorte que le faisceau soit toujours dirigé vers le haut. Ces instances pourraient être clonées à partir d'une version préfabriquée dans ReplicatedStorage ou passées dans new() comme argument et stockées à l'intérieur de l'objet sous self.
Instances correspondantes
Comme noté ci-dessus, de nombreuses classes dans ce projet ont une représentation dans le modèle de données, une instance qui correspond à la classe et est manipulée par elle.
Plutôt que de créer ces instances lorsqu'un objet de classe est instancié, le code opte généralement pour Clone() une version préfabriquée de la Instance stockée sous ReplicatedStorage ou ServerStorage. Bien qu'il soit possible de sérialiser les propriétés de ces instances et de les créer à partir de zéro dans les fonctions new() de la classe, cela rendrait l'édition des objets très encombrante et plus difficile à comprendre pour un lecteur. De plus, cloner une instance est généralement une opération plus rapide que de créer une nouvelle instance et de personnaliser ses propriétés à l'exécution.
Composition
Bien que l'héritage soit possible en Luau en utilisant des métatables, le projet opte plutôt pour permettre aux classes de s'étendre les unes aux autres par composition. Lors de la combinaison de classes par composition, l'objet "enfant" est instancié dans la méthode new() de la classe et est inclus comme membre sous self.
Pour un exemple de cela en action, voir la classe CloseButton qui enveloppe la classe Button.
Nettoyage
De la même manière qu'une Instance peut être détruite avec la méthode Destroy(), les classes qui peuvent être instanciées peuvent également être détruites. La méthode destructrice pour les classes du projet est destroy() avec un d minuscule pour la cohérence camelCase à travers les méthodes de la base de code, ainsi que pour distinguer entre les classes du projet et les instances Roblox.
Le rôle de la méthode destroy() est de détruire toutes les instances créées par l'objet, de déconnecter toutes les connexions, et d'appeler destroy() sur tous les objets enfants. Cela est particulièrement important pour les connexions car les instances avec des connexions actives ne sont pas nettoyées par le ramasse-miettes Luau, même s'il n'y a plus de références à l'instance ou de connexions à l'instance.
Singletons
Les singletons, comme leur nom l'indique, sont des classes pour lesquelles un seul objet peut jamais exister. Ils sont l'équivalent du projet des Services de Roblox. Plutôt que de stocker une référence à l'objet singleton et de le passer dans le code Luau, Plant tire parti du fait que l'exigence d'un ModuleScript met en cache sa valeur retournée. Cela signifie que l'exigence du même ModuleScript singleton à partir de différents endroits fournit de manière cohérente le même objet retourné. La seule exception à cette règle serait si différents environnements (client ou serveur) accédaient au ModuleScript.
Les singletons se distinguent des classes instanciables par le fait qu'ils n'ont pas de méthode new(). Au lieu de cela, l'objet ainsi que ses méthodes et son état sont retournés directement via le ModuleScript. Comme les singletons ne sont pas instanciés, la syntaxe self n'est pas utilisée et les méthodes sont plutôt appelées avec un point (.) plutôt qu'avec un deux-points (:).
Vérification de type stricte
Luau prend en charge le typage graduel, ce qui signifie que vous êtes libre d'ajouter des définitions de type optionnelles à une partie ou à l'ensemble de votre code. Dans ce projet, une vérification de type strict est utilisée pour chaque script. C'est l'option la moins permissive pour l'outil Analyse de script de Roblox et donc la plus susceptible de détecter des erreurs de type avant l'exécution.
Syntaxe de classe typée
L'approche établie pour créer des classes en Lua est bien documentée, cependant elle n'est pas bien adaptée au typage fort de Luau. En Luau, l'approche la plus simple pour obtenir le type d'une classe est la méthode typeof() :
type ClassType = typeof(Class.new())Cela fonctionne mais ce n'est pas très utile lorsque votre classe est initiée avec des valeurs qui n'existent qu'à l'exécution, par exemple des objets Player. De plus, l'hypothèse faite dans la syntaxe de classe idiomatique Lua est que déclarer une méthode sur une classe self sera toujours une instance de cette classe ; ce n'est pas une hypothèse que le moteur d'inférence de type peut faire.
Pour prendre en charge l'inférence de type stricte, le projet Plant utilise une solution qui diffère de la syntaxe de classe idiomatique Lua de plusieurs manières, dont certaines peuvent sembler non intuitives :
- La définition de self est dupliquée, à la fois dans la déclaration de type et dans le constructeur. Cela introduit un fardeau de maintenabilité, mais des avertissements seront signalés si les deux définitions ne sont plus synchronisées.
- Les méthodes de classe sont déclarées avec un point, de sorte que self peut être explicitement déclaré comme étant de type ClassType. Les méthodes peuvent toujours être appelées avec un deux-points comme prévu.
--!strict
local MyClass = {}
MyClass.__index = MyClass
export type ClassType = typeof(setmetatable(
{} :: {
property: number,
},
MyClass
))
function MyClass.new(property: number): ClassType
local self = {
property = property,
}
setmetatable(self, MyClass)
return self
end
function MyClass.addOne(self: ClassType)
self.property += 1
end
return MyClassTypes de cast après des gardes logiques
Au moment de la rédaction, le type d'une valeur n'est pas réduit après une instruction conditionnelle de garde. Par exemple, après la garde ci-dessous, le type de optionalParameter n'est pas réduit à number.
--!strict
local function foo(optionalParameter: number?)
if not optionalParameter then
return
end
print(optionalParameter + 1)
endPour atténuer cela, de nouvelles variables sont créées après ces gardes avec leur type explicitement casté.
--!strict
local function foo(optionalParameter: number?)
if not optionalParameter then
return
end
local parameter = optionalParameter :: number
print(parameter + 1)
endTraverser les hiérarchies du DataModel
Dans certains cas, la base de code doit traverser la hiérarchie du modèle de données d'un arbre d'objets qui sont créés à l'exécution. Cela présente un défi intéressant pour la vérification de type. Au moment de la rédaction, il n'est pas possible de définir une hiérarchie de modèle de données générique en tant que type. En conséquence, il y a des cas où la seule information de type disponible pour une structure de modèle de données est le type de l'instance de niveau supérieur.
Une approche à ce défi consiste à caster en any puis à affiner. Par exemple :
local function enableVendor(vendor: Model)
local zonePart: BasePart = (vendor :: any).ZonePart
endLe problème avec cette approche est qu'elle impacte la lisibilité. Au lieu de cela, le projet utilise un module générique appelé getInstance pour traverser les hiérarchies du modèle de données qui cast en any en interne.
local function enableVendor(vendor: Model)
local zonePart: BasePart = getInstance(vendor, "ZonePart")
endÀ mesure que la compréhension du moteur de type du modèle de données évolue, il est possible que des modèles comme celui-ci ne soient plus nécessaires.
Interface utilisateur
Plant comprend une variété d'interfaces utilisateur 2D complexes et simples. Celles-ci incluent des éléments d'affichage tête haute (HUD) non interactifs comme le compteur de pièces et des menus interactifs complexes comme le magasin.
Approche de l'UI
Vous pouvez comparer de manière lâche l'UI de Roblox à l'HTML DOM, car c'est une hiérarchie d'objets qui décrit ce que l'utilisateur devrait voir. Les approches pour créer et mettre à jour une UI Roblox sont largement divisées en pratiques impératives et déclaratives.
| Approche | Avantages et inconvénients |
|---|---|
| Impératif | Dans l'approche impérative, l'UI est traitée comme n'importe quelle autre hiérarchie d'instances sur Roblox. La structure de l'UI est créée avant l'exécution dans Studio et ajoutée au modèle de données, généralement directement dans StarterGui. Ensuite, à l'exécution, le code manipule des morceaux spécifiques de l'UI pour refléter l'état requis par le créateur. Cette approche présente certains avantages. Vous pouvez créer l'UI à partir de zéro dans Studio et la stocker dans le modèle de données. C'est une expérience d'édition simple et visuelle qui peut accélérer la création de l'UI. Comme le code de l'UI impératif ne se préoccupe que de ce qui doit être changé, cela rend également les changements simples de l'UI faciles à mettre en œuvre. Un inconvénient notable est que, puisque les approches UI impératives nécessitent que l'état soit implémenté manuellement sous forme de transformations, des représentations complexes de l'état peuvent devenir très difficiles à trouver et à déboguer. Il est courant que des erreurs émergent lors du développement de code UI impératif, surtout lorsque l'état et l'UI deviennent désynchronisés en raison de mises à jour multiples interagissant dans un ordre inattendu. Un autre défi avec les approches impératives est qu'il est plus difficile de décomposer l'UI en composants significatifs qui peuvent être déclarés une fois et réutilisés. Comme l'ensemble de l'arbre UI est déclaré au moment de l'édition, des modèles communs peuvent être répétés dans plusieurs parties du modèle de données. |
| Déclaratif | Dans l'approche déclarative, l'état souhaité des instances UI est déclaré explicitement, et l'implémentation efficace de cet état est abstraite par des bibliothèques telles que Roact ou Fusion. L'avantage de cette approche est que l'implémentation de l'état devient triviale et vous n'avez qu'à décrire à quoi vous voulez que votre UI ressemble. Cela rend l'identification et la résolution des bogues significativement plus faciles. L'inconvénient clé est de devoir déclarer l'ensemble de l'arbre UI dans le code. Des bibliothèques comme Roact et Fusion ont une syntaxe pour faciliter cela, mais c'est tout de même un processus long et une expérience d'édition moins intuitive lors de la composition de l'UI. |
Plant utilise une approche impérative sous l'idée que montrer les transformations directement donne une vue d'ensemble plus efficace de la façon dont l'UI est créée et manipulée sur Roblox. Cela ne serait pas possible avec une approche déclarative. Certaines structures et logiques UI répétées sont également abstraites en composants réutilisables pour éviter un piège commun dans la conception UI impérative.
Architecture de haut niveau

Couches et composants
Dans Plant, toutes les structures UI sont soit une Layer, soit un Component.
- Layer est défini comme un singleton de regroupement de niveau supérieur qui enveloppe des structures UI préfabriquées dans ReplicatedStorage. Une couche peut contenir un certain nombre de composants, ou elle peut encapsuler entièrement sa propre logique. Des exemples de couches sont le menu d'inventaire ou l'indicateur du nombre de pièces dans l'affichage tête haute.
- Component est un élément UI réutilisable. Lorsqu'un nouvel objet composant est instancié, il clone un modèle préfabriqué à partir de ReplicatedStorage. Les composants peuvent eux-mêmes contenir d'autres composants. Des exemples de composants sont une classe de bouton générique ou le concept d'une liste d'articles.
Gestion des vues
Un problème courant de gestion de l'UI est la gestion des vues. Ce projet a une gamme de menus et d'éléments HUD, dont certains écoutent les entrées utilisateur, et une gestion soigneuse de quand ils sont visibles ou activés est requise.
Plant aborde ce problème avec son système UIHandler qui gère quand une couche UI doit ou ne doit pas être visible. Toutes les couches UI dans le jeu sont catégorisées comme HUD ou Menu et leur visibilité est gérée par les règles suivantes :
- L'état activé des couches Menu et HUD peut être basculé.
- Les couches HUD activées ne sont affichées que si aucune couche Menu n'est activée.
- Les couches Menu activées sont stockées dans une pile, et une seule couche Menu est visible à la fois. Lorsqu'une couche Menu est activée, elle est insérée au début de la pile et affichée. Lorsqu'une couche Menu est désactivée, elle est retirée de la pile et la prochaine couche Menu activée dans la file d'attente est affichée.
Cette approche est intuitive car elle permet de naviguer dans les menus avec un historique. Si un menu est ouvert à partir d'un autre menu, fermer le nouveau menu affichera à nouveau l'ancien menu.
Les singletons de couche UI s'enregistrent auprès du UIHandler et reçoivent un signal qui se déclenche lorsque sa visibilité doit changer.
Lectures complémentaires
À partir de cet aperçu approfondi du projet Plant, vous voudrez peut-être explorer les guides suivants qui approfondissent des concepts et des sujets connexes.
- Modèle Client-Serveur — Un aperçu du modèle client-serveur dans Roblox.
- Événements distants et rappels — Tout sur les événements réseau distants et les rappels pour la communication à travers la frontière client-serveur.
- UI — Détails sur les objets et la conception de l'interface utilisateur sur Roblox.