Les capabilities


  • Moddeurs confirmés Rédacteurs

    Le système de capability ajouté par Forge est relativement simple et terriblement efficace. Il permet en effet d'exposer des interfaces aux autres mods. Par exemple Forge a créé des capabilities pour différentes choses, il met à disposition des capabilities pour jouer avec les liquides, les items ou encore l'énergie (entre autres). Mais vous pouvez vous aussi exposer votre propre capability ou l'implémentation d'une capability déjà existante.

    C'est donc ce que nous allons voir dans ce tutoriel. Vous apprendrez à utiliser les capabilities déjà exposées par Forge, à créer votre propre capability et à l'exposer.

    Sommaire du tutoriel

    Pré-requis

    Comprendre les capabilities

    Avant de commencer à créer ou utiliser des capabilities il est important de comprendre comment celles-ci fonctionnent. Une capability est un objet permettant d'accéder à l'instance d'une interface pour un objet donné. Par exemple la capability CapabilityItemHandler.ITEM_HANDLER_CAPABILITY permet d'accéder à une instance de IItemHandler, tandis que la capability CapabilityEnergy.ENERGY permet d'accéder à une instance de IEnergyStorage.
    Comme vous vous en doutez sûrement, vous ne pouvez cependant pas récupérer ces instances depuis n'importe quel objet, il faut en effet que l'objet soit un ICapabilityProvider (fournisseur de capability, ça a du sens). Il existe plusieurs classes dans Minecraft qui implémentent cette interface, comme Entity, Chunk ou encore ItemStack par exemple. On peut cependant implémenter cette interface nous même dans nos propres classes.
    Donc pour résumer, les capabilities permettant d'exposer des interfaces. Muni d'une capability et d'une instance de ICapabilityProvider on peut récupérer une instance de l'interface associée à la capability.

    Créer une capability

    Voyons comment créer notre propre capability. Si vous avez correctement lu ce qui a été dit plus haut, une capability permet d'exposer une interface, nous allons donc faire notre interface.

    Personnellement je veux implémenter un système d'épuisement dans le jeu, voici donc l'interface que je crée.

    /**
     * Interface permettant de gérer la fatigue. La fatigue est représentée par un entier allant de 0 à 10 000, avec 10 000
     * correspondant au maximum de fatigue.
     */
    public interface IExhaustable
    {
    
        /**
         * Renvoie un nombre entre 0 et 10 000
         * @return la fatigue
         */
        int getExhaustion();
    
        /**
         * Permet de définir la fatigue, celle-ci est un nombre entre 0 et 10 000 (bornes incluses).
         * Dans le cas d'une valeur passée supérieure à 10 000, celle-ci est est plafonnée automatiquement.
         * Dans le cas d'une valeur négative passée, celle-ci est compté comme nulle.
         * @param value La nouvelle valeur de la fatigue
         */
        void setExhaustion(int value);
    
        /**
         * Réduit la fatigue.
         * @param value La quantité de fatigue à enlever
         */
        default void reduceExhaustion(int value) {
            this.setExhaustion(this.getExhaustion() - value);
        }
    
        /**
         * Augmente la fatigue.
         * @param value La quantité de fatigue à ajouter
         */
        default void increaseExhaustion(int value) {
            this.setExhaustion(this.getExhaustion() + value);
        }
    }
    

    C'est une interface tout ce qu'il y a de plus simple.

    La seconde étape est de créer une implémentation par défaut de notre capability. Encore une fois cela va être très simple, à l'image de notre interface :

    public class ExhaustionHolder implements IExhaustable
    {
        private int exhaustion = 0;
    
        @Override
        public int getExhaustion()
        {
            return this.exhaustion;
        }
    
        @Override
        public void setExhaustion(int value)
        {
            this.exhaustion = clamp(value);
        }
    
        private int clamp(int value) {
            if(value > 10000) return 10000;
            if(value < 0) return 0;
            return value;
        }
    }
    

    Et la dernière chose dont nous avons besoin est une implémentation de Capability.IStorage, celle-ci permet d'enregistrer les données relatives à notre interface dans des tags NBT afin de la sauvegarder (qu'on ne perde pas nos données quand quitte le monde). Nous allons faire ceci tout de suite et encore une fois c'est pas une implémentation compliquée.

    public static class DefaultExhaustionStorage implements Capability.IStorage<IExhaustable> {
    
        @Nullable
        @Override
        public INBTBase writeNBT(Capability<IExhaustable> capability, IExhaustable instance, EnumFacing side)
        {
            return new NBTTagInt(instance.getExhaustion());
        }
    
        @Override
        public void readNBT(Capability<IExhaustable> capability, IExhaustable instance, EnumFacing side, INBTBase nbt)
        {
            instance.setExhaustion(((NBTTagInt)nbt).getInt());
        }
    }
    

    Maintenant je vais créer une classe que je vais appeler CapabilityExhaustion dans laquelle je vais rajouter une fonction permettant d'enregistrer ma capability :

    public static void register()
    {
        CapabilityManager.INSTANCE.register(IExhaustable.class, new DefaultExhaustionStorage(), ExhaustionHolder::new);
    }
    

    Pour enregistrer une capability on doit donc appeler CapabilityManager#register en passant l'interface que l'on veut exposer, notre implémentation de Capability.IStorage ainsi que l'implémentation par défaut de notre interface.

    Dans cette même classe je vais ajouter une variable statique qui contiendra l'instance de notre capability (celle permettant de récupérer l'instance de notre interface via une instance de ICapabilityProvider, relisez la permière partie si vous êtes perdus).

    @CapabilityInject(IExhaustable.class)
    public static final Capability<IExhaustable> EXHAUSTION_CAPABILITY = null;
    

    On crée donc une variable de type Capability que l'on annote avec CapabilityInject, cette annotation permettra à Forge de changer la valeur de la variable une fois notre capability enregistrée via le CapabilityManager.

    On va maintenant appeler notre fonction register sinon notre capability ne sera jamais enregistrée. Pour cela je me rend dans la classe principale de mon mod et j'appelle ma fonction dans l'event FMLCommonSetupEvent :

    @Mod(ModTutorial.MOD_ID)
    public class ModTutorial {
    
    	// [...]
    
    	public ModTutorial() {
    		FMLJavaModLoadingContext.get().getModEventBus().addListener(this::setup);
    		// [...]
    	}
    
    	private void setup(final FMLCommonSetupEvent event) {
    		// [...]
    		CapabilityExhaustion.register();
    	}
    
            // [...]
    }
    

    Voilà, nous avons créer notre capability et nous l'avons enregistrée. Cependant nous ne l'utilisons pas, et elle ne sert pas à grand chose. Rendez-vous dans la suite du tutoriel.

    Attacher une capability à une classe

    Afin qu'on puisse récupérer une capability depuis l'instance d'une classe, il faut que celle-ci implémente l'interface ICapabilityProvider. Cette interface possède une méthode à implémenter (et une autre dont l'implémentation par défaut est suffisante donc nous nous en occuperont pas). La méthode de cette interface permet donc de récupérer une capability, cependant il faut savoir que ce n'est pas parce qu'une classe implémente ICapabilityProvider qu'elle possède forcément votre capability (par exemple on imagine mal un ItemStack être fatigué, il ne possèdera donc sûrement pas notre capability). Afin d'être sûr que vous soyez parfaitement conscient de ce que vous faites quand vous manipulez les capabilities, Forge demande de retourner un LazyOptional qui doit être empty() si l'instance ne possède pas la capability, ou alors of(instance)instance est l'instance de l'interface associée à la capability demandée.

    Dans le cas d'une classe qui nous appartient il est très simple d'implémenter cette interface mais imaginons que je veuille ajoute ma capability aux entités, je n'ai bien-heureusement pas la possibilité de modifier le code source de cette classe. Comme vous pouvez vous en douter Forge a ajouté un event pour pouvoir faire ce genre de choses. Cet event est AttachCapabilitiesEvent, et nous ajoutons notre capability via AttachCapabilitiesEvent#addCapability(ResourceLocation, ICapabilityProvider). Comme vous pouvez le voir il nous faut là aussi une instance de ICapabilityProvider donc nous allons implémenter une classe comme si vous voulions lui donner notre capability puis nous en passerons une instance à cette méthode. La ResourceLocation est une clé permettant d'identifier de manière unique votre capability (elle sert pour la sauvegarde).

    Nous allons donc créer une implémentation de ICapabilityProvider pour notre capability, et plus précisement nous allons implémenter ICapabilitySerializable afin que Forge sache qu'il faut sauvegarder notre capability.
    Voici donc notre implémentation :

    public class PlayerExhaustionWrapper implements ICapabilitySerializable<INBTBase>
    {
        private IExhaustable holder = CapabilityExhaustion.EXHAUSTION_CAPABILITY.getDefaultInstance();
        private final LazyOptional<IExhaustable> lazyOptional = LazyOptional.of(() -> this.holder);
    
        @Nonnull
        @Override
        public <T> LazyOptional<T> getCapability(@Nonnull Capability<T> cap, @Nullable EnumFacing side)
        {
            return CapabilityExhaustion.EXHAUSTION_CAPABILITY.orEmpty(cap, lazyOptional);
        }
    
        @Override
        public INBTBase serializeNBT()
        {
            return CapabilityExhaustion.EXHAUSTION_CAPABILITY.writeNBT(this.holder, null);
        }
    
        @Override
        public void deserializeNBT(INBTBase nbt)
        {
            CapabilityExhaustion.EXHAUSTION_CAPABILITY.readNBT(holder, null, nbt);
        }
    }
    

    Il ne devrait rien à avoir de surprenant, je crée une instance de mon interface (celle par défaut) puis lorsque quelqu'un appelle getCapability en passant en paramètre ma capability (CapabilityExhaustion.EXHAUSTION_CAPABILITY) je retourne cette instance. Ce mécanisque de vérification est déjà fait pour nous dans Capability#orEmpty car c'est un comportement que la majorité des moddeurs et Forge utilisent.

    Pour ce qui est de serializeNBT et deserializeNBT, je redirige simplement les appels vers l'implémentation de Capability.IStorage que j'ai founi lors de l'enregistrement de ma capability.

    On peut donc maintenant fournir une instance de cette implémentation à l'event AttachCapabilitiesEvent. Donc ma classe CapabilityExhaustion que j'annote préalablement de @Mod.EventBusSubscriber(modid = ModTutorial.MOD_ID), j'ajoute la variable suivante :

    public static final ResourceLocation CAP_KEY = new ResourceLocation(ModTutorial.MOD_ID, "exhaustion");
    

    Ainsi que l'event pour attacher ma capability aux entités (je veux que les entités subissent la fatigue) :

    @SubscribeEvent
    public static void attachToEntities(AttachCapabilitiesEvent<Entity> event)
    {
        if(event.getObject() instanceof EntityLivingBase && !event.getObject().world.isRemote)
        {
            PlayerExhaustionWrapper wrapper = new PlayerExhaustionWrapper();
            event.addCapability(CAP_KEY, wrapper);
        }
    }
    

    Ici ma condition permet de vérifier que l'entité est une entité vivante et aussi que je sois côté serveur.

    Important

    Ne faites pas d'opération trop lourde pendant cet event car il est appelé à l'instantiation de chaque entité (dans notre cas), ce qui correspond à un nombre d'appels assez conséquent.

    Note

    Si nous avions voulu attacher notre capability à un ItemStack nous souscrit à l'événement AttachCapabilitiesEvent<ItemStack>. Les type génériques valides sont Entity, ItemStack, Chunk, TileEntity et World .

    Voilà, maintenant toutes les instance de EntityLivingBase côté serveur possèdent notre capability.

    Utiliser une capability

    Nous allons donc maintenant apprendre à utiliser une capability et plus précisément notre capability mais gardez bien en tête que les méthodes à appeler pour utiliser d'autre capabilities sont exactement les mêmes que celles que nous allons voir. En réalité il n'y a qu'une seule méthode à utiliser : ICapabilityProvider#getCapability.
    Je vais mettre ici beaucoup de code dans lequel je récupére une instance de LazyOptional après avoir appelé ICapabilityProvider#getCapability puis je l'utilise pour effectuer un traitement. J'utilise la méthode LazyOptional#ifPresent. Si vous n'êtes pas à l'aise avec les Optional je vous invite à consulter la documentation de Java.

    @Mod.EventBusSubscriber(modid = ModTutorial.MOD_ID)
    public class ExhaustionModificationsHandler
    {
        private static final UUID MODIFIER_ID = UUID.fromString("73c800ed-7da9-4a6f-8fe1-b106096269f6");
    
        @SubscribeEvent
        public static void onLivingJump(LivingEvent.LivingJumpEvent event)
        {
            event.getEntity().getCapability(CapabilityExhaustion.EXHAUSTION_CAPABILITY)
                    .ifPresent(cap -> cap.increaseExhaustion(50));
        }
    
        @SubscribeEvent
        public static void onLivingTick(LivingEvent.LivingUpdateEvent event)
        {
            event.getEntity().getCapability(CapabilityExhaustion.EXHAUSTION_CAPABILITY)
                    .ifPresent(cap -> {
                        cap.reduceExhaustion(2);
                        if(event.getEntity().motionX == 0 && event.getEntity().motionY == 0 && event.getEntity().motionZ ==0)
                        {
                            cap.reduceExhaustion(4);
                        }
                        if(event.getEntity().isSprinting())
                        {
                            cap.increaseExhaustion(3);
                        }
                        if(event.getEntity().isSwimming())
                        {
                            cap.increaseExhaustion(3);
                        }
    
                        // Operations :
                        //  0 : valeur à ajouter à la valeur de base
                        //          valeurFinale = valeurBase + quantite
                        //  1 : pourcentage de la valeur de base à ajouter
                        //          valeurFinale += valeurBase * quantite
                        //  2 : pourcentage de la valeur finale à rajouter en plus
                        //          valeurFinale *= 1 + quantite
                        AttributeModifier modifier = new AttributeModifier(
                                MODIFIER_ID,
                                "exhaustion",
                                - cap.getExhaustion() / 10_000f,
                                2
                        );
    
                        updateModifierFor(EntityLivingBase.SWIM_SPEED, modifier, event.getEntityLiving());
                        updateModifierFor(SharedMonsterAttributes.MOVEMENT_SPEED, modifier, event.getEntityLiving());
                        updateModifierFor(SharedMonsterAttributes.ATTACK_SPEED, modifier, event.getEntityLiving());
                    });
        }
    
        @SubscribeEvent
        public static void onLivingEat(LivingEntityUseItemEvent event)
        {
            event.getEntity().getCapability(CapabilityExhaustion.EXHAUSTION_CAPABILITY).ifPresent(capa -> {
                if(event.getItem().getItem() instanceof ItemFood)
                {
                    ItemFood food = (ItemFood) event.getItem().getItem();
                    capa.reduceExhaustion(food.getUseDuration(event.getItem()));
                }
            });
        }
    
        @SubscribeEvent
        public static void onLivingAttack(LivingAttackEvent event)
        {
            event.getEntity().getCapability(CapabilityExhaustion.EXHAUSTION_CAPABILITY).ifPresent(capa -> capa.increaseExhaustion(50));
        }
    
        private static void updateModifierFor(IAttribute attribute, AttributeModifier modifier, EntityLivingBase entity)
        {
            IAttributeInstance instance = entity.getAttribute(attribute);
            if(instance.hasModifier(modifier))
            {
                instance.removeModifier(modifier);
            }
    
            if(entity instanceof EntityPlayerMP) {
                EntityPlayerMP player = (EntityPlayerMP) entity;
                if(player.isCreative() || player.isSpectator()) return;
            }
    
            instance.applyModifier(modifier);
        }
    }
    

    À chaque fois je récupère le LazyOptional et si il contient une instance de mon interface j'effectue un traitement avec. Pour résumer le code, quand le joueur effectue certaines actions (courir, sauter, nager, prendre des dégâts) il accumule de la fatigue, quand il mange il perd de la fatigue et il perd aussi de la fatigue au cours du temps. Plus le joueur est fatigué, plus il est lent.

    Rendre votre capability persistante après la mort du joueur

    Si vous avez déjà un peu testé le code que l'on a fait jusqu'à maintenant vous vous êtes peut-être rendu compte que si vous mourrez, vous ne conservez pas la fatigue que vous avez accumulée avant votre mort. Dans le cas de la fatigue ce n'est pas très grave mais il peut arriver que vous vouliez garder le même état.

    Pour cela nous allons un peu modifier le contenu de notre classe CapabilityExhaustion, je vais ajouter une Map qui me permettra de stocker l'instance de mon interface (celle associée à ma capability) telle qu'elle était avant le respawn du joueur afin de pouvoir la restorer par la suite :

    private static final Map<Entity, IExhaustable> INVALIDATED_CAPS = new WeakHashMap<>();
    

    Attention

    Il est important d'avoir une WeakHashMap ici afin de ne pas créer de fuite de mémoire.

    Ensuite dans l'event pour attacher ma capability aux entités, je stock l'instance de mon interface dans le cas où l'entité est un joueur (seuls le joueurs meurent est respawn) :

    @SubscribeEvent
    public static void attachToEntities(AttachCapabilitiesEvent<Entity> event)
    {
        if(event.getObject() instanceof EntityLivingBase && !event.getObject().world.isRemote)
        {
            PlayerExhaustionWrapper wrapper = new PlayerExhaustionWrapper();
            event.addCapability(CAP_KEY, wrapper);
            if(event.getObject() instanceof EntityPlayer)
            {
                event.addListener(() -> wrapper.getCapability(CapabilityExhaustion.EXHAUSTION_CAPABILITY).ifPresent(cap -> INVALIDATED_CAPS.put(event.getObject(), cap)));
            }
        }
    }
    

    Puis je souscrit à l'event PlayerEvent.Clone qui est appelé au respawn du joueur (après sa mort ou après avoir passé un portail). Pour savoir si le joueur vient de mourir on a la méthode PlayerEvent.Clone#isWasDeath. Si le joueur était mort on récupère donc les données de l'ancienne instance de notre interface et on les passe à la nouvelle instance de notre capability :

    @SubscribeEvent
    public static void copyCapabilities(PlayerEvent.Clone event)
    {
        if(event.isWasDeath())
        {
            event.getEntityPlayer().getCapability(CapabilityExhaustion.EXHAUSTION_CAPABILITY).ifPresent(newCapa -> {
                if(INVALIDATED_CAPS.containsKey(event.getOriginal()))
                {
                    INBTBase nbt = CapabilityExhaustion.EXHAUSTION_CAPABILITY.writeNBT(INVALIDATED_CAPS.get(event.getOriginal()), null);
                    CapabilityExhaustion.EXHAUSTION_CAPABILITY.readNBT(newCapa, null, nbt);
                }
            });
        }
    }
    

    Synchroniser votre capability

    Lorsque que vous créez une capability, les données relatives à celle-ci ne sont pas synchronisées automatiquement entre le client et le serveur. Dans notre cas, la capability que j'ai créé n'est :

    • Premièrement : pas attachée à aux entités côté client car j'ai mis la condition !event.getObject().world.isRemote qui permet de s'assurer que l'on se trouve côté serveur.

    • Deuxièmement : pas synchronisée avec le client. En effet celui-ci n'a aucun moyen de connaitre son état de fatigue actuel, cela pose un problème si nous voulons par exemple afficher celui-ci sur un GUI.

    Nous allons donc voir comment synchroniser notre capability avec le client concerné afin de pouvoir y accéder côté client. Je ne vais vous montrer ici que le paquet que je vais utiliser car vous avez bien sûr les pré-requis.

    Pour commencer je vais donc créer un simple paquet qui aura pour rôle de transporter la valeur de la fatigue du serveur vers le client. À sa réception je change la valeur de la fatigue sur le client :

    public class SyncExhaustionPacket
    {
        private int exhaustion;
    
        public SyncExhaustionPacket(IExhaustable instance) {
            this.exhaustion = instance.getExhaustion();
        }
    
        public SyncExhaustionPacket(int exhaustion)
        {
            this.exhaustion = exhaustion;
        }
    
        public static void encode(SyncExhaustionPacket pck, PacketBuffer buf)
        {
            buf.writeInt(pck.exhaustion);
        }
    
        public static SyncExhaustionPacket decode(PacketBuffer buf)
        {
            return new SyncExhaustionPacket(buf.readInt());
        }
    
        public static void handle(SyncExhaustionPacket pck, Supplier<NetworkEvent.Context> ctxSupplier)
        {
            if(ctxSupplier.get().getDirection().getReceptionSide() == LogicalSide.CLIENT)
                ctxSupplier.get().enqueueWork(() -> handleClientUpdate(pck));
            ctxSupplier.get().setPacketHandled(true);
        }
    
        @OnlyIn(Dist.CLIENT)
        private static void handleClientUpdate(SyncExhaustionPacket pck)
        {
            Minecraft.getInstance().player.getCapability(CapabilityExhaustion.EXHAUSTION_CAPABILITY)
                    .ifPresent(capa -> capa.setExhaustion(pck.exhaustion));
        }
    
    }
    

    On veut pouvoir envoyer ce paquet dès que la valeur de la fatigue change. C'est pourquoi je vais créer une implémentation dédiée pour les joueurs côté serveur afin qu'elles prennent en paramètre un joueur :

    public class PlayerExhaustionHolder extends ExhaustionHolder
    {
    
        private EntityPlayerMP player;
    
        public PlayerExhaustionHolder(EntityPlayerMP player)
        {
            this.player = player;
        }
    
        @Override
        public void setExhaustion(int value)
        {
            super.setExhaustion(value);
            // setExhaustion peut être appelé trop tôt et que player.connection soit null
            if (player.connection != null)
            {
                player.getCapability(CapabilityExhaustion.EXHAUSTION_CAPABILITY)
                        .ifPresent(capa -> TutorialNetwork.CHANNEL.send(
                                PacketDistributor.PLAYER.with(() -> this.player),
                                new SyncExhaustionPacket(capa))
                        );
            }
        }
    }
    

    A chaque fois que la valeur change, on envoie un paquet au client concerné. On va aussi modifier un peu l'implémentation dans notre classe PlayerExhaustionWrapper afin de pouvoir renseigner l'instance de IExhaustable voulue :

    public class PlayerExhaustionWrapper implements ICapabilitySerializable<INBTBase>
    {
    
        private IExhaustable holder;
        // [...]
    
        public PlayerExhaustionWrapper(IExhaustable holder) {
            this.holder = holder;
        }
    
        // [...]
    
    }
    

    Et puis on modifie ce qui se trouve dans l'événement pour attacher notre capability aux entités. Rendez-vous donc dans le classe CapabilityExhaustion à la fonction attachToEntities(AttachCapabilitiesEvent<Entity>). Suivant si l'entité est une instance de EntityPlayerMP ou non, on va instancier l'une ou l'autre de nos implémentations de IExhausable. Je vais aussi retirer la condition vérifiant que nous sommes côté serveur, cependant dans la classe ExhaustionModificationsHandler je vérifierai bien à chaque fois que je sois sur le serveur afin de modifier ma valeur seulement côté serveur puis de la synchroniser :

    @SubscribeEvent
    public static void attachToEntities(AttachCapabilitiesEvent<Entity> event)
    {
        if(event.getObject() instanceof EntityLivingBase)
        {
            IExhaustable holder;
            if(event.getObject() instanceof EntityPlayerMP)
            {
                holder = new PlayerExhaustionHolder((EntityPlayerMP)event.getObject());
            }
            else
            {
                holder = CapabilityExhaustion.EXHAUSTION_CAPABILITY.getDefaultInstance();
            }
    
            PlayerExhaustionWrapper wrapper = new PlayerExhaustionWrapper(holder);
            event.addCapability(CAP_KEY, wrapper);
            
            if(event.getObject() instanceof EntityPlayer)
            {
                event.addListener(() -> wrapper.getCapability(CapabilityExhaustion.EXHAUSTION_CAPABILITY).ifPresent(cap -> INVALIDATED_CAPS.put(event.getObject(), cap)));
            }
        }
    }
    

    Et voilà, tout devrait fonctionner. Pour vérifier cela je met un point d'arrêt au niveau de l'arrivé du paquet sur le client et je vois que je reçois bien la valeur de la fatigue :

    2019-06-10_14-28-26.png

    La mise à jour se bien fait sur le client.

    Résultat

    Les différentes modifications du code sont retrouvables sur le Github de MinecraftForgeFrance.

    Vous pouvez vous référer aux différents commits :

    Licence et attribution

    Creative Commons

    Ce tutoriel rédigé par @BrokenSwing corrigé par <correcteur> et 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

    retour Sommaire des tutoriels