Les capabilities


  • Moddeurs confirmés Rédacteurs

    Sommaire

    Introduction

    Dans ce tutoriel nous allons aborder les capabilities. Les capabilities forment un système ajouté par forge permettant de mettre à disposition des fonctionnalités de manière dynamique et flexible sans avoir à implémenter directement les interfaces.

    Chaque capability met à disposition une interface qui correspond aux fonctionnalités, accompagnées d'une implémentation par défaut et d'une classe permettant de sauvegarder cette dernière.

    Nous allons nous donner plusieurs objectifs pour ce tutoriel :

    • Utiliser la capability IItemHandler mise à disposition par Forge afin d’interagir avec le contenu du coffre de Minecraft.
    • Créer notre propre capability pour ajouter un concept de pollution aux chunks suivant les blocs placés dans ces derniers. J'afficherai une jauge pour représenter cette pollution.

    Nous nous concentrerons donc sur ces objectifs tout en nous apprenant comment utiliser et créer des capability. Pendant toute la durée de ce tutoriel il sera très important de lire attentivement et surtout comprendre comment fonctionnent les capabilities. Le code nécessaire à la mise en place de ces dernières est une chose mais comprendre comment cela fonctionne est beaucoup plus important, c'est pourquoi je ne vous donnerai pas assez de code pour que vous puissiez copier/coller et obtenir quelque chose qui fonctionne.

    Pré-requis

    Code

    Le système de capability :

    Comme indiqué dans l'introduction, une capability n'est finalement que la représentation de fonctionnalités que l'on met à disposition sous forme d'interface. Donc quand vous pensez capability, pensez interface. Ensuite, il va bien falloir donner cette capability à quelqu'un, quelque chose ; ce "quelqu'un, quelque chose" implémentera donc l'interface ICapabilityProvider qui permet d'implémenter les méthodes suivantes : ICapabilityProvider#hasCapability et ICapability#getCapability qui servent respectivement, comme leur nom l'indique, à savoir si une capability est présente et de récupérer une capability. Ainsi, pour ajouter une capability à un ICapabilityProvider (càd : il doit retourner vrai lors de l'appel de ICapabilityProvider#hasCapability et retourner une instance de l'interface de la capability lors de l'appel de ICapabilityProvider#getCapability), vous avez 2 solutions :

    • Vous pouvez modifier le code source de la classe implémentant ICapabilityProvider : dans ce cas là, vous modifiez l'implémentation des méthodes pour retourner les valeurs voulues.
    • Vous ne pouvez pas modifier le code source de la classe implémentant ICapabilityProvider mais il existe un event permettant d'y attacher votre capability : dans ce cas ci, vous utilisez bien sûr l'event prévu à cet effet.
      Une capability se découpera donc de la manière suivante :
    • L'interface possédant des méthodes qui correspondent aux fonctionnalités ajoutées
    • Une classe proposant une implémentation par défaut de l'interface
    • Une classe implémentant Capability.IStorage servant à sauvegarder les données relatives à la capability (sauvegarde facultative mais classe requise).
    • Une classe implémentant ICapabilityProvider servant de wrapper pour ajouter votre capability à tous les objets dont vous ne pouvez pas modifier les codes sources (facultative si votre capability ne vise pas ces objets. Un moddeur annexe pourra cependant toujours wrapper votre capability pour l'attacher à un de ces objets si il le souhaite).
      Tout cela se conclura par l'enregistrement de la capability grâce au CapabilityManager.

    Remarque : les capabilities ne sont pas synchronisées automatiquement entre le client et le serveur, vous devez pour cela utiliser les paquets. Cependant ce tutoriel ne traitera pas de ce procédé qui est indépendant du système des capabilities. De plus, les capabilities ne sont pas persistantes après la mort, il faut pour cela passer par l'event PlayerEvent.Clone afin de copier les valeurs des capabilities du joueur original dans les capabilities du nouveau joueur (utiliser Player.Clone#isWasDeath pour vérifier si le joueur est mort).

    Notre interface :

    Dans un premier temps créons notre interface.
    Nous avons donc une interface qui représente la pollution d'un chunk, nous l'appellerons IPollution :
    IPollution :

    public interface IPollution {
    
        default public void removePollution(int amount) {
            this.addPollution(-amount);
        }
    
        default public void addPollution(int amount) {
            this.setPollution(this.getPollution() + amount);
        }
    
        public void setPollution(int amount);
    
        public int getPollution();
    
    }
    

    Voilà, nous avons notre interface, il n'y a rien de compliqué ici.

    Implémentation par défaut :

    Nous allons maintenant créer notre implémentation par défaut de notre interface :

    DefaultPollution :

    public class DefaultPollution implements IPollution {
    
        protected int pollution;
    
        public DefaultPollution() {
            this.pollution = 0;
        }
    
        @Override
        public int getPollution() {
            return this.pollution;
        }
    
        @Override
        public void setPollution(int amount) {
            this.pollution = amount;
        }
    }
    

    Le storage :

    On va à présent créer la classe qui aura pour but de stocker (sauvegarder) notre capability, c'est simplement une classe implémentant IStorage et qui permet d'écrire dans des NBT.

    PollutionStorage :

    // Comme vous pouvez le voir, le type générique à indiquer à IStorage est celui de votre interface
    public class PollutionStorage implements Capability.IStorage <ipollution>{
    
        @CapabilityInject(IPollution.class)
        public static final Capability <ipollution>POLLUTION_CAPABILITY = null;
    
        @Override
        public NBTBase writeNBT(Capability <ipollution>capability, IPollution instance, EnumFacing side) {
            NBTTagCompound nbt = new NBTTagCompound();
            // Écriture des données
            return nbt;
        }
    
        @Override
        public void readNBT(Capability <ipollution>capability, IPollution instance, EnumFacing side, NBTBase base) {
            if(base instanceof NBTTagCompound) {
                NBTTagCompound nbt = (NBTTagCompound)base;
                // Lecture des données
            }
        }
    }
    

    J'ai aussi ajouté un champ qui contiendra l'instance de notre capability, ici sa valeur est nulle mais sera changée automatiquement par Forge grâce à l'annotation. Ce champ peut bien sûr être mis n'importe où, choisissez ce qui vous convient le mieux.

    Wrapper notre capability :

    Comme nous allons devoir ajouter notre capability aux chunks dont on ne peut pas modifier le code source, nous allons devoir wrapper notre implémentation de IPollution dans une classe implémentant ICapabilityProvider. C'est ce que nous allons faire.
    Créez une classe implémentant ICapabilitySerializable<nbtbase> (association de ICapabilityProvider et INBTSerializable). Dans cette classe ajoutez un champ qui contiendra une instance de votre interface, pour ma part ça donne donc ça pour la déclaration de ce champ :
    PollutionProvider :

    protected IPollution pollution;
    

    Je ne le montre pas ici mais dans le constructeur de la classe j'initialise ce champ avec l'implémentation par défaut de notre capability. Viennent ensuite les méthodes de l'interface ICapabilitySerializable<nbtbase> à implémenter. La première permet de savoir si notre objet à bien notre capability, un simple test d'égalité fera l'affaire :
    PollutionProvider :

    @Override
    public boolean hasCapability(Capability capability, EnumFacing facing) {
        return capability == PollutionStorage.POLLUTION_CAPABILITY;
    }
    

    La seconde méthode doit renvoyer une instance de IPollution si la capability demandée est la nôtre. Ici on ne s'occupe que de notre capability donc si ICapabilityProvider#hasCapability renvoie vrai c'est que la capability demandée est la nôtre :
    PollutionProvider :

    @Override
    public <t> T getCapability(Capability <t>capability, EnumFacing facing) {
        return this.hasCapability(capability, facing) ? PollutionStorage.POLLUTION_CAPABILITY.cast(this.pollution) : null;
    }
    

    Ici on voit que j'utilise notre capability afin de cast notre instance de IPollution pour éviter d'avoir un warning de la part de l'IDE.

    Viennent ensuite les méthodes de sérialisation et de désérialisation :
    PollutionProvider :

    @Override
    public NBTBase serializeNBT() {
        return PollutionStorage.POLLUTION_CAPABILITY.writeNBT(this.pollution, null);
    }
    
    @Override
    public void deserializeNBT(NBTBase nbt) {
        PollutionStorage.POLLUTION_CAPABILITY.readNBT(this.pollution, null, nbt);
    }
    

    Ici encore on ne fait pas grand-chose, on wrap seulement ce que l'on a déjà fait, c'est à dire que l'on fait appel directement aux méthodes de notre storage. Si vous vous demandez pourquoi je mets null au paramètre side, c'est parce que notre capability ne demande pas de côté, mais cet argument peut être utile pour les capabilities qui touchent aux inventaires notamment pour savoir quels slots sont concernés.

    Donner notre nouvelle capability aux chunks :

    Pour attacher notre capability aux chunks nous allons utiliser l'event AttachCapabilitiesEvent<chunk> car nous ne pouvons pas modifier les codes sources de la classe Chunk.
    ATTENTION : cet event ne permet pas d'attacher des capabilities à tous les objets mais seulement aux classes suivantes : Chunk, World, TileEntity, ItemStack et Entity. Cet event est déclenché dans le constructeur de chacune de ces classes, le code se trouvant dans l'event doit donc être le plus rapide possible car il est exécuté à chaque création d'objet.
    Voici la méthode que je place dans une classe d'event :
    Classe enregistrée sur le bus d'événement Forge :

    public static final CAPABILITY_LOCATION = new ResourceLocation("modid", "pollution"); // On évite d'instancier à chaque fois le même objet
    
    @SubscribeEvent
    public static void attachCapability(AttachCapabilitiesEvent <chunk>event) {
        event.addCapability(CAPABILITY_LOCATION, new PollutionProvider()); // Ici, PollutionProvider est la classe que nous venons de créer et qui implémente ICapabilitySerializable
    }
    

    De cette façon, à chaque nouvelle instance de la classe Chunk, on ajoute notre capability.
    ATTENTION : cette event est appelé sur les 2 sides, vous pouvez très bien n'ajouter votre capability que sur un certain side.

    Enregistrer notre capability :

    Vous pouvez enregistrer votre capability à n'importe quel moment en utilisant la méthode CapabilityManager#register, cependant il faut que votre capability soit enregistrée quand un objet auquel elle est attachée et instanciée, c'est pourquoi il est préférable de l'enregistrer assez tôt (init par exemple). Voici comment j'enregistre ma capability :
    Proxy, pre-initialisation :

    CapabilityManager.INSTANCE.register(IPollution.class, new PollutionStorage(), DefaultPollution::new);
    

    Test de notre capability :

    Ici je ne vais pas vous donner tout le code qui m'a permis d'arriver au résultat (j'ai ajouté de la synchronisation client/serveur avec les paquets) mais je vais vous montrer comment accéder à votre interface depuis l'objet ciblé. Ici nous avons l'interface IPollution à récupérer depuis un Chunk (dans le code suivant, chunk est une instance de la classe Chunk) :

    IPollution pollution = chunk.getCapability(PollutionStorage.POLLUTION_CAPABILITY, null);
    

    Ce n'est pas plus compliqué que cela, j'arrive donc au résultat suivant pour changer la pollution si on pose ou on casse un bloc de charbon dans un chunk :

    @SubscribeEvent
    public static void blockPlaced(PlaceEvent event) {
        if (!event.getWorld().isRemote) {
            if (event.getPlacedBlock().getBlock() == Blocks.COAL_BLOCK) {
                Chunk chunk = event.getWorld().getChunkFromBlockCoords(event.getPos());
                chunk.getCapability(PollutionStorage.POLLUTION_CAPABILITY, null).addPollution(5);
                chunk.markDirty();
                updateChunkData(chunk); // Envoie la nouvelle valeur au client, je ne montrerai pas le contenu de cette méthode
            }
        }
    }
    
    @SubscribeEvent
    public static void breakBlock(BreakEvent event) {
        if (!event.getWorld().isRemote) {
            if (event.getState().getBlock() == Blocks.COAL_BLOCK) {
                Chunk chunk = event.getWorld().getChunkFromBlockCoords(event.getPos());
                chunk.getCapability(PollutionProvider.POLLUTION_CAPABILITY, null).removePollution(5);
                chunk.markDirty();
                updateChunkData(chunk);
            }
        }
    }
    

    J'ai ensuite ajouté une jauge de pollution en overlay sur le client en voici ce que j'obtiens :

    Vider les coffres autour de vous :

    Vous pensiez que j'avais oublié de nous devions voir comment utiliser la capability IItemhandler de Forge ? (A moins que ce soit vous qui n'ayez oublié l'introduction ...). Nous allons donc créer un simple objet qui videra les coffres dans un rayon de 5 blocs autour de nous pour nous les donner. Créez une classe pour votre objet, comme vous savez si bien le faire et nous allons ré-écrire la fonction Item#onItemRightClick :

    @Override
    public ActionResult <itemstack>onItemRightClick(World world, EntityPlayer player, EnumHand hand) {
    
    }
    

    Ce que nous allons faire ici c'est boucler sur les blocs dans un rayon défini et vérifier si ils ont une TileEntity. Si c'est le cas nous allons vérifier si cette TileEntity possède la capability CapabilityItemHandler#ITEM_HANDLER_CAPABILITY, si c'est le cas nous récupérerons l'interface associée à cette capability puis viderons l'inventaire qu'elle représente. Prêts ? C'est parti :

    @Override
    public ActionResult <itemstack>onItemRightClick(World world, EntityPlayer player, EnumHand hand) {
        if (!world.isRemote) { // On ne donne les objets au joueur que côté serveur
            final int radius = 5;
        // Ici je vous épargne les boucles, on a les variables x, y et z qui bouclent de -radius à radius.
            BlockPos target = player.getPosition().add(x, y, z);
            IBlockState state = world.getBlockState(target);
            if (state.getBlock().hasTileEntity(state)) { // On vérifie si les blocs ont une TileEntity
                TileEntity te = world.getTileEntity(target);
                if (te != null && te.hasCapability(CapabilityItemHandler.ITEM_HANDLER_CAPABILITY, null)) { // On vérifie si la TileEntity possède la capability voulue
                    IItemHandler itemHandler = te.getCapability(CapabilityItemHandler.ITEM_HANDLER_CAPABILITY, null); // C'est le cas donc on récupère l'interface
                    for (int slot = 0; slot < itemHandler.getSlots(); slot++) {
                        ItemStack stack = itemHandler.extractItem(slot, 64, false); // On extrait tous les stacks des slots
                        player.addItemStackToInventory(stack); // Qu'on ajoute à l'inventaire du joueur (ici il faudrait tester si l'inventaire du joueur est plein mais ce n'est pas vraiment le sujet du tutoriel)
                    }
                }
            }
        // Fin des boucles x, y, z
        }
        return new ActionResult<itemstack>(EnumActionResult.SUCCESS, player.getHeldItem(hand));
    }
    

    Et maintenant, je vais en jeu, je place une armure en diamand dans un coffre à ma droite, 64 de grass dans un coffre à ma gauche, je fais clic-droit avec l'objet et voici le résultat :

    Un jeu d'enfant. Je vous laisse découvrir par vous-même les autres capabilities ajoutéess par Forge.

    Sources

    N'hésitez pas à consulter la documentation de Forge, bien qu'elle ne soit pas complète, les capabilities sont documentées. De plus, si vous avez besoin d'exemples de capability vous pouvez toujours jeter un œil à celles ajoutées par Forge mais aussi jeter un œil aux mods de tests créés pour illustrer les PR sur le repo de MinecraftForge.

    Rédaction :

    • BrokenSwing

    Correction :

    • Folgansky


    Ce tutoriel de BrokenSwing publié sur Minecraft Forge France est mis à disposition selon les termes de la licence Creative Commons Attribution - Pas d’Utilisation Commerciale - Partage dans les Mêmes Conditions 4.0 International

    retourRetour vers le sommaire des tutoriels



  • C'est peut-être un up inutile, mais pourrais-tu faire une partie pour les entités (joueur de préférence) et une autre avec une synchronisation via les packets ?
    Parce-que je t'avoue que je galère à ce niveau là.

    Ça serait cool et ça aiderait pas mal de monde je pense !


  • Moddeurs confirmés Rédacteurs

    Salut,
    Pour ce qui est d'attacher une Capability à un joueur, il n'y a rien de compliqué. Il suffit de souscrire à l'event AttachCapabilityEvent avec comme type générique Entity puis de vérifier que l'instance de l'entité est un joueur.

    @SubscribeEvent
    public static void attachCapability(AttachCapabilitiesEvent<Entity> event) {
        if(event.getObject() instanceof EntityPlayer) {
            //event.addCapability(...);
        }
    }
    

    Ensuite, comme pour tout objet implémentant ICapabilityProvider il suffit d'appeler ICapabilityProvider#hasCapability et ICapabilityProvider#getCapability. Si on attache notre capability à tous les joueurs (comme dans le code plus haut par exemple) alors il n'y a pas besoin de vérifier si le joueur possède la capability (donc pas d'appel de ICapabilityProvider#hasCapability).

    Pour ce qui est de la synchronisation il me semble que je précise dans le tutoriel pourquoi je n'aborde pas le sujet, mais je vais ré-expliquer ici au cas où ça ne soit pas le cas.

    Tout simplement, synchroniser les données contenues dans une capability peut être effectué via de très simples paquets, or ce tutoriel ne concerne pas le système de paquet. De plus il existe plusieurs manière de synchroniser (par exemple envoyer seulement la donnée qui a changée ou envoyer toutes les données à chaque fois).

    Je pense que quelqu'un qui a compris comment fonctionne les paquets est capable de faire la synchronisation seul.

    Mais admettons : globalement il suffit (quand il s'agit d'un joueur) de mettre le joueur en paramètre du constructeur de la capability, ainsi lorsqu'on l'attache au joueur on passe ce dernier en paramètre. Ensuite, dans l'implémentation de l'interface, quand une méthode visant à changer les données est appelée, il faut alors envoyer via un paquet au joueur (on a l'instance vu qu'on l'a mis en paramètre du constructeur). Ce paquet lors de se réception met à jour les données (on a aussi l'instance du joueur lors de la réception du paquet, donc on peut récupérer l'instance de la capability très facilement).

    Pour résumer : on wrap le joueur dans la capability et lors d'une modification des données, on envoie un paquet qui, à sa réception, mettra à jour les données en récupérant l'instance de la capability via l'instance du joueur contenue dans le MessageContext ou accessible via Minecraft.getMinecraft().player (suivant le side sur lequel on se trouve).

    Il ne faudra pas non plus oublier de synchroniser l’entièreté des données lors de l'apparition du joueur.

    Voilà quelques explications complémentaires, ce n'est peut-être pas très clair mais il faudra se débrouiller avec. Je ne rajouterai pas d'explications au tutoriel car ne n'est pas le sujet. Mais si quelqu'un a besoin d'aide, il peut toujours créer un sujet dans la section Aide aux moddeurs du forum.

    TLDR : Attacher une capability au joueur ou quoi que ce soit d'autre est très simple, et non, je n'expliquerai pas comment synchroniser le tout.


Log in to reply