Lumora Engine — Content-Coding-Guide
Diese Seite zeigt, wie du Content für die Lumora-Engine schreibst: Items, Crates, Bosse, Abilities und mehr. Du brauchst dafür kein Wissen über die Engine-Interna — eine kleine, in sich geschlossene Klasse pro Inhalt genügt.
Die Engine entwickelt Gameplay. Content nutzt Gameplay. Content beschreibt nur, was existiert — die Engine entscheidet, wie es umgesetzt wird. Diese beiden Welten werden niemals vermischt.
Lumora ist die neue MMO-Engine (Paper-Plugin), die JxnSMP ablöst. Sie ist strikt in
Engine, Systems und Content getrennt.
Als Content-Autor arbeitest du fast ausschließlich im Ordner content/ — du legst
eine neue Datei an, beschreibst deinen Inhalt und bist fertig. Kein zentrales Register,
keine Engine-Klasse, die du anfassen musst.
Getting Started
Bevor wir loslegen, die drei Dinge, die du über das Arbeiten mit Lumora wissen musst. Sie gelten für jeden Content-Typ — Boss, Item, Effekt, Ability.
1. Wo lege ich Content ab?
Aller Content lebt unter src/main/java/dev/lumora/content/<kategorie>/.
Die Regel ist klar: ein Unterordner pro Content-Stück (benannt nach der
id), in dem die Einstiegsklasse und alle ihre Helfer-Klassen zusammenliegen — auch
bei Single-File-Content. Der Bootstrap-Scan findet Klassen in beliebiger Tiefe,
also kostet das nichts und hält Ordnung, wenn viele Inhalte dazukommen.
2. Wie wird er gefunden? (Auto-Discovery)
Es gibt keine Registrierungsliste. Beim Serverstart durchsucht
ContentBootstrap das Plugin-Jar nach allen Klassen unter
dev.lumora.content.** — in beliebiger Tiefe, also auch in
deinen Unterordnern — die eine Content-Kategorie erfüllen (z. B.
implements Content oder extends CustomEffect), erzeugt jede über
ihren parameterlosen Konstruktor und ruft register() auf.
Hilfsklassen ohne Kategorie (z. B. deine IceTitanHealth) werden ignoriert
— sie werden nur von deinem Boss instanziiert, nicht vom Bootstrap.
Neuer Content = ein neuer Unterordner unter content/, sonst nichts.
Du fasst nie eine Engine- oder Bootstrap-Datei an, um etwas zu
„registrieren". Genau das macht parallele Arbeit merge-frei: zwei Devs legen zwei neue
Ordner an und kommen sich nie in der Quere.
Jede Content-Klasse braucht einen no-arg-Konstruktor (der Default reicht). Innere,
nicht-statische Klassen und anonyme Klassen werden übersprungen — darum bekommt jeder
Inhalt seine eigene .java-Datei.
3. Wie teste ich, dass es ankam?
Nach dem Deploy (siehe unten) prüfst du im Spiel mit
/lumora content verify (Alias /lum), ob dein Inhalt erkannt und
pro Engine registriert wurde. Der Befehl listet jede gefundene Content-id auf — taucht
deine id auf, ist die Discovery durch.
Bauen, Deployen & Testen
Lumora wird mit Gradle gebaut und auf den geteilten Test-Server kopiert. Der komplette Zyklus:
# 1) Bauen + Jar direkt in den Test-Server kopieren
./gradlew deploy # -> ../Paper/plugins/Lumora-<version>.jar
# (nur Jar bauen, ohne kopieren:)
./gradlew shadowJar # -> build/libs/Lumora-<version>.jar
Danach den Server neu starten (..\Paper\start.bat) — Lumora
discovert Content nur beim Start.
/reload
Plugins sauber neu laden geht bei Paper nicht — /reload lässt Lumora in
einem halben Zustand zurück. Immer den ganzen Server neu starten.
Lumora schreibt Standard-Configs nach plugins/Lumora/*.yml und überschreibt
sie nicht. Hast du das Default-Schema geändert, lösche die veralteten
plugins/Lumora/*.yml, damit sie beim nächsten Start frisch erzeugt werden.
Gradle ist in gradle.properties auf JDK 21 gepinnt
(Gradle 8.x läuft nicht auf JDK 25). Diesen Pin nicht entfernen.
Kurz-Checkliste für jede Änderung:
Das Zwei-Schichten-Prinzip
Lumora trennt sauber zwischen Content-Authoring und Engine-Interna. Als Content-Autor berührst du nur Layer A.
Content-Authoring
Spielend einfach. Eine in sich geschlossene Klasse pro Inhalt, in einer eigenen Datei
unter content/<typ>/. Wird automatisch erkannt — kein zentrales Register.
Engine-Interna
Strukturiert und stabil. Builder, Definition, Registry, Factory, Runtime, EventBus. Davon siehst du als Content-Autor nichts.
Auto-Discovery: zwei Devs, kein Merge-Konflikt
Es gibt keine zentrale Registrierungsliste, in die alle ihre Inhalte eintragen. Jeder Content liegt in seiner eigenen Datei und wird beim Start automatisch gefunden. Dadurch arbeiten zwei Entwickler an unterschiedlichen Inhalten, ohne sich jemals in derselben Datei in die Quere zu kommen.
Zwei gleichwertige Formen
Content gibt es in zwei Ausprägungen — beide leben unter content/:
-
Einfacher Content (Items, Crates): eine Klasse, die
CustomItembzw.CustomCrateerweitert undregister()mit einem inline-Fluent-DSL überschreibt. -
Strukturierter Content (Bosse, Mobs usw.): eine Klasse, die
Contentimplementiert, einen statischen Builder aufruft und Component-Klassen verdrahtet (Health, Loot, Spawn, AI …).
Der geteilte Entity-Core
Boss und Mob fühlen sich beim Schreiben identisch an — und das ist kein Zufall. Genau wie
Minecraft selbst (Entity → LivingEntity → Mob, ein Boss ist nur ein
Mob + Bossbar) teilen sich Boss und Mob in Lumora einen Core
unter engine/entity/. Dort lebt alles, was eine lebende Entität ausmacht — an
einer Stelle, von allen geerbt:
EntityBuilder<SELF>— alle Entity-Regler (Aussehen, Ausrüstung, Attribute, Flags, Immunitäten, FX, Sounds, Selbst-Effekte, Drops). Self-typed (CRTP), damit jede Fluent-Methode den konkreten Builder-Typ zurückgibt.EntityShell— unveränderlicher Snapshot dieser Config (jede Definition hält genau einen).EntityShellApplier— wendet die Shell beim Spawn auf das frische Bukkit-Entity an (Name, Glow, Scale, Attribute, Ausrüstung, Effekte, Flags). Der eine geteilte Spawn-Pfad.EntityComponent<R>— die Hooks (onSpawn/onTick/onHit/onDamaged/onIncomingDamage/onDeath), typisiert auf die jeweilige Runtime.
BossBuilder und MobBuilder erben von
EntityBuilder; BossComponent extends EntityComponent<BossRuntime>,
MobComponent extends EntityComponent<MobRuntime>. Ergebnis: derselbe
Content-Code. Genau deshalb sehen Boss- und Mob-Authoring gleich aus — ein Boss
ergänzt nur Bossbar + Phasen, ein Mob lässt sie weg.
Kommt ein neuer gemeinsamer Knopf dazu (z. B. ein Flag), wird er einmal im
EntityBuilder + EntityShell + EntityShellApplier
verdrahtet — und steht sofort bei Boss und Mob (und künftigen Entity-Typen) zur
Verfügung.
Verfügbare Engines
Lumora besteht aus mehreren spezialisierten Engines. Für jede erstellst du Content auf dieselbe Art:
Wohin gehören die Dateien?
Jeder Content-Typ hat seine eigene Kategorie unter content/, und darin bekommt
jedes einzelne Content-Stück seinen eigenen Unterordner. Ein Boss bringt
alle seine Component-Klassen in diesem Ordner mit; ein Single-File-Content (Effekt, Item)
bekommt ebenfalls einen Ordner:
Lege für jeden neuen Inhalt einen eigenen Ordner an, statt einen bestehenden zu füllen.
Multi-File-Content (wie icetitan) hat alle Helfer im selben Ordner. Die
Auto-Discovery findet alles in beliebiger Tiefe automatisch — du musst nirgendwo etwas
„anmelden".
Tutorial: Dein erster Boss in 6 Schritten
Vom leeren Editor zum lauffähigen Mini-Boss. Wir bauen einen simplen
"frost_imp" — kein Schnickschnack, nur das Nötigste, damit er spawnt und
kämpft. Jeder Schritt baut auf dem vorigen auf.
Schritt 1 — Eine leere Content-Klasse anlegen
Lege einen eigenen Ordner an und darin die Datei
content/bosses/frostimp/FrostImp.java. Sie implementiert
Content — das ist der Einstiegspunkt, den die Auto-Discovery findet.
package dev.lumora.content.bosses.frostimp;
import dev.lumora.api.content.Content;
public final class FrostImp implements Content {
@Override public void register() {
// kommt gleich
}
}
Schritt 2 — Den Builder mit einer id starten
In register() rufst du BossBuilder.create("id"). Die id ist der
eindeutige Name, über den der Boss überall referenziert wird.
@Override public void register() {
BossBuilder.create("frost_imp")
.register(); // wirft noch — Health fehlt (Schritt 4)
}
Schritt 3 — Aussehen geben: Name & Basis-Mob
displayName(...) nimmt MiniMessage, baseEntity(...) bestimmt den
Vanilla-Mob, auf dem der Boss aufsetzt.
BossBuilder.create("frost_imp")
.displayName("<aqua>Frost Imp")
.baseEntity(EntityType.VEX)
.register();
Schritt 4 — Die Pflicht-Component: Health
Ein Boss muss eine HealthComponent haben, sonst wirft
register(). Lege dafür eine zweite Datei in denselben Ordner:
content/bosses/frostimp/FrostImpHealth.java:
package dev.lumora.content.bosses.frostimp;
import dev.lumora.engine.boss.component.HealthComponent;
public final class FrostImpHealth extends HealthComponent {
@Override public double maxHealth() { return 60; }
}
Schritt 5 — Health verdrahten + abschließen
Reiche die Component in den Builder und gib dem Imp noch ein paar Werte mit. Das
.register() am Ende meldet ihn bei der Engine an.
@Override public void register() {
BossBuilder.create("frost_imp")
.displayName("<aqua>Frost Imp")
.baseEntity(EntityType.VEX)
.health(new FrostImpHealth()) // Pflicht erfüllt
.attackDamage(4)
.scale(1.3)
.bossBarColor(BossBar.Color.BLUE)
.register();
}
Schritt 6 — Deployen, spawnen & testen
Bauen, kopieren, Server neu starten — dann den Imp direkt im Spiel spawnen:
./gradlew deploy # Jar -> ../Paper/plugins/
# Server neu starten (..\Paper\start.bat) — NICHT /reload
# im Spiel (Permission: lumora.boss.admin):
/boss list # listet alle Boss-ids + aktive Anzahl
/boss spawn frost_imp # spawnt den Imp an deiner Position
/lumora content verify # optional: prueft die Auto-Discovery
/boss spawn <id> hat Tab-Vervollständigung für alle registrierten Bosse.
Steht frost_imp in /boss list, ist er registriert und spawnbar.
(Programmatisch geht es zusätzlich über BossAPI.spawn("frost_imp", location) —
z. B. aus einem World-Event.)
Zwei kleine Dateien, kein Engine-Code. Von hier aus erweiterst du ihn mit Loot, FX, Immunitäten, Phasen oder eigenen Components — alles weiter unten.
Tutorial: Dein erster Mob in 4 Schritten
Ein Mob wird mit denselben Methoden gebaut wie ein Boss — nur ohne
Bossbar und Phasen (beide erben vom Entity-Core). Wir bauen einen
"snow_rabbit". Wenn du das Boss-Tutorial kennst, kommt dir hier alles bekannt
vor.
Schritt 1 — Eine Content-Klasse anlegen
Eigener Ordner, eigene Datei: content/mobs/snowrabbit/SnowRabbit.java. Auch ein
Mob implementiert Content.
package dev.lumora.content.mobs.snowrabbit;
import dev.lumora.api.content.Content;
import dev.lumora.engine.mob.MobBuilder;
import org.bukkit.entity.EntityType;
public final class SnowRabbit implements Content {
@Override public void register() {
// kommt gleich
}
}
Schritt 2 — Builder starten & Basis geben
MobBuilder.create("id") statt BossBuilder — sonst dieselben
Aussehen-/Basis-Methoden. Anders als beim Boss ist keine HealthComponent nötig:
die HP setzt du direkt mit maxHealth(...).
MobBuilder.create("snow_rabbit")
.displayName("<white>Snow Rabbit")
.baseEntity(EntityType.RABBIT)
.maxHealth(12)
.register();
Schritt 3 — Verhalten mit den geerbten Reglern
Attribute, Immunitäten, FX, Sounds, Drops — alles dieselben Methoden wie beim Boss (sie
kommen aus dem geteilten EntityBuilder). Mob-Extras: loot(...) und
component(...).
MobBuilder.create("snow_rabbit")
.displayName("<white>Snow Rabbit")
.baseEntity(EntityType.RABBIT)
.maxHealth(12)
.movementSpeed(0.4)
.immuneTo(DamageCause.FREEZE)
.ambientParticle(Particle.SNOWFLAKE, 2)
.xpDrop(3)
.register();
Schritt 4 — Deployen & spawnen
./gradlew deploy
# Server neu starten — NICHT /reload
# im Spiel (Permission: lumora.mob.admin):
/mob list # listet alle Mob-ids + aktive Anzahl
/mob spawn snow_rabbit # spawnt den Mob an deiner Position
Du hast nichts Neues gelernt — nur MobBuilder statt
BossBuilder und /mob spawn statt /boss spawn. Loot
und eigene Hook-Components funktionieren identisch (siehe Mob bauen).
Tutorial: Deinen ersten Effekt bauen
Effekte sind ein geteiltes System: einmal gebaut, von Boss, Mob, Ability
und Item nutzbar. Wichtig: ein Lumora-Effekt macht echte eigene Logik —
nicht bloß addPotionEffect(vanilla). Wir bauen einen
"burning"-Effekt, der direkt Feuer anlegt, und rufen ihn dann aus einer
Component auf.
Schritt 1 — Von CustomEffect erben & register()
Eigener Ordner, eigene Datei: content/effects/burning/Burning.java. In
register() deklarierst du id, Standarddauer, einen
Anzeigenamen (MiniMessage, wird über der Hotbar gezeigt) und optional
einen Panel-Carrier (dazu unten mehr).
package dev.lumora.content.effects.burning;
import dev.lumora.engine.effect.CustomEffect;
import org.bukkit.Particle;
import org.bukkit.entity.LivingEntity;
import org.bukkit.potion.PotionEffectType;
public final class Burning extends CustomEffect {
@Override protected void register() {
setId("burning");
setDisplayName("<red>🔥 Burning"); // Actionbar: "🔥 Burning Ns"
setPanelCarrier(PotionEffectType.UNLUCK); // optional: Eintrag im Inventar-Panel
defaultDuration(100); // Ticks, falls apply() 0 übergibt
}
}
Schritt 2 — Die Hooks mit echter Logik füllen
Du überschreibst nur, was du brauchst: onApply (einmalig beim Start),
onTick (jeden Tick), onRemove (am Ende). Innen volle
Bukkit-Freiheit — hier setzen wir das Entity tatsächlich in Brand, kein Vanilla-Trank:
@Override public void onTick(LivingEntity entity) {
entity.setFireTicks(20); // hält das Feuer am Brennen, solange der Effekt läuft
entity.getWorld().spawnParticle(Particle.FLAME,
entity.getLocation().add(0, 1, 0), 5, 0.2, 0.3, 0.2, 0.01);
}
@Override public void onRemove(LivingEntity entity) {
entity.setFireTicks(0); // beim Ablauf sauber löschen
}
Schritt 3 — Den Effekt von einer Component aus auslösen
Effekte legt man per id über die statische EffectEngine.apply(...) auf — von
überall (Boss, Mob, Ability, Item). Hier aus dem onHit-Hook einer
Boss-Component:
public final class FrostImpIgnite extends BossComponent {
@Override
public void onHit(BossRuntime runtime, LivingEntity victim, double damage) {
EffectEngine.apply(victim, "burning", 100); // id + Dauer in Ticks
}
}
Mit .component(new FrostImpIgnite()) im Builder hängst du den Hook an den Boss.
Die "burning"-Datei wird automatisch entdeckt — kein Verdrahten nötig. Solange
der Effekt aktiv ist, zeigt die Engine dem Spieler automatisch
🔥 Burning 5s über der Hotbar (mehr zur Anzeige im
Effekt-System).
Tutorial: Eine eigene Component bauen
Eine BossComponent ist der Weg zu beliebigem Boss-Verhalten ohne
Engine-Edits. Du erbst, wählst einen Hook und schreibst rohes Bukkit hinein.
Schritt 1 — Erben & einen Hook wählen
Es gibt sechs Hooks — onSpawn, onTick, onHit,
onDamaged, onIncomingDamage, onDeath (alle im
Hook-Kapitel erklärt). Überschreibe nur, was du brauchst.
Schritt 2 — Reagieren: onHit
Der einfachste Fall ist reagieren. IceTitanFrostbite legt beim Treffer
einen Effekt auf das Opfer:
public final class IceTitanFrostbite extends BossComponent {
@Override
public void onHit(BossRuntime runtime, LivingEntity victim, double damage) {
EffectEngine.apply(victim, "frostbite", 60);
}
}
Schritt 3 — Eingreifen: onIncomingDamage
Components können auch in die Schadens-Pipeline eingreifen.
IceTitanWard reduziert jeden eingehenden Schaden um 20 %:
public final class IceTitanWard extends BossComponent {
@Override
public void onIncomingDamage(BossRuntime runtime, EntityDamageEvent event) {
event.setDamage(event.getDamage() * 0.8); // oder event.setCancelled(true)
}
}
Schritt 4 — Dein eigenes Mini-Beispiel: onTick mit Partikeln
Jetzt etwas Neues: eine Component, die jeden Tick einen rotierenden Partikelring um den Boss legt. Sie zeigt, dass im Hook das ganze Bukkit-API offen liegt — Entity, Welt, Mathe:
public final class SwirlingAura extends BossComponent {
private double angle = 0;
@Override
public void onTick(BossRuntime runtime) {
angle += Math.PI / 16; // jeden Tick ein Stück weiter
var entity = runtime.entity();
Location center = entity.getLocation().add(0, 1, 0);
for (int i = 0; i < 6; i++) {
double a = angle + (Math.PI / 3) * i; // 6 Punkte gleichmäßig im Kreis
Location p = center.clone().add(Math.cos(a) * 1.5, 0, Math.sin(a) * 1.5);
entity.getWorld().spawnParticle(Particle.SOUL_FIRE_FLAME, p, 1, 0, 0, 0, 0);
}
}
}
Anhängen wie jede Component — .component(new SwirlingAura()). Eine Component
darf eigenen Zustand halten (hier angle), weil pro Boss eine Instanz existiert.
Kein NMS, kein Mixin. Was du in Fabric per Mixin in den Damage-/Tick-Pfad hängen würdest, schreibst du hier in einen Hook mit rohem Bukkit — in einer kleinen, wiederverwendbaren Klasse.
Ein Item hinzufügen
Items sind einfacher Content. Du erweiterst CustomItem und beschreibst
das Item in register() über ein flüssiges DSL. Das ist alles —
keine Registrierung, kein Engine-Code.
public final class FlameBlade extends CustomItem {
@Override protected void register() {
setId("flame_blade").material(Material.NETHERITE_SWORD)
.displayName(Component.text("Flame Blade")).model(1).onUse("flame_blade");
}
}
setId(...)— die eindeutige id, mit der überall auf das Item verwiesen wird.material(...)— das Vanilla-Basismaterial.displayName(...)— der angezeigte Name (AdventureComponent).model(...)— die CustomModelData für deine Textur im Resource-Pack.onUse("flame_blade")— verknüpft das Item mit einer Ability über deren id.
Das Item kennt die Ability-Klasse nicht — es nennt nur ihre id ("flame_blade").
Die Engine löst die Referenz zur Laufzeit auf. So bleiben Inhalte entkoppelt.
Eine Crate hinzufügen
Crates sind ebenfalls einfacher Content. Du erweiterst CustomCrate.
Rarity, Key, Anzeige, Einmal-Nutzung und Loot werden vollständig im Builder beschrieben.
public final class DragonCrate extends CustomCrate {
@Override protected void register() {
setId("dragon").rarity(Rarity.LEGENDARY)
.key("<gold>Dragon Key", Material.TRIPWIRE_HOOK, 13) // oder .keyless()
.display(DisplayKind.ITEM, Material.PAPER, 22, 1.2f)
.singleUse(SingleUse.PER_PLAYER) // none | per_player | global
.loot(LootEntry.weightedItem("flame_blade", 5, 1));
}
}
key(name, material, model)— definiert den benötigten Schlüssel. Alternativ.keyless()für schlüssellose Crates.display(kind, material, model, scale)— wie die Crate in der Welt dargestellt wird.singleUse(...)—NONE,PER_PLAYERoderGLOBAL.loot(...)— gewichtete Loot-Einträge; verweist per id auf andere Inhalte (hier"flame_blade").
Rarity ist niemals auf feste Stufen beschränkt
Es gibt keine harte Liste aus drei Tiers. Du nutzt vorhandene Raritäten oder baust per Builder deine eigene — mit eigenem Namen und eigener Farbe:
// Eigene Rarity — niemals auf 3 Tiers beschränkt:
Rarity MYTHIC = Rarity.builder("mythic")
.nameColor(NamedTextColor.LIGHT_PURPLE)
.build();
Einen Boss bauen
Die Boss-Engine ist fertig und auf Fabric-Niveau: Ein Boss wird
komplett deklarativ über den fluenten BossBuilder beschrieben — Aussehen,
Ausrüstung, Attribute, Immunitäten, Partikel, Sounds, Selbst-Effekte, Loot, Bossbar,
Phasen — und alles, was kein eingebauter Regler abdeckt, erledigt eine eigene
BossComponent mit vollem Bukkit-Zugriff. Kein
NMS, kein Mixin nötig: was Fabric per Mixin macht, machst du hier mit rohem
Bukkit in einem Hook.
Die Einstiegsklasse implementiert Content, ruft
BossBuilder.create("id"), hängt alle Regler an und schließt mit
.register() ab. Sie wird beim Start automatisch gefunden
(ContentBootstrap) — null Engine-Edits, merge-frei.
public final class IceTitan implements Content {
@Override public void register() {
BossBuilder.create("ice_titan")
.displayName("<aqua><bold>Ice Titan")
.baseEntity(EntityType.STRAY)
.scale(1.6).glowing(true)
.health(new IceTitanHealth()) // Pflicht: ohne Health wirft register()
.loot(new IceTitanLoot())
.component(new IceTitanFrostbite()) // eigener Hook: on-hit Frostbite
.register();
}
}
register() wirft eine IllegalStateException, wenn keine
HealthComponent gesetzt wurde. Jeder andere Regler ist optional — fehlt er,
gilt der Vanilla-Standard.
Der Boss enthält keine Spiellogik. Er beschreibt nur seine Bestandteile.
HP und Regeneration leben in einer HealthComponent, die Drops in einer
LootComponent — beide haben genau eine Verantwortung und sind
wiederverwendbar:
public final class IceTitanHealth extends HealthComponent {
@Override public double maxHealth() { return 300; }
@Override public double regenerationPerSecond() { return 2; }
}
public final class IceTitanLoot extends LootComponent {
@Override public void build(LootBuilder loot) {
loot.dropVanilla(Material.DIAMOND, 1.0, 3, 6)
.dropVanilla(Material.PACKED_ICE, 1.0, 8, 16)
.dropItem("flame_blade", 0.5, 1, 1); // CUSTOM-Item per id
}
}
Die komplette Regler-Tabelle
Jede Methode des BossBuilder — gruppiert. Alle geben den Builder zurück, sind
also frei verkettbar. Was hier fehlt, baust du als Component.
Aussehen
displayName(String mini)— Anzeigename als MiniMessage (z. B."<aqua><bold>Ice Titan").displayName(Component c)— Anzeigename als fertige Adventure-Component.baseEntity(EntityType type)— die Vanilla-Mob-Basis (StandardZOMBIE).glowing(boolean)— Glow-Outline an/aus.scale(double)— Körpergröße über das 1.21-SCALE-Attribut (z. B.1.6= 60 % größer).
Ausrüstung (was der Boss trägt/hält)
Jeder Slot nimmt entweder einen ItemStack oder die id eines Custom-Items (zur Spawn-Zeit aufgelöst).
equip(EquipmentSlot slot, ItemStack)/equip(EquipmentSlot slot, String customItemId)— generischer Slot-Setter.mainHand(ItemStack)/mainHand(String id)— Haupthand.offHand(ItemStack)— Nebenhand.helmet/chestplate/leggings/boots(ItemStack)— Rüstungsslots.equipmentDropChance(float)— Chance 0..1, dass jedes getragene Teil beim Tod droppt.
Attribute
Generisch jedes Minecraft-Attribut, plus benannte Bequemlichkeits-Setter:
attribute(Attribute, double)— beliebiges Attribut auf einen Basiswert.movementSpeed(double),knockbackResistance(double),attackDamage(double),attackKnockback(double),followRange(double),armor(double),armorToughness(double).
Entity-Flags
silent(boolean)— keine Vanilla-Geräusche.gravity(boolean)— Schwerkraft an/aus.ai(boolean)— Mob-KI aktiv/inaktiv.invulnerable(boolean)— unverwundbar.collidable(boolean)— Kollision mit anderen Entities.fireTicks(int)— Brenn-Ticks beim Spawn (-1= keine).
FX & Sounds
ambientParticle(Particle, int count)— dauerhaftes Umgebungs-Partikel.spawnSound(String),deathSound(String),hurtSound(String),ambientSound(String)— Sound-Keys (z. B."minecraft:entity.wither.spawn").
Selbst-Effekte
effect(PotionEffect...)— Trank-Effekte, die der Boss beim Spawn auf sich selbst erhält (z. B. dauerhafte Resistenz).
Immunität
immuneTo(DamageCause...)— Schadensarten, gegen die der Boss komplett immun ist (z. B.FREEZE,DROWNING,FALL).
Drops & XP
xpDrop(int)— fallengelassene Erfahrung (-1= Vanilla).clearVanillaDrops(boolean)— Vanilla-Drops unterdrücken (nur deinLootComponentzählt).
Bossbar
bossBar(boolean show)— Bossbar ein-/ausblenden (Standard an).bossBarColor(BossBar.Color)— Farbe der Bossbar (StandardRED).
Alle Regler als Einzeiler
Zum schnellen Abgucken — ein Beispielaufruf pro Regler. Alles ist frei verkettbar:
BossBuilder.create("demo")
// Aussehen
.displayName("<red><bold>Demo Boss")
.baseEntity(EntityType.WITHER_SKELETON)
.glowing(true)
.scale(1.8)
// Ausrüstung
.mainHand("flame_blade") // Custom-Item per id
.offHand(new ItemStack(Material.SHIELD))
.helmet(new ItemStack(Material.NETHERITE_HELMET))
.chestplate(new ItemStack(Material.NETHERITE_CHESTPLATE))
.leggings(new ItemStack(Material.NETHERITE_LEGGINGS))
.boots(new ItemStack(Material.NETHERITE_BOOTS))
.equip(EquipmentSlot.HAND, new ItemStack(Material.STICK))
.equipmentDropChance(0.1f)
// Attribute
.attribute(Attribute.MAX_HEALTH, 200) // beliebiges Attribut generisch
.movementSpeed(0.3)
.knockbackResistance(0.8)
.attackDamage(10)
.attackKnockback(1.5)
.followRange(40)
.armor(10)
.armorToughness(4)
// Entity-Flags
.silent(true)
.gravity(true)
.ai(true)
.invulnerable(false)
.collidable(true)
.fireTicks(0)
// FX & Sounds
.ambientParticle(Particle.SMOKE, 6)
.spawnSound("minecraft:entity.wither.spawn")
.deathSound("minecraft:entity.wither.death")
.hurtSound("minecraft:entity.wither_skeleton.hurt")
.ambientSound("minecraft:entity.wither.ambient")
// Selbst-Effekte
.effect(new PotionEffect(PotionEffectType.FIRE_RESISTANCE, Integer.MAX_VALUE, 0))
// Immunität
.immuneTo(DamageCause.FIRE, DamageCause.LAVA, DamageCause.FALL)
// Drops & XP
.xpDrop(80)
.clearVanillaDrops(true)
// Bossbar
.bossBar(true)
.bossBarColor(BossBar.Color.PURPLE)
// Verhalten
.health(new MyHealth()) // Pflicht
.loot(new MyLoot())
.abilities("ice_storm")
.component(new MyComponent())
.phase(0.5, rt -> { /* ... */ })
.register();
Verhalten: Components & Phasen
health(HealthComponent)— Pflicht. HP-Regeln.loot(LootComponent)— Drop-Tabelle (vom Engine beim Tod gerollt).spawn(SpawnComponent)— Spawn-Regeln.ai(AIComponent)— eigenes KI-Verhalten (überladen gegenüberai(boolean)).abilities(String... ids)— Abilities per id verknüpfen.component(BossComponent... comps)— beliebig viele Hook-Components (siehe unten).phase(double atHpFraction, PhaseAction)— Phasenwechsel bei einem HP-Anteil 0..1.
Components & Hooks — der Fabric-Level-Teil
Eine BossComponent ist der Schlüssel zu unbegrenztem Boss-Verhalten
ohne Engine-Edits. Ein Boss trägt beliebig viele davon
(.component(a, b, c)); die Engine ruft den passenden Hook auf allen
auf. Du überschreibst nur die Hooks, die du brauchst, und darfst innen alles
tun: Trank-Effekte, Partikel, Blöcke, Sounds, Summons, Scheduler, rohes Bukkit.
Neue Fähigkeit (Freeze-on-hit, Schild, Enrage, Adds spawnen, Wetter …) =
eine neue Subklasse. Die Engine ändert sich nie.
BossComponent spezialisiert den geteilten
EntityComponent<BossRuntime> — dieselben Hooks
nutzt eine MobComponent (mit MobRuntime). Was du hier lernst, gilt
1:1 für Mobs.
Die Hooks
onSpawn(BossRuntime rt)— direkt nach dem Spawn.onTick(BossRuntime rt)— jeden Boss-Tick.onHit(BossRuntime rt, LivingEntity victim, double damage)— der Boss hat etwas getroffen (für On-Hit-Effekte).onDamaged(BossRuntime rt, LivingEntity attacker, double damage)— der Boss hat Schaden bekommen (attackerkannnullsein).onIncomingDamage(BossRuntime rt, EntityDamageEvent event)— eingehender Schaden bevor er angewendet wird; die ganze Schadens-Pipeline. Mitevent.setDamage(...)abschwächen/verstärken oder mitevent.setCancelled(true)blocken. Feuert für jede Schadensart.onDeath(BossRuntime rt)— der Boss ist gestorben (bevor Loot gerollt wird).
In jedem Hook erreichst du das lebende Bukkit-Entity über rt.entity() — ab da
ist die volle Bukkit-API offen.
Beispiel 1 — On-Hit-Effekt (reagieren)
Wenn der Ice Titan etwas trifft, legt er den geteilten "frostbite"-Effekt auf
das Opfer. Zwei Engine-Ideen auf einmal: der offene Hook und das
wiederverwendbare Effekt-System:
public final class IceTitanFrostbite extends BossComponent {
@Override
public void onHit(BossRuntime runtime, LivingEntity victim, double damage) {
EffectEngine.apply(victim, "frostbite", 60);
}
}
Beispiel 2 — Die Schadens-Pipeline verändern (eingreifen)
Components reagieren nicht nur — sie greifen in die Pipeline ein. Diese hier reduziert jeden eingehenden Schaden um 20 %:
public final class IceTitanWard extends BossComponent {
@Override
public void onIncomingDamage(BossRuntime runtime, EntityDamageEvent event) {
event.setDamage(event.getDamage() * 0.8);
}
}
Es gibt keine gesonderte „Boss-Mechanik-API", die dich einschränkt. Im Hook hast du das Entity, die Welt, den Scheduler — also alles. Genau das, was du in Fabric per Mixin tätest, schreibst du hier in eine kleine, wiederverwendbare Component.
Phasen
Eine Phase feuert einmal, sobald der Boss auf (oder unter) einen
HP-Anteil seines Maximums fällt. .phase(0.5, rt -> {...}) heißt „bei der
Hälfte". Die PhaseAction ist völlig frei — den Boss buffen, Adds spawnen,
Partikel umstellen, broadcasten, eine Ability zünden:
BossBuilder.create("ice_titan")
// ...
.phase(0.5, rt -> { // Enrage bei halber HP
rt.entity().getWorld().strikeLightningEffect(rt.entity().getLocation());
rt.entity().addPotionEffect(
new PotionEffect(PotionEffectType.SPEED, Integer.MAX_VALUE, 1));
})
.register();
Das geteilte Effekt-System
Statuseffekte sind nicht boss-spezifisch — es ist ein eigenes, geteiltes
System, nutzbar von Boss, Mob, Ability und Item. Ein Effekt liegt unter
content/effects/<id>/: eine Klasse extends CustomEffect, die
in register() ihre Metadaten deklariert und die Hooks überschreibt, die sie
braucht — onApply, onTick (jeden Tick), onRemove.
Die register()-DSL
setId(String)— die eindeutige id, mit der der Effekt angewendet wird.defaultDuration(int ticks)— Standarddauer, fallsapply()0 übergibt.setDisplayName(String mini)— der Name, der dem Spieler angezeigt wird (MiniMessage, mit eigener Farbe/Logo).setPanelCarrier(PotionEffectType)— optional: ein Vanilla-Effekt als „Träger", damit der Effekt im Inventar-Panel auftaucht (siehe unten).
Nicht bloß addPotionEffect(vanillaEffekt). Du hast die volle Bukkit-API:
Attribute, Partikel, Blöcke, Sounds, Scheduler. Frostbite z. B. nutzt
kein Vanilla-Potion, sondern verlangsamt direkt über einen
MOVEMENT_SPEED-AttributeModifier.
Beispiel: Frostbite (echter Effekt, kein Vanilla-Trank)
Frostbite senkt das Tempo um 30 % über einen eigenen AttributeModifier
mit eigenem NamespacedKey — in onApply hinzugefügt, in
onRemove wieder entfernt — und sprüht jeden Tick Schnee:
public final class Frostbite extends CustomEffect {
private static final NamespacedKey SLOW_KEY =
new NamespacedKey("lumora", "effect_frostbite_slow");
private static final double SLOW_FACTOR = -0.30; // -30% movement speed
@Override protected void register() {
setId("frostbite");
setDisplayName("<aqua>❄ Frostbite");
setPanelCarrier(PotionEffectType.UNLUCK); // Inventar-Panel; im RP umskinnen
defaultDuration(60);
}
@Override public void onApply(LivingEntity entity) {
AttributeInstance speed = entity.getAttribute(Attribute.MOVEMENT_SPEED);
if (speed == null) return;
removeSlow(speed); // gegen stale Modifier absichern
speed.addModifier(new AttributeModifier(
SLOW_KEY, SLOW_FACTOR, AttributeModifier.Operation.MULTIPLY_SCALAR_1));
}
@Override public void onTick(LivingEntity entity) {
entity.getWorld().spawnParticle(Particle.SNOWFLAKE,
entity.getLocation().add(0, 1, 0), 3, 0.2, 0.3, 0.2, 0.01);
}
@Override public void onRemove(LivingEntity entity) {
AttributeInstance speed = entity.getAttribute(Attribute.MOVEMENT_SPEED);
if (speed != null) removeSlow(speed);
}
private static void removeSlow(AttributeInstance speed) {
for (AttributeModifier m : new ArrayList<>(speed.getModifiers())) {
if (m.getKey().equals(SLOW_KEY)) speed.removeModifier(m);
}
}
}
Anwenden lässt sich der Effekt von überall per id über die statische
EffectEngine.apply(...) — aus einem Boss-/Mob-Hook, einer Ability, einem Item:
// entity, effekt-id, dauer in Ticks (<= 0 ⇒ Standarddauer des Effekts)
EffectEngine.apply(victim, "frostbite", 60);
Anzeige — zwei Wege
Ein Spieler sieht einen aktiven Effekt auf zwei Arten, die sich ergänzen:
-
Actionbar (automatisch, ohne Resourcepack). Die Engine zeigt jeden
aktiven Effekt über der Hotbar als
displayName + " Ns"(Restsekunden), mehrere durch Abstand getrennt — z. B.❄ Frostbite 3s. Dafür musst du nichts tun außersetDisplayName(...). -
Inventar-Panel (optional, via
setPanelCarrier). Setzt du einen Carrier, legt die Engine für die Dauer diesen Vanilla-Effekt (still, mit Icon) auf den Spieler — so erscheint ein Eintrag mit Icon + Countdown im Effekt-Panel des Inventars. Das Resourcepack reskinnt den Carrier, damit er als dein Effekt liest.
Für setPanelCarrier(PotionEffectType.UNLUCK) überschreibst du im RP:
- Icon:
assets/minecraft/textures/mob_effect/unluck.png(18×18) - Name (lang):
"effect.minecraft.unluck": "❄ Frostbite"
Die mob_effect-Registry wird nicht zum Client gesynct
(nur z. B. Enchantments/Biomes werden übertragen). Ein server-seitig registrierter
„echter" Custom-Effekt kann auf einem Vanilla-Client also gar nicht gerendert werden.
Deshalb: für die Logik dein CustomEffect, für die Panel-Darstellung
ein vorhandener Vanilla-Effekt als Träger + Resourcepack-Reskin — kein Client-Mod nötig.
Quelle:
docs.papermc.io/paper/dev/registries.
Die Engine hält das Register aller CustomEffects und die aktiven Effekte
pro Entity, tickt jeden, zeigt ihn an und entfernt ihn (inkl. Carrier), wenn die Dauer
abläuft. Derselbe "frostbite"-Effekt dient dem Boss-On-Hit, dem Mob, einer
Ability und einem Item — ohne Code zu kopieren.
Rezepte (Cookbook)
Kleine, fertige Schnipsel für häufige Wünsche. Jeder steckt in die
BossBuilder.create(...)-Kette (bzw. in einen Hook).
Boss unsichtbar machen
Kein eigener Builder-Regler — per Selbst-Effekt INVISIBILITY:
.effect(new PotionEffect(PotionEffectType.INVISIBILITY, Integer.MAX_VALUE, 0, true, false))
Boss immun gegen Feuer
.immuneTo(DamageCause.FIRE, DamageCause.FIRE_TICK, DamageCause.LAVA)
Custom-Item in die Hand geben
.mainHand("flame_blade") // per id (Custom-Item)
.offHand(new ItemStack(Material.SHIELD)) // oder ein roher ItemStack
XP-Drop setzen & Vanilla-Drops abschalten
.xpDrop(50)
.clearVanillaDrops(true) // nur dein LootComponent zählt
Eigener Hurt-Sound
.hurtSound("minecraft:entity.stray.hurt")
Riesig & leuchtend, mit Umgebungs-Partikeln
.scale(2.5)
.glowing(true)
.ambientParticle(Particle.SNOWFLAKE, 8)
Bei halber HP zornig werden
.phase(0.5, rt -> {
rt.entity().getWorld().strikeLightningEffect(rt.entity().getLocation());
rt.entity().addPotionEffect(
new PotionEffect(PotionEffectType.SPEED, Integer.MAX_VALUE, 1));
})
Beim Tod etwas tun (Hook)
public final class VictoryBroadcast extends BossComponent {
@Override public void onDeath(BossRuntime runtime) {
runtime.entity().getWorld().getPlayers().forEach(p ->
p.sendMessage(Component.text("Der Boss ist besiegt!")));
}
}
Effekt im Inventar-Panel anzeigen
In register() deines CustomEffect einen Vanilla-Carrier setzen —
dann im Resourcepack dessen Icon + Namen überschreiben:
setPanelCarrier(PotionEffectType.UNLUCK); // Träger fürs Inventar-Panel
assets/minecraft/textures/mob_effect/unluck.png # 18x18 Icon
# + lang-Eintrag: "effect.minecraft.unluck": "❄ Frostbite"
Vollbeispiel: Ice Titan
Der kanonische Referenz-Boss als ein zusammenhängender Block — er zeigt nahezu die gesamte Builder-Oberfläche, zwei Components (reagieren und eingreifen), eine Phase und das geteilte Effekt-System. Null Engine-Code.
public final class IceTitan implements Content {
@Override
public void register() {
BossBuilder.create("ice_titan")
.displayName("<aqua><bold>Ice Titan")
.baseEntity(EntityType.STRAY)
.glowing(true)
.scale(1.6)
.health(new IceTitanHealth())
.loot(new IceTitanLoot())
.component(new IceTitanFrostbite()) // on-hit freeze effect
.component(new IceTitanWard()) // -20% incoming damage (pipeline)
.phase(0.5, rt -> { // enrage at half health
rt.entity().getWorld().strikeLightningEffect(rt.entity().getLocation());
rt.entity().addPotionEffect(new PotionEffect(PotionEffectType.SPEED, Integer.MAX_VALUE, 1));
})
.mainHand("flame_blade") // holds a CUSTOM item
.helmet(new ItemStack(Material.DIAMOND_HELMET))
.equipmentDropChance(0.15f)
.movementSpeed(0.28).knockbackResistance(0.6).attackDamage(8).armor(8)
.xpDrop(50).clearVanillaDrops(true)
.immuneTo(DamageCause.FREEZE, DamageCause.DROWNING, DamageCause.FALL)
.ambientParticle(Particle.SNOWFLAKE, 8)
.spawnSound("minecraft:entity.wither.spawn")
.deathSound("minecraft:entity.wither.death")
.hurtSound("minecraft:entity.stray.hurt")
.effect(new PotionEffect(PotionEffectType.RESISTANCE, Integer.MAX_VALUE, 0, true, false))
.bossBarColor(BossBar.Color.BLUE)
.register();
}
}
Kein if (id.equals("ice_titan")) { ... } irgendwo in der Engine. Die Engine
darf keinen konkreten Inhalt kennen. Der Builder entscheidet — nicht die Engine.
Einen Mob bauen
Ein Mob ist der leichtere Zwilling eines Bosses: gebaut mit
MobBuilder auf demselben Entity-Core, nur
ohne Bossbar und ohne Phasen. Der ganze Aussehen-/Attribute-/Flags-/FX-/
Immunitäts-/Drops-Teil ist identisch zum Boss (er kommt aus
EntityBuilder) — du nutzt also dieselben Methoden, die du oben schon kennst.
Was ein Mob zusätzlich hat
Gegenüber dem geteilten Entity-Core bringt MobBuilder nur diese Extras mit:
maxHealth(double)— die HP (setzt dasMAX_HEALTH-Attribut; Standard 20). Kein separatesHealthComponentnötig.abilities(String... ids)— Abilities per id, die der Mob rotierend wirkt.abilityInterval(int ticks)— Abstand zwischen zwei Ability-Casts (Standard 80 Ticks).loot(LootComponent)— Drop-Tabelle (dieselbeLootComponent, die auch Bosse nutzen).component(MobComponent... comps)— Hook-Components (gleiche Hooks wie Boss).
Alles andere — displayName, baseEntity, scale,
movementSpeed, attackDamage, immuneTo,
ambientParticle, hurtSound, xpDrop,
effect, … — ist exakt die Boss-Oberfläche aus der
Regler-Tabelle. Es gibt nur kein
bossBar()/bossBarColor() und kein
phase().
Eine Mob-Component
Eine MobComponent hat dieselben Hooks wie eine
BossComponent (beide spezialisieren EntityComponent) — nur ist der
Runtime-Typ MobRuntime. Authoring 1:1 wie beim Boss:
public final class FrostwolfChill extends MobComponent {
@Override
public void onHit(MobRuntime runtime, LivingEntity victim, double damage) {
EffectEngine.apply(victim, "frostbite", 40); // derselbe geteilte Effekt wie beim Boss
}
}
Hooks, Loot, Effekte, Immunitäten, FX — alles, was du fürs Boss-Authoring gelernt hast,
gilt 1:1 für Mobs. Der einzige Unterschied im Code: MobBuilder /
MobComponent / MobRuntime statt der Boss-Pendants, plus
maxHealth(...) statt eines HealthComponent.
Vollbeispiel: Frostwolf
Der Referenz-Mob als zusammenhängender Block — gebaut mit derselben Builder-Oberfläche wie
ein Boss (minus Bossbar/Phasen), mit Loot und einer On-Hit-Component, die den
geteilten "frostbite"-Effekt nutzt. Eigener Ordner
content/mobs/frostwolf/.
public final class Frostwolf implements Content {
@Override
public void register() {
MobBuilder.create("frostwolf")
.displayName("<aqua>Frostwolf")
.baseEntity(EntityType.WOLF)
.maxHealth(40)
.movementSpeed(0.35).attackDamage(5)
.immuneTo(DamageCause.FREEZE)
.ambientParticle(Particle.SNOWFLAKE, 4)
.hurtSound("minecraft:entity.wolf.hurt")
.deathSound("minecraft:entity.wolf.death")
.xpDrop(10)
.loot(new FrostwolfLoot())
.component(new FrostwolfChill()) // on-hit frostbite — same shared effect a boss uses
.register();
}
}
Die Loot-Component ist dieselbe Klasse, die auch ein Boss benutzt:
public final class FrostwolfLoot extends LootComponent {
@Override public void build(LootBuilder loot) {
loot.dropVanilla(Material.BONE, 1.0, 1, 3)
.dropVanilla(Material.PACKED_ICE, 0.5, 1, 2);
}
}
Eine Ability hinzufügen
Abilities sind der Ort für vollständig freie Logik. Du erweiterst eine
Ability-Basis und überschreibst execute(...) bzw. cast(...). Hier
ist alles erlaubt: Partikel, Raytraces, Scheduler, eigene Mechaniken. Die Engine schränkt
dich nicht ein.
public final class IceStorm extends Ability {
@Override public void register() {
setId("ice_storm").cooldown(8.0).manaCost(40);
}
@Override public void cast(AbilityContext ctx) {
Player caster = ctx.caster();
Location origin = caster.getEyeLocation();
// Raytrace nach vorne
RayTraceResult hit = caster.getWorld().rayTraceEntities(
origin, origin.getDirection(), 25.0, e -> e != caster);
// Partikel-Spirale + Schaden über einen Scheduler
ctx.scheduler().repeat(0L, 2L, 30, tick -> {
double radius = tick * 0.3;
for (int i = 0; i < 8; i++) {
double angle = (Math.PI / 4) * i + tick * 0.2;
Location p = origin.clone().add(
Math.cos(angle) * radius, tick * 0.1, Math.sin(angle) * radius);
caster.getWorld().spawnParticle(Particle.SNOWFLAKE, p, 3, 0, 0, 0, 0.01);
}
});
if (hit != null && hit.getHitEntity() instanceof LivingEntity target) {
target.damage(14.0, caster);
target.setFreezeTicks(120);
}
}
}
Diese Ability heißt "ice_storm" — genau die id, die der Boss oben unter
.abilities("ice_storm") nennt und ein Item unter .onUse(...) nutzen
könnte. Inhalte verweisen aufeinander nur per id, niemals per direkter Klassenreferenz.
Troubleshooting & FAQ
Mit hoher Wahrscheinlichkeit fehlt die HealthComponent —
register() wirft dann eine IllegalStateException
(„missing a HealthComponent — call .health(...)"). Setze
.health(new DeinHealth()). Prüfe danach mit
/boss list bzw. /lumora content verify, ob die id gelistet wird.
Direkt im Spiel: /boss spawn <id> (Permission
lumora.boss.admin) bzw. /mob spawn <id> (Permission
lumora.mob.admin) — beide mit Tab-Vervollständigung. /boss list /
/mob list zeigen alle ids + die aktive Anzahl. Programmatisch:
BossAPI.spawn(id, location) bzw. MobEngine.bound().spawn(id, location).
Meist passt die id nicht: Der String in
EffectEngine.apply(entity, "id", ticks) muss exakt der
setId(...)-Wert im CustomEffect sein. Außerdem muss die
Effekt-Klasse unter content/effects/<id>/ liegen und einen
no-arg-Konstruktor haben, sonst wird sie nicht entdeckt. Check via
/lumora content verify, ob der Effekt unter der effect-Engine auftaucht.
Drei Klassiker, in dieser Reihenfolge: (1) Hast du ./gradlew deploy
laufen lassen? (2) Hast du den Server neu gestartet (kein
/reload)? (3) Bei yml-Änderungen: alte
plugins/Lumora/*.yml löschen, damit sie frisch erzeugt werden.
/lumora content verify (Alias /lum) listet jede entdeckte
Content-id pro Engine. Taucht deine id dort auf, hat die Auto-Discovery sie gefunden und
registriert.
Nein. Der Bootstrap registriert nur Klassen, die eine Content-Kategorie erfüllen
(implements Content, extends CustomEffect, …). Reine
Hilfsklassen wie IceTitanHealth werden bewusst übersprungen — sie werden von
deinem Content instanziiert, nicht vom Bootstrap.
NPCs & mehr in Arbeit
Bosse und Mobs sind fertig und teilen sich den
Entity-Core. Weitere Entity-Typen (z. B. NPCs, Pets)
entstehen nach exakt demselben Muster — ein Content-Einstieg, ein fluenter
Builder, der von EntityBuilder erbt, und offene Hook-Components über
EntityComponent. Solange sie noch gebaut werden, dokumentieren wir bewusst
keine NPC-/Pet-API, um nichts Falsches zu versprechen. Das Boss-/Mob-Authoring
unten zeigt aber schon genau, wie es aussehen wird.
Layer B: Engine-Interna (nur zur Einordnung)
Damit du verstehst, was unter der Haube passiert — du musst das nicht anfassen: Jede Engine folgt demselben internen Stack. Dein Builder-Aufruf wandert da hindurch:
- Definition = unveränderliche Daten (das „Rezept" deines Inhalts).
- Runtime = lebendiger Zustand pro Instanz (z. B. der konkrete Boss in der Welt).
- Kommunikation läuft über einen EventBus (publish /
@Subscribe) — Engines bleiben entkoppelt.
Du rufst BossBuilder.create(...) auf und bist fertig. Definition, Registry,
Factory und Runtime entstehen automatisch im Hintergrund.
Die goldenen Regeln
Halte dich an diese Prinzipien, dann bleibt dein Content sauber und die Engine stabil:
Kein if (id.equals("ice_titan")) in der Engine. Konkrete Inhalte tauchen
ausschließlich in content/ auf.
Du sagst was existiert. Das wie ist Sache der Engine.
Brauchst du dieselbe Logik zweimal, wird sie zur wiederverwendbaren Component — nicht zu kopiertem Code. Wenn du oft kopieren musst, fehlt der Engine vermutlich eine Funktion.
Health, Loot, Spawn, AI — jede Component macht genau eine Sache.
Eigene ids, eigene Raritäten, eigene Texturen, vollständig eigenes Verhalten in Abilities. Die Engine vereinfacht — sie beschränkt deine Kreativität nicht.
„Ist das Engine oder Content?" Ist die Antwort nicht sofort klar, überdenke den
Code noch einmal. Im Zweifel: Es ist Content, und es gehört nach content/.