API pour extensions
Un autre mod atteint LethalBreed par un seul point d'entrée et une poignée de
petites classes et paquets, tous sous oas.dreyka.lethalbreed.api. Rien ici n'exige de
mixin : une extension déclare des options, écoute des événements, lit ce que le mod croit savoir
à l'instant, et ajoute ses propres zombies spéciaux, par des appels qui restent stables d'une
version à l'autre, comme le javadoc de ce package le promet déjà.
| Classe | Sert à |
|---|---|
| LethalBreedAddon | le point d'entrée, appelé deux fois au démarrage |
| LethalBreedApi | déclarer un namespace d'IA, écouter un changement de phase |
| LethalBreedConfigApi | enregistrer ses propres options dans le même fichier de config |
| LethalBreedState | lecture seule : phase, nombre suivi, variant d'un zombie, meutes |
| api.event.* | six événements Fabric : adoption d'un zombie, contamination, humeur, entrée en meute, épuration au spawn, ciblage |
| api.variant.* | ajouter un zombie spécial, passif, actif ou à la mort |
Dépendre du mod
Il n'y a pas de dépôt Maven à viser : le jar publié sur Modrinth ou CurseForge est aussi celui
contre lequel une extension se compile. Le déposer dans un dossier du projet et le déclarer en
modImplementation, jamais en compileOnly ni
en implementation, sans quoi Loom le laisse non remappé et le compilateur
se met à parler de class_1799 plutôt que des classes qu'on attendait.
L'API Fabric va sur la même configuration, parce que les événements du mod sont des
Event de Fabric et qu'il en faut un au moment de compiler.
build.gradle.ktsplugins {
id("fabric-loom") version "1.17.12"
}
repositories {
mavenCentral()
maven("https://maven.fabricmc.net/")
}
dependencies {
minecraft("com.mojang:minecraft:1.21.11")
mappings(loom.officialMojangMappings())
modImplementation("net.fabricmc:fabric-loader:0.19.5")
modImplementation("net.fabricmc.fabric-api:fabric-api:0.141.6+1.21.11")
modImplementation(files("libs/lethalbreed-2.0.0.jar"))
}
En Groovy le bloc se translittère à l'identique, à une règle de Gradle près : la version d'un
greffon y doit rester un littéral, donc id 'fabric-loom' version '1.17.12'
et jamais une propriété. Le modid à mettre dans le depends de son
fabric.mod.json est lethalbreed.
Le point d'entrée
LethalBreedAddon est une interface fonctionnelle avec
une méthode obligatoire et une facultative, appelées à deux moments distincts du démarrage.
Déclarer la classe qui l'implémente sous le point d'entrée
lethalbreed:addon de son propre
fabric.mod.json :
fabric.mod.json"entrypoints": {
"lethalbreed:addon": ["com.example.mymod.MyAddon"]
}
onLethalBreedReady() tourne avant que le fichier de
config soit lu et avant que quoi que ce soit lise un registre : c'est là que va
LethalBreedConfigApi.register, avec tout namespace d'IA autorisé et
tout écouteur de phase, assez tôt pour être connu avant le premier zombie chargé.
onConfigLoaded() est une méthode par défaut vide, appelée une fois le
fichier réellement lu et borné ; ne la surcharger que si une de ses décisions dépend de la valeur
réelle d'une option plutôt que de son défaut.
Une extension complète
Tout ce qui suit compile contre le package tel qu'il est : un namespace autorisé, une option enregistrée à côté de celles du mod, un écouteur de phase, un variant spécial, et un écouteur d'événement qui épargne un boss de l'épuration au spawn du mod.
MyAddon.javapackage com.example.mymod;
import oas.dreyka.lethalbreed.api.LethalBreedAddon;
import oas.dreyka.lethalbreed.api.LethalBreedApi;
import oas.dreyka.lethalbreed.api.LethalBreedConfigApi;
import oas.dreyka.lethalbreed.api.OptionBounds;
import oas.dreyka.lethalbreed.api.event.SpawnCullCallback;
import oas.dreyka.lethalbreed.api.variant.SpecialVariant;
import oas.dreyka.lethalbreed.api.variant.SpecialVariantRegistry;
public final class MyAddon implements LethalBreedAddon {
@Override
public void onLethalBreedReady() {
LethalBreedApi.allowAiNamespace("com.example.mymod.");
LethalBreedConfigApi.register("mymod", "MyMod", MyOptions.class,
new OptionBounds("mymodSpawnChance", 0.0, 1.0));
LethalBreedApi.onPhaseChanged((from, to) ->
MyMod.LOGGER.info("phase moved from {} to {}", from, to));
SpecialVariantRegistry.register(SpecialVariant.of("mymod:frostbite",
SpecialVariant.Kind.ACTIVE, 10, new FrostbiteBehavior()));
SpawnCullCallback.EVENT.register((entity, phase, proposed) ->
entity.getType() != MyEntities.SWAMP_BOSS && proposed);
}
}
MyOptions.javapackage com.example.mymod;
public final class MyOptions {
public static double mymodSpawnChance = 0.2;
}
Chaque champ de la classe support doit commencer par le préfixe revendiqué,
vérifié plutôt que présumé : un champ qui l'aurait raté atterrirait sinon dans l'onglet Misc à
côté de l'option d'un inconnu. Le préfixe lui-même fait au moins trois caractères, et en prendre
un déjà pris, ou un qui capturerait une option déjà existante, lève une
IllegalArgumentException avant que quoi que ce soit ne soit écrit.
Le dernier paramètre de register est variadique : on passe autant
d'OptionBounds qu'on veut, un par option à borner, et une option qu'on
n'y nomme pas passe sans être bornée. L'écran de configuration étiquette une ligne avec
lethalbreed.option.<nomDuChamp> et un onglet avec
lethalbreed.category.<categorie>, le nom brut servant de repli. La
catégorie y entre telle qu'elle a été passée, casse comprise : les onglets livrés s'écrivent
lethalbreed.category.Perf ou
lethalbreed.category.Compute, donc un
register("mymod", "MyMod", ...) se nomme dans
lethalbreed.category.MyMod, jamais sous le préfixe. Ces
deux clés s'écrivent dans le fichier de langue de l'extension, sous le préfixe
lethalbreed. : le jeu fusionne les fichiers de langue entre mods, donc
rien à toucher du côté de celui-ci.
Lire ce que le mod sait
LethalBreedState est la moitié lecture seule de la
surface : rien ici ne change quoi que ce soit, et rien n'exige d'écouteur enregistré au préalable.
Tout ce qui traverse la frontière est un type vanilla, un primitif, ou
LethalBreedState.Pack, si bien qu'une extension compilée contre elle
continue de compiler quand les classes d'état interne du mod changent de forme. Thread serveur
uniquement, comme le reste du mod.
lire l'étatint phase = LethalBreedState.phase();
int tracked = LethalBreedState.trackedZombieCount();
SpecialVariant variant = LethalBreedState.variantOf(zombie);
List<LethalBreedState.Pack> packs = LethalBreedState.packs(level);
| Méthode | Répond |
|---|---|
| phase() | la progression de la nuit, zéro sur un monde neuf, sans plafond |
| trackedZombieCount() | les zombies pilotés par le mod, toutes dimensions confondues |
| variantOf(Zombie) | le variant spécial qu'il porte, ou null pour un zombie ordinaire |
| packs(ServerLevel) | chaque meute de cette dimension, en instantané pris à l'appel |
Événements
Six événements Fabric, chacun avec une seule question et une réponse chaînée :
un écouteur reçoit le verdict tel qu'il se tient et renvoie celui qu'il veut, si bien qu'un
écouteur sans avis sur un cas donné renvoie proposed et ne change rien.
Un écouteur qui lève une exception est signalé une fois, dans le journal serveur, puis traité
comme s'il s'était abstenu ; le mod ne s'arrête pas pour une extension cassée.
| Événement | Méthode | Décide |
|---|---|---|
| ContaminationCallback | boolean allowContamination(LivingEntity, boolean) | si une créature attrape la contamination |
| MoodCallback | MoodState mood(Zombie, MoodState) | laquelle des cinq humeurs un zombie tient pour une activation |
| PackJoinCallback | boolean allowJoin(Zombie, long, boolean) | si un zombie est admis dans une meute, id de meute 0 (PackJoinCallback.FOUNDING) signifie une fondation plutôt qu'une adhésion |
| SpawnCullCallback | boolean allowCull(Entity, int, boolean) | si un hostile est épuré (retiré) ; renvoyer true pour l'épurer, false pour qu'il survive |
| TargetCandidateCallback | boolean isValidPrey(Mob, LivingEntity, boolean) | si une créature est une proie pour un zombie |
| ZombieAdoptCallback | boolean allowAdopt(Zombie, boolean) | si un zombie est repris par le cerveau du mod ; renvoyer false le laisse entièrement au jeu de base |
ZombieAdoptCallback est la sortie de secours d'une extension qui a son propre
zombie. Tout ce qui charge en étant un Zombie est repris :
husk, noyé, villageois zombie, et la sous-classe qu'un autre mod aura écrite. C'est le bon
défaut, puisque la horde entière est ce à quoi le mod sert, mais une créature écrite pour faire
autre chose se retrouve les goals retirés, la navigation pilotée par le flow field et une meute
qui l'emmène ailleurs. Refuser l'adoption est total : le zombie garde son IA, n'entre dans
aucune meute, n'est jamais activé. Il reste un hostile dans un monde où le mod épure les
hostiles, donc ce refus s'accompagne presque toujours d'un
SpawnCullCallback. La question est posée à chaque chargement et pas
une seule fois dans la vie du monde, parce que l'extension qui l'a revendiqué peut fort bien
avoir été absente la fois précédente.
TargetCandidateCallback tourne sur la phase large. Plusieurs centaines de
fois par tick sur un serveur chargé, dans le balayage qui chiffre déjà le coût d'une horde. Lire
un attachment, comparer un type d'entité, renvoyer : tout ce qui alloue, parcourt un chunk ou
prend un verrou va ailleurs. Un datapack qui veut seulement épargner une liste fixe de types
d'entités de l'épuration au spawn veut plutôt le tag
lethalbreed:spawn_protected, sans une ligne de code.
Les types qui traversent la frontière
Les signatures ci-dessus nomment leurs types sans leur paquet, et deviner celui d'un type du jeu
coûte une compilation : Zombie a déménagé en 1.21 et vit maintenant sous
monster.zombie. Les imports exacts, côté mod et côté jeu, pour les six
événements, les variants et la lecture d'état :
importsimport oas.dreyka.lethalbreed.api.LethalBreedState;
import oas.dreyka.lethalbreed.api.event.ContaminationCallback;
import oas.dreyka.lethalbreed.api.event.MoodCallback;
import oas.dreyka.lethalbreed.api.event.MoodState;
import oas.dreyka.lethalbreed.api.event.PackJoinCallback;
import oas.dreyka.lethalbreed.api.event.SpawnCullCallback;
import oas.dreyka.lethalbreed.api.event.TargetCandidateCallback;
import oas.dreyka.lethalbreed.api.event.ZombieAdoptCallback;
import oas.dreyka.lethalbreed.api.variant.VariantBehavior;
import oas.dreyka.lethalbreed.api.variant.VariantContext;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.Mob;
import net.minecraft.world.entity.monster.zombie.Zombie;
Les sous-classes ont suivi Zombie dans le même dossier :
Husk, Drowned et
ZombieVillager vivent sous
net.minecraft.world.entity.monster.zombie elles aussi, ce qui compte
pour une extension qui trie par type dans un écouteur d'adoption.
MoodState est une énumération de cinq constantes,
NORMAL, FLEEING,
SHELTERING, CELEBRATING,
SLEEPING. L'écouteur de phase est un
PhaseChanged, une seule méthode
void onPhaseChanged(int from, int to).
| Méthode | Répond |
|---|---|
| Zombie zombie() | le zombie qui porte le variant |
| ServerLevel level() | la dimension où il se tient |
| LivingEntity target() | la proie choisie par le mod, null tant qu'il n'y en a pas |
| boolean ready() | si le délai propre au variant est écoulé |
| void resetCooldown() | relance ce délai après avoir agi |
Variants spéciaux
Les huit variants livrés entrent par la même porte qu'un variant d'extension : le
tirage, la sauvegarde et le tick lisent tous
SpecialVariantRegistry et ne savent pas distinguer un variant de
l'autre. SpecialVariant.of(id, kind, weight, behavior) est le chemin
court, disponible dès la phase zéro à un poids fixe ; le constructeur complet du record accepte à
la place une phase de déblocage et un poids lus en direct, pour un variant dont la disponibilité
suit une option de config plutôt qu'une constante.
Un id namespacé ("mymod:frostbite") évite la collision
entre deux mods ; les huit livrés sont des mots nus parce que les sauvegardes les tiennent déjà
ainsi. Enregistrer depuis onLethalBreedReady() et ne jamais
désenregistrer : un variant disparu alors qu'un monde tient encore des zombies portant son id
transformerait ces zombies en entrée de sauvegarde qui ne résout plus vers rien. Renommer un
variant garde l'ancien id lisible via
SpecialVariantRegistry.alias(oldId, id).
Le nom qu'un joueur voit quand specialShowName est actif se lit dans un
fichier de langue, et la clé se déduit de l'id : le deux-points devient un point, le tout en
minuscules, derrière lethalbreed.special.. Un variant
mymod:frostbite se nomme donc dans
lethalbreed.special.mymod.frostbite, écrit dans le fichier de langue de
l'extension. Sans cette clé, c'est elle que le joueur lit au-dessus du zombie.
| Méthode | Appelée |
|---|---|
| onSpawn(Zombie) | PASSIVE, une fois, quand le zombie finit son spawn |
| onUnassign(Zombie) | quand le variant est retiré, pour défaire onSpawn |
| tick(VariantContext) | ACTIVE, sur l'activation propre au zombie, décalée |
| onDeath(Zombie, ServerLevel) | DEATH, pendant que le zombie est encore dans le monde |
Les quatre méthodes ne font rien par défaut, si bien qu'une extension ne
surcharge que celle qu'appelle son Kind et laisse les autres telles
quelles. Une seule instance de VariantBehavior est partagée par tous les
zombies portant ce variant : l'état propre à un zombie va sur le zombie lui-même, via un data
attachment Fabric propre à l'extension, pas sur un champ du behavior.
onUnassign couvre le variant qu'on retire d'un zombie, pas
l'extension qu'on retire du dossier. Un jar sorti de mods/ n'appelle
plus rien : ce que onSpawn a posé de permanent reste dans la NBT des
zombies déjà nés, et le mod les charge sans rien dire puisque c'est un effet ordinaire. Poser un
effet sans durée est donc une décision qui survit à l'extension, et une extension qui veut
pouvoir disparaître proprement pose des durées finies qu'elle renouvelle dans
tick.
Où signaler ce qui casse
Le carnet de rapports du dépôt, github.com/Dreyka-Oas/lethalbreed-support/issues, avec un compte GitHub. Le mod est sous licence MIT, détaillée sur Installation : écrire une extension contre la surface ci-dessus ne demande aucune permission au-delà, c'est précisément à cela qu'elle sert.