Developers
Plugin API
RP Engine has a Java API, so your own Spigot or Paper plugin can use what a server's pack holds: give a custom item, place a model, play an emote, park a vehicle, spawn a custom mob, show a HUD, open a dialog with your own values on it, and cancel any of those when your rules say no.
Every snippet is shown in Java and Kotlin; pick one and the rest of the docs follows your choice. This page matches RP Engine 0.2.59.
Setting up#
RP Engine is not published to a Maven repository. Download RPEngine.jar from
resourcepack.ai/rp-engine, put it in your
project's libs/ folder and compile against it. Use compileOnly: the server
already has the plugin, and a shaded second copy breaks class loading.
dependencies {
compileOnly 'org.spigotmc:spigot-api:1.21.4-R0.1-SNAPSHOT'
compileOnly files('libs/RPEngine.jar')
}dependencies {
compileOnly("org.spigotmc:spigot-api:1.21.4-R0.1-SNAPSHOT")
compileOnly(files("libs/RPEngine.jar"))
}RP Engine runs on Java 17 and Minecraft 1.19.4 or newer, so target Java 17 if you want your plugin to load everywhere the engine does.
In your plugin.yml, tell the server to load RP Engine first:
depend: [RPEngine] # your plugin can't run without it
# or
softdepend: [RPEngine] # optional: check it is there before using itGetting the API#
Everything hangs off the plugin instance. Fetch it in onEnable:
if (!(Bukkit.getPluginManager().getPlugin("RPEngine") instanceof RPEnginePlugin engine)) {
getLogger().info("RP Engine isn't installed"); // only possible with softdepend
return;
}
engine.items(); // custom items
engine.models(); // models, and the ones standing in your worlds
engine.emotes(); // emotes and movement sets
engine.vehicles(); // vehicles, and the ones standing in your worlds
engine.shaderObjects(); // 3D shader objects: place them and drive them
engine.armor(); // 3D armour sets, and putting them on people
engine.sounds(); // custom sounds
engine.music(); // background music: playlists, one per player
engine.icons(); // icons, and putting one into text
engine.textEffects(); // Text Effects, and writing text in one
engine.vfx(); // VFX: play one where somebody stands
engine.mobs(); // custom mobs: spawn one, animate one, give an egg
engine.skills(); // mob skills: add a mechanic, condition or targeter; cast one
engine.overlays(); // HUD overlays: show one, hide one, feed it values
engine.dialogs(); // dialogs: open one, feed it values, read player settings
engine.registry(); // everything this server holds, by id
engine.registration(); // adding content of your ownval engine = Bukkit.getPluginManager().getPlugin("RPEngine") as? RPEnginePlugin
?: return logger.info("RP Engine isn't installed") // only possible with softdepend
engine.items() // custom items
engine.models() // models, and the ones standing in your worlds
engine.emotes() // emotes and movement sets
engine.vehicles() // vehicles, and the ones standing in your worlds
engine.shaderObjects() // 3D shader objects: place them and drive them
engine.armor() // 3D armour sets, and putting them on people
engine.sounds() // custom sounds
engine.music() // background music: playlists, one per player
engine.icons() // icons, and putting one into text
engine.textEffects() // Text Effects, and writing text in one
engine.vfx() // VFX: play one where somebody stands
engine.mobs() // custom mobs: spawn one, animate one, give an egg
engine.skills() // mob skills: add a mechanic, condition or targeter; cast one
engine.overlays() // HUD overlays: show one, hide one, feed it values
engine.dialogs() // dialogs: open one, feed it values, read player settings
engine.registry() // everything this server holds, by id
engine.registration() // adding content of your ownWhat is API and what is not
Everything in ai.resourcepack.engine.api (and api.event) is supported.
Nothing in ai.resourcepack.engine.core is, and it can change in any
release, including a patch.
IDs#
Everything has one namespace:path ID, an ordinary Minecraft resource
location. parse returns empty rather than throwing, so a bad ID in somebody's
config becomes a message, not a stack trace.
ContentId ruby = ContentId.parse("mypack:ruby").orElseThrow();val ruby = ContentId.parse("mypack:ruby").orElseThrow()Store IDs, never what they resolve to. An icon's codepoint moves when content changes and an item's model is derived from its ID. The ID is the only stable reference.
Items#
Optional<ItemStack> stack = engine.items().create(ruby);
Optional<ContentId> what = engine.items().idOf(hand);
boolean isRuby = engine.items().is(hand, ruby);val stack: Optional<ItemStack> = engine.items().create(ruby)
val what: Optional<ContentId> = engine.items().idOf(hand)
val isRuby: Boolean = engine.items().`is`(hand, ruby)Identity lives in the stack's persistent data, not its name or model. An item
renamed in an anvil is still itself, and a vanilla diamond somebody named
"Ruby" is not one. create(id, amount) makes a stack of several.
To make a stack of your own wear a pack's art without becoming that item, call
wearModel(stack, modelId). Don't set the item model yourself: below 1.21.4
the model is a number only the engine knows. create and wearModel are main
thread only; ids() and info() are safe from any thread.
Models#
engine.models().place(location, "golem", PlaceOptions.defaults()); // a Studio model
engine.models().place(location, tree, PlaceOptions.defaults().placer(player)); // an item's place: block
engine.models().itemFor("golem", ItemOptions.defaults());
engine.models().near(location, 16); // what is standing around here
engine.models().at(entity); // is this entity part of a model?
engine.models().at(entity).ifPresent(model -> model.play("wave", true)); // play on a placed modelengine.models().place(location, "golem", PlaceOptions.defaults()) // a Studio model
engine.models().place(location, tree, PlaceOptions.defaults().placer(player)) // an item's place: block
engine.models().itemFor("golem", ItemOptions.defaults())
engine.models().near(location, 16.0) // what is standing around here
engine.models().at(entity) // is this entity part of a model?
engine.models().at(entity).ifPresent { it.play("wave", true) } // play on a placed modelThere are two ways to place:
- By model id (a String): just the model.
PlaceOptionssets a startinganimation, ascaleand aplacer. - By item
ContentId: exactly what a player placing that item gets (hitbox, seat, light, storage, states, animated rig, the item'splaceactions). With aplacerset,ModelPlaceEventfires first, withstack()null. It returns empty when the item has noplace:block, the block is occupied, or a listener cancelled it.
A Placement is a handle on a model standing in a world:
| Method | What it does |
|---|---|
modelId(), location(), hitbox() | What it is and where; hitbox() is its Interaction entity |
animations(), playing() | Its animation names, and the one running |
play(name) | Plays an animation. Asking again for a one-shot that is mid-play is ignored |
play(name, true) | Starts it over even if it is already running |
stop(), seek(seconds), playhead() | Stop, drive its clock yourself, read how far in it is |
data() | Persistent data saved with the model, for your own keys (Placement.key(plugin, name)) |
remove(dropItem), isValid() | Break it, and check it still exists |
play returns false when the model has no animation by that name, the
model has no moving parts (one that places as a single still display can't
animate), it has been broken, or that one-shot is already playing (use
play(name, true) to restart it). It never throws.
near(location, radius) and in(chunk) find placements; there is no "every
model on the server" call. isModel(entity) says whether an entity belongs to
any model.
Models on entities that aren't ours#
bind(entity, model) (optionally with a scale) puts a model on an NPC or a
boss, unbind takes it off, modelOn(entity) says which one it wears, and
animate(entity, name) / stopAnimating(entity) play it.
animate returns false when the entity wears nothing of ours, or its model
has no animation by that name. It is only for a bound model: a model standing
in the world, including the hitbox a right-click hands you, also answers false.
For one of those, use at(entity) and play on the placement, as above.
What the pack says about a model#
What a pack's place: block said is a read-only ModelInfo: facing, scale,
seat and light, plus storage() (a StorageSpec: type, rows, title, sounds),
jukebox(), states() with nextState, lightIn and solidIn, grow(),
and variation() (the random turn and size it is placed with). The engine
handles the clicks itself; cancel ModelInteractEvent to stop one. Growing
fires neither ModelBreakEvent nor ModelPlaceEvent. See
Working furniture.
Emotes#
engine.emotes().play(player, "wave"); // solo
engine.emotes().play(lead, "hug", List.of(other)); // with a cast
engine.emotes().canPerform(player);
engine.emotes().isEmoting(player.getUniqueId());
engine.emotes().stop(player);engine.emotes().play(player, "wave") // solo
engine.emotes().play(lead, "hug", listOf(other)) // with a cast
engine.emotes().canPerform(player)
engine.emotes().isEmoting(player.uniqueId)
engine.emotes().stop(player)A cast is moved, so ask them first
An emote with a cast teleports the people it names. The engine's own /emote
sends an invitation first; play does not, so asking is your job.
EmoteResult carries a typed reason rather than a sentence, so you write the
message in your own words and language.
Putting a rig on somebody yourself#
engine.emotes().wear(player, "rowing"); // again to swap, null to take off
engine.emotes().face(player, 90f); // point the body; null follows their look
engine.emotes().seek(player, seconds); // pose the rig at this moment of its emote
engine.emotes().seekProp(player, "skate_right", clock); // drive one carried prop's clockengine.emotes().wear(player, "rowing") // again to swap, null to take off
engine.emotes().face(player, 90f) // point the body; null follows their look
engine.emotes().seek(player, seconds) // pose the rig at this moment of its emote
engine.emotes().seekProp(player, "skate_right", clock) // drive one carried prop's clockwear is a movement set where you, not the player's legs, decide what the rig
wears. Moving doesn't end it, and calling it again swaps without restarting.
Emotes.BUILT_IN_SITTING and BUILT_IN_STANDING work on any pack.
faceis for a passenger whose body belongs to a seat. Call it every tick with the seat's direction. It only affects a rig you put on withwear.seek, called every tick with a placement'splayhead(), keeps a rig in step with a model's animation (a paddler's arms on a paddle).seekPropdrives a carried prop by your own clock, such as a wheel that turns with distance travelled. It survives a swap to another emote carrying the same prop.anchor(player, feet)pins the rig to a point you move every tick (a seat you move yourself), so the two arrive together;nullhands it back.lean(player, pitch, roll)tips the rig in the wearer's own frame, such as a rider leaning into a corner. It holds until you set it again.wear(player, id, under)layers the emote over one of the built-in stances, for an emote that animates only some bones.
Carrying a rig on a seat of your own#
A worn rig follows its wearer by being teleported every tick, which the client smooths out, so on anything fast it trails behind. Mount the rig's displays on your seat instead and the client draws them wherever the seat is:
int[] ids = engine.emotes().passengerEntityIds(rider); // bones, carried models, hands
double lift = engine.emotes().rigOriginOffset(); // blocks above the feetval ids = engine.emotes().passengerEntityIds(rider) // bones, carried models, hands
val lift = engine.emotes().rigOriginOffset() // blocks above the feetEvery display hangs off one point, rigOriginOffset() above the rider's feet,
so put your seat's passenger position there. Read the number rather than
copying it, as it follows the size the engine draws players at.
Mount with a packet, not addPassenger
The engine teleports every display every tick, and a Bukkit teleport of a passenger dismounts it. Send a client-side passengers packet instead. That packet is the vehicle's whole list: include whoever really sits there, resend it when the server sends its own, and send it to anybody who starts seeing the seat.
The ids change: putting a rig on spawns every display, and swapping to an emote
with different carried models spawns those again. EmoteRigSpawnEvent fires
each time with the new list, so mount there. It fires for every rig, so check
the player is yours.
Rebuilding the rigs#
A player's rig wears their skin, and rigs are baked into a pack. The engine
rebakes when a new skin arrives but only sends that build to whoever joins
next. To make everybody see everybody's current skin now (a minigame dressing
players in emotes), call engine.reloadRigs() on the main thread. On 1.20.3+,
where packs stack, that resends only the small rig pack and fires
RigPackBuildEvent; on older versions it is a full reload().
3D armour#
ContentId santa = ContentId.parse("christmas:santa").orElseThrow();
engine.armor().available(); // false before 1.21.4
engine.armor().info(santa); // its name and pieces
engine.armor().create(santa, ArmorPiece.HELMET); // one piece, as an item
ArmorResult result = engine.armor().wear(player, santa); // the whole set, now
engine.armor().wearing(player); // which set each worn piece is from
engine.armor().strip(player); // every 3D piece offval santa = ContentId.parse("christmas:santa").orElseThrow()
engine.armor().available() // false before 1.21.4
engine.armor().info(santa) // its name and pieces
engine.armor().create(santa, ArmorPiece.HELMET) // one piece, as an item
val result = engine.armor().wear(player, santa) // the whole set, now
engine.armor().wearing(player) // which set each worn piece is from
engine.armor().strip(player) // every 3D piece offA set from a content folder is namespace:path; one pushed from Studio is
studio:<id>. Each piece is an ordinary item that goes on by hand like armour,
so wear is a shortcut for all four. Whatever was in a slot goes back to the
player (or drops at their feet if their inventory is full) and is listed in
ArmorResult.displaced(). strip removes only 3D pieces and gives them to
nobody. ArmorResult has a reason: WORN, UNKNOWN_SET or UNSUPPORTED.
A set pushed from Studio is only drawn for players holding that push's pack.
Main thread only, except available, ids and info. See
3D armour.
3D shader objects#
Needs RP Engine 0.2.10 or newer, and a server on Minecraft 1.21.4 or newer.
engine.shaderObjects() places and drives 3D objects:
ShaderObjects objects = engine.shaderObjects();
if (!objects.supported()) return; // false before 1.21.4
ContentId nuke = ContentId.parse("studio:nuke").orElseThrow();
ShaderObject bomb = objects.spawn(location, nuke).orElseThrow();
bomb.moveBy(new Vector(0, -20, 0), 50, Easing.IN); // over 50 ticks
bomb.turnTo(90, 0, 0, 40, Easing.IN_OUT); // yaw, pitch, roll
bomb.resize(40, 2, Easing.LINEAR); // diameter in blocks
bomb.state("detonate"); // starts its clock
bomb.play("drop"); // a sequence from objects/val objects = engine.shaderObjects()
if (!objects.supported()) return // false before 1.21.4
val nuke = ContentId.parse("studio:nuke").orElseThrow()
val bomb = objects.spawn(location, nuke).orElseThrow()
bomb.moveBy(Vector(0, -20, 0), 50, Easing.IN) // over 50 ticks
bomb.turnTo(90f, 0f, 0f, 40, Easing.IN_OUT) // yaw, pitch, roll
bomb.resize(40.0, 2, Easing.LINEAR) // diameter in blocks
bomb.state("detonate") // starts its clock
bomb.play("drop") // a sequence from objects/spawn(location, id, size) sets the size; without it the object's own size is
used. at(entity), near(location, radius) and loaded() find objects
already placed, and ids() and info(id) list what the server has (states,
sequences, size). On a handle, replay(), settle(), seed(n), moveTo,
stop() and remove() match the commands;
state(name) and play(name) return false for a name the object doesn't have.
A time of 0 ticks is instant. Easing is LINEAR, IN, OUT or IN_OUT.
A handle lasts as long as its entity, and the object's state, place, turn and
size survive a restart; a running sequence or move does not. Main thread only,
except ids and info.
Sounds#
engine.sounds().play(player, id); // only this player hears it
engine.sounds().playAt(location, id); // everybody nearby hears it here
engine.sounds().playFrom(entity, id); // everybody nearby hears it follow this entity
engine.sounds().stop(player, id); // end it early for this playerengine.sounds().play(player, id) // only this player hears it
engine.sounds().playAt(location, id) // everybody nearby hears it here
engine.sounds().playFrom(entity, id) // everybody nearby hears it follow this entity
engine.sounds().stop(player, id) // end it early for this playerAll three use the sound's declared category, volume and pitch, and each has an
overload that takes volume and pitch. Use playFrom for anything that
moves. Each returns false for an unknown id. A stereo file plays at full volume
wherever it was played from (Minecraft only places mono sounds in the world),
so use mono for anything heard from a spot. Playing and stopping are main
thread only; ids() and info() are safe from any thread.
Music#
Background music is a playlist of the pack's sounds. Each player hears one piece at a time: what your plugin started for them, otherwise the music of the place they stand in.
Music music = engine.music();
music.play(player, bossTheme); // from its first track, until stopped
music.skip(player); // on to the next track
music.stop(player); // end it; the place's music comes back
music.playing(player); // the piece playing for them, if any
music.track(player); // and the trackval music = engine.music()
music.play(player, bossTheme) // from its first track, until stopped
music.skip(player) // on to the next track
music.stop(player) // end it; the place's music comes back
music.playing(player) // the piece playing for them, if any
music.track(player) // and the trackplay beats the place's music, so a boss fight can start its own and stop it
when the boss dies. play returns false for an unknown id, skip when nothing
is playing. Calling stop with nothing started silences the place's music
until they leave that place. Main thread only, except ids() and info().
HUD overlays#
An overlay is a picture kept on a player's screen: a stat block, a health bar,
a crouch indicator. Shader objects made in Studio are overlays, and
so are the ones in a huds/ folder. Server owners draw them;
your plugin decides who sees one and what numbers are in it.
engine.overlays().show(player, "studio:health"); // stays until hidden
engine.overlays().hide(player, "studio:health");
engine.overlays().hideAll(player);
engine.overlays().showing(player); // what they have onengine.overlays().show(player, "studio:health") // stays until hidden
engine.overlays().hide(player, "studio:health")
engine.overlays().hideAll(player)
engine.overlays().showing(player) // what they have onAn overlay made in Studio has the id studio:<id>, where <id> is the one on
its card on the pack page. There is no duration: to show one for five seconds,
schedule the hide.
Feeding it values#
An overlay's labels and progress bars can print {name} placeholders. Any name
works.
engine.overlays().set(player, "stamina", "40");
engine.overlays().set(player, "stamina", null); // remove it
engine.overlays().value(player, "stamina"); // what you last setengine.overlays().set(player, "stamina", "40")
engine.overlays().set(player, "stamina", null) // remove it
engine.overlays().value(player, "stamina") // what you last setA placeholder is looked up in this order, and the first answer wins:
- Whatever you
set. A built-in with the same name never overrules it. - The built-ins:
player,name,displayname,uuid,world,x,y,z,direction,health,health_max,health_percent,food,air,level,xp,xp_points,xp_needed,ping,gamemode,online,max_online,time,day. See HUD overlays. - PlaceholderAPI, if the server has it.
A name none of them knows draws as nothing rather than as {name}.
Values are per player, not per overlay, and a progress bar reads the same
names, so a stamina bar out of 100 needs only the one set call. They are also
the values dialogs print.
The same values can change more than words. A piece of a Studio overlay can be told, in its From code settings, to show only while a condition holds, to switch to one of its named colours, or (a progress bar) to take its length or a colour threshold from a value. Your plugin just sets the value:
engine.overlays().set(player, "status", "poisoned"); // a named colour
engine.overlays().set(player, "rank", "vip"); // Show when {rank} == "vip"
engine.overlays().set(player, "mana_len", "120"); // a bar 120 pixels longengine.overlays().set(player, "status", "poisoned") // a named colour
engine.overlays().set(player, "rank", "vip") // Show when {rank} == "vip"
engine.overlays().set(player, "mana_len", "120") // a bar 120 pixels longColours can only be ones the overlay names, and shapes can't change at all: they are compiled into the resource pack. See Changing it from code.
set and value are safe from any thread. They only record the value; the
next tick draws it, so six set calls in a row cost one redraw, and setting a
value to what it already was costs nothing. show, hide and hideAll are main thread only.
Laying a value out#
To build a line from several pieces (a kill feed: name, weapon icon, name), use the engine's own measurements:
int nameWidth = engine.overlays().width("Steve"); // pixels the cursor moves
String gap = engine.overlays().space(5); // five pixels of nothing
String back = engine.overlays().space(-12); // twelve pixels back, to draw overval nameWidth = engine.overlays().width("Steve") // pixels the cursor moves
val gap = engine.overlays().space(5) // five pixels of nothing
val back = engine.overlays().space(-12) // twelve pixels back, to draw overwidth knows vanilla's letters, every icon in the pack and the spaces space
makes. Both work in any text drawn in the default font.
Dialogs#
A dialog is a screen of its own, such as a store, a settings menu
or a punish menu, on Minecraft 1.21.6 and newer. Server owners make them in
Studio or in a dialogs/ folder; your plugin decides when one opens and what
values are on it.
engine.dialogs().show(player, "studio:shop");
engine.dialogs().close(player);
engine.dialogs().ids(); // what this server has
engine.dialogs().info(id); // name, command, settings it declares
engine.dialogs().supported(); // false before 1.21.6
engine.dialogs().canShow(player, id); // would show get as far as the client?engine.dialogs().show(player, "studio:shop")
engine.dialogs().close(player)
engine.dialogs().ids() // what this server has
engine.dialogs().info(id) // name, command, settings it declares
engine.dialogs().supported() // false before 1.21.6
engine.dialogs().canShow(player, id) // would show get as far as the client?A dialog made in Studio has the id studio:<id>, where <id> is the one in its
editor, and each page after the first is studio:<id>.<page>.
show returns false rather than throwing when the server is older than 1.21.6,
there is no such dialog, the player is offline, or they don't hold the pack the
dialog's picture is in. canShow checks all of that first. What it can't
promise is that the game accepts the dialog's JSON; a refusal is logged to the
console.
A dialog can declare a command players open it with (/shop). The engine
registers it; DialogInfo.command() and permission() say what it is.
DialogInfo.quickAction() says whether it is on the Quick Actions
key (G).
A dialog opens straight away even if it was pushed a moment ago. pending()
only tells you whether its id is in the server's registry yet, which matters
only to a datapack or command block of your own that names it.
When a player clicks a button#
RP Engine fires no event for a dialog click. A button runs a command, so
the way to react is to make that command yours: register /buy (or whatever
the button runs) in your plugin.yml and handle it in your command executor.
The command runs as the player who clicked, with {name} placeholders already
filled in. A button can run several commands in order, and a Run as console
button runs its command as the console instead. The one dialog event is
DialogSlotMoveEvent, for item slots.
If you want click events in Java, with typed inputs, use Dialog Engine, a separate plugin built for that.
Values on it#
A dialog's words can print {name} placeholders, on its screen as well as in
its title, tooltips, buttons and commands. See Live values.
Hand values over as you open it:
engine.dialogs().show(staff, "studio:punish", Map.of("target", suspect.getName()));
// "Punish {target}" opens as "Punish Steve", the name and the face on its card are Steve's,
// and a reason that runs "mute {target} 1h" runs "mute Steve 1h".engine.dialogs().show(staff, "studio:punish", mapOf("target" to suspect.name))
// "Punish {target}" opens as "Punish Steve", the name and the face on its card are Steve's,
// and a reason that runs "mute {target} 1h" runs "mute Steve 1h".Or publish a value once, for every dialog and HUD that prints it. These are the
same values overlays().set publishes, one set per player:
engine.dialogs().set(player, "coins", String.valueOf(balance));
engine.dialogs().set(player, "coins", null); // remove it
engine.dialogs().value(player, "coins"); // what you last setengine.dialogs().set(player, "coins", balance.toString())
engine.dialogs().set(player, "coins", null) // remove it
engine.dialogs().value(player, "coins") // what you last setA list (rows drawn in Studio, one per item) is filled all at once:
engine.dialogs().list(player, "warps", List.of("Spawn", "Shop", "Arena"));
// More than one field a row: "" is the row's own value, "note" is {warps_note}.
engine.dialogs().listRows(player, "warps", List.of(Map.of("", "Spawn", "note", "Where you start")));engine.dialogs().list(player, "warps", listOf("Spawn", "Shop", "Arena"))
// More than one field a row: "" is the row's own value, "note" is {warps_note}.
engine.dialogs().listRows(player, "warps", listOf(mapOf("" to "Spawn", "note" to "Where you start")))It sets warps_1, warps_2 and so on plus warps_count, and clears items a
longer list set before. Rows past the end are hidden, clicks and all. The
command that opens a dialog can pass a list too, warps=Spawn,Shop,Arena, for
that opening only.
A placeholder is looked up in this order, and the first answer wins:
- What you passed to
show(case-insensitive). - What you published with
set(oroverlays().set). - The built-ins, the same ones a HUD has, all about the player viewing it.
- PlaceholderAPI, if the server has it.
- The player's own settings.
Settings come last because players change them themselves with a command. A setting can never stand in for a real value, but a setting that shares a name with anything above shows that value instead, so give settings names of their own.
A name nothing answers is left as written, so a dialog that says {VIP} stays
as it is. Formatting codes are removed from values. On a Studio dialog's
screen, a value is limited to characters the game's small font draws (others
become ?) and is cut short with "…" if it doesn't fit its line.
A dialog is drawn once, as it opens. A value that changes while it is on
screen shows next time. Call reopen after a click changed something; it opens
the dialog again with the values it was opened with:
// A Buy button on the store runs /buy sword. In your command:
economy.withdraw(player, price);
engine.dialogs().set(player, "coins", String.valueOf(economy.getBalance(player)));
engine.dialogs().reopen(player); // the store again, with the new balance on it// A Buy button on the store runs /buy sword. In your command:
economy.withdraw(player, price)
engine.dialogs().set(player, "coins", economy.getBalance(player).toString())
engine.dialogs().reopen(player) // the store again, with the new balance on itThe server isn't told when a player closes a dialog, so reopen opens it
whether it is still on screen or not. Call it in answer to a click, not on a
timer.
Player settings#
A switch, slider or choice on a Studio dialog can be bound to a player setting: every player has their own value, the control shows it, and clicking it changes it. Read or change one from your plugin:
engine.dialogs().setting(player, "show_sidebar"); // Optional: "on" or "off"
engine.dialogs().setSetting(player, "show_sidebar", "off"); // as if they clicked it
engine.dialogs().setSetting(player, "show_sidebar", null); // back to the default
engine.dialogs().info(id).map(DialogInfo::variables); // {"show_sidebar": ["on", "off"]}engine.dialogs().setting(player, "show_sidebar") // Optional: "on" or "off"
engine.dialogs().setSetting(player, "show_sidebar", "off") // as if they clicked it
engine.dialogs().setSetting(player, "show_sidebar", null) // back to the default
engine.dialogs().info(id).map(DialogInfo::variables) // {"show_sidebar": ["on", "off"]}Settings are stored on the player and survive restarts. PlaceholderAPI reads
them as %rpengine_var_<name>%. Limits:
| Name | lower-case letters, digits and _, up to 32 characters |
| Value | letters, digits, _, . and -, up to 32 characters |
| Per player | 64 settings |
setSetting returns false for anything outside those. Your plugin may set any
name, not only one a dialog declares. Nothing reopens on its own, so call
reopen if they're looking at it.
Your own container in a dialog#
A Studio dialog can have a grid of item slots showing a container "from a plugin", by name. Register one under that name and the grid shows your inventory for whoever opens it, and lets them move items in and out where the grid allows it:
engine.dialogs().container("bank", new DialogContainer() {
@Override
public Inventory inventory(Player viewer) {
return banks.of(viewer);
}
@Override
public boolean mayPlace(Player viewer, int slot, ItemStack stack) {
return !stack.getType().name().endsWith("SHULKER_BOX");
}
@Override
public void changed(Player viewer) {
banks.save(viewer); // after every move that touched it
}
});engine.dialogs().container("bank", object : DialogContainer {
override fun inventory(viewer: Player): Inventory = banks.of(viewer)
override fun mayPlace(viewer: Player, slot: Int, stack: ItemStack): Boolean =
!stack.type.name.endsWith("SHULKER_BOX")
override fun changed(viewer: Player) {
banks.save(viewer) // after every move that touched it
}
})The engine reads your inventory as each dialog opens and writes to it as each
move happens, on the main thread. A move reads both slots at that moment, so a
stale screen can't duplicate items. DialogSlotMoveEvent fires before every
move in any container.
set and value are safe from any thread. Everything else in dialogs touches
a player and is main thread only.
Vehicles#
engine.vehicles().spawn(location, id); // park one, ready to get into
engine.vehicles().of(player); // the vehicle they are in
engine.vehicles().at(entity); // is this thing part of a vehicle?
engine.vehicles().near(location, 16); // nearest first
engine.vehicles().loaded(); // every one in a loaded chunk
engine.vehicles().isRiding(uuid); // safe from any threadengine.vehicles().spawn(location, id) // park one, ready to get into
engine.vehicles().of(player) // the vehicle they are in
engine.vehicles().at(entity) // is this thing part of a vehicle?
engine.vehicles().near(location, 16.0) // nearest first
engine.vehicles().loaded() // every one in a loaded chunk
engine.vehicles().isRiding(uuid) // safe from any threadA Vehicle is a handle on one standing in a world, occupied or not:
Vehicle car = engine.vehicles().of(player).orElseThrow();
car.speed(); // blocks per second, negative in reverse
car.states(); // MOVING, TURNING, AIRBORNE, IDLE ...
car.driver(); // Optional<Player>
car.occupants(); // driver first, then in seat order
car.seat(other, 1); // put somebody in seat 1
car.eject(other); // and take them out
car.submersion(); // how deep in water, in blocks. 0 out of it
car.pointOn(-0.7, 0.6, 1.5); // where a point on the bodywork is, in the worldval car = engine.vehicles().of(player).orElseThrow()
car.speed() // blocks per second, negative in reverse
car.states() // MOVING, TURNING, AIRBORNE, IDLE ...
car.driver() // Optional<Player>
car.occupants() // driver first, then in seat order
car.seat(other, 1) // put somebody in seat 1
car.eject(other) // and take them out
car.submersion() // how deep in water, in blocks. 0 out of it
car.pointOn(-0.7, 0.6, 1.5) // where a point on the bodywork is, in the worldsubmersion() is a depth; VehicleState.SUBMERGED only means "in a water
block", which a puddle also is. A vehicle whose medium isn't water floods
and stops in water; whether that wrecks it is up to you.
Vehicle definitions can carry addons: blocks for your plugin. Read yours with
vehicle.info().addon("my-addon"); the engine passes it through untouched.
Fuel, and anything else that stops a vehicle going#
setEnabled(false) is the switch. A disabled vehicle ignores its driver and
coasts to a halt, still falls and floats, and people get in and out as normal.
The flag is saved on the chassis, so a car that ran dry is still dry after a
restart.
NamespacedKey fuel = car.key(this, "fuel");
@EventHandler
public void onState(VehicleStateEvent event) {
// Burn only while it is actually going somewhere.
if (event.entered(VehicleState.MOVING)) burners.add(event.vehicle());
if (event.left(VehicleState.MOVING)) burners.remove(event.vehicle());
}
// once a second
for (Vehicle vehicle : burners) {
int left = vehicle.data().getOrDefault(fuel, PersistentDataType.INTEGER, 0) - 1;
vehicle.data().set(fuel, PersistentDataType.INTEGER, Math.max(0, left));
vehicle.setEnabled(left > 0);
}val fuel = car.key(this, "fuel")
@EventHandler
fun onState(event: VehicleStateEvent) {
// Burn only while it is actually going somewhere.
if (event.entered(VehicleState.MOVING)) burners += event.vehicle()
if (event.left(VehicleState.MOVING)) burners -= event.vehicle()
}
// once a second
for (vehicle in burners) {
val left = vehicle.data().getOrDefault(fuel, PersistentDataType.INTEGER, 0) - 1
vehicle.data().set(fuel, PersistentDataType.INTEGER, maxOf(0, left))
vehicle.setEnabled(left > 0)
}data() is the chassis's persistent data and survives everything the vehicle
does. uniqueId() is the chassis's id and the one stable key: seats and model
entities get new ids on every chunk load, so never store those.
| Method | What it does | Saved? |
|---|---|---|
setEnabled(boolean) | Ignores the driver and coasts to a halt | Yes |
setSpeedLimit(bps) | Lower top speed, braking and reverse scaled to match. An aircraft limited below takeoff speed can't take off | No |
setHandling(speed, turn) | Scales what it can do, as fractions of its definition | No |
setDamage(VehicleDamage) | Per-wheel condition, engine power and steering pull, read by the handling model | No |
setDescent(bps) | An aircraft comes down at this rate whatever it's doing. 0 holds height, negative clears it | No |
setPosture(pitch, roll) | A drawn lean on top of the physics | No |
stop() | Dead this tick, like hitting a wall |
Re-apply the unsaved ones when you pick a vehicle up again (after a chunk load
or restart). VehicleDamage.NONE is the vehicle exactly as its pack defines
it:
car.setDamage(VehicleDamage.builder()
.wheel(VehicleCorner.FRONT_LEFT, 0) // torn off
.wheel(VehicleCorner.REAR_LEFT, 0.45) // buckled
.enginePower(0.7)
.steeringPull(-3) // pulls left
.build());car.setDamage(VehicleDamage.builder()
.wheel(VehicleCorner.FRONT_LEFT, 0.0) // torn off
.wheel(VehicleCorner.REAR_LEFT, 0.45) // buckled
.enginePower(0.7)
.steeringPull(-3.0) // pulls left
.build())A wheel runs from 1 (sound) to 0 (gone). Damage is not health and never
changes the art; the engine has no opinion on how much punishment a vehicle
takes. That is yours to track in data().
Who may get in is an event, not a method
There is no setLocked. Cancel VehicleEnterEvent and they stay standing.
VehicleExitEvent can't be cancelled, so a player is never trapped; to keep
somebody in a race, put them back with seat.
Collisions and parts#
VehicleImpactEvent fires once per meaningful collision (cause(): VEHICLE,
WORLD or LANDING). first() and, for a vehicle pair, second() each carry
the velocity before and the change, the contact normal, closing speed, impulse,
spin change, area() (which side took it) and contactOffset() (where on the
vehicle, in its own frame: x right, y up from its base, z forward). A zero
offset means the engine can't say where. For WORLD impacts it is a face, not a
point.
To react:
applyImpulse(deltaVelocity, spinDelta)changes the physical response;velocity()reads it. An upward part lifts a land vehicle off the ground and gravity brings it back, so a jump pad is one call (RP Engine 0.2.45 and newer).scuff(area, contact, amount)adds a persistent scrape (amount 0 to 1, at most ten marks; a repeat hit in the same place deepens the mark).parts()lists an animated model's bones,partOffset(name)says where one sits, anddetachPart(name, velocity, spin, despawnTicks)knocks it off.detachedParts()is saved on the chassis.showStatus(text)borrows the vehicle's action-bar line for a moment.
Building a vehicle the engine does not have#
A skateboard is pushed rather than throttled, tucks for speed and spins in the air. None of that is in the format, but the handle exposes the pieces:
Vehicle board = engine.vehicles().of(player).orElseThrow();
VehicleInput keys = board.input(); // what the driver is pressing, this tick
if (keys.forward() && board.groundSpeed() < 7) {
board.nudge(2.2); // a kick: +2.2 blocks/s along the heading
board.dress(player, "myplugin_push"); // wear this emote over the seat's states
}
if (board.is(VehicleState.AIRBORNE) && keys.left()) board.spin(-360);
board.undress(player); // back to the seat's own table
board.dressVariant(player, "goofy"); // this rider's own variant of every state
board.turnOccupant(player, 270.0); // face a different way from the seat; null resets
board.holdOccupant(player, true); // sneak no longer dismounts while moving
board.wallRiding(); // is it on a wall right now?val board = engine.vehicles().of(player).orElseThrow()
val keys = board.input() // what the driver is pressing, this tick
if (keys.forward() && board.groundSpeed() < 7) {
board.nudge(2.2) // a kick: +2.2 blocks/s along the heading
board.dress(player, "myplugin_push") // wear this emote over the seat's states
}
if (board.`is`(VehicleState.AIRBORNE) && keys.left()) board.spin(-360.0)
board.undress(player) // back to the seat's own table
board.dressVariant(player, "goofy") // this rider's own variant of every state
board.turnOccupant(player, 270.0) // face a different way from the seat; null resets
board.holdOccupant(player, true) // sneak no longer dismounts while moving
board.wallRiding() // is it on a wall right now?input()is the same input the physics read, on the same tick. Where keys can't be read (Spigot, or before 1.21.4)keys()is false and onlythrottle()means anything. Nothing in the engine acts on sprint, so it's yours to use.dressVariantturnsmovingintomoving_goofyand so on, falling back to the plain emote where no variant exists.- Sneak is Minecraft's dismount. Call
holdOccupantonce, when they get on: the dismount happens before your next tick task could react. A held rider can still get off by tapping sneak, and the hold stops applying once the vehicle is below half a block a second, so nobody is ever trapped. Tell riders what the key does now.
A driver that isn't a player#
drive(keys) drives a vehicle as though somebody in the driver's seat were
holding those keys, from the next tick until you call it again (RP Engine
0.2.51 and newer). Nobody has to be in it, and a real driver's keys are ignored
meanwhile. It's for computer racers, demo laps and ghosts. The handling is a
player's, setEnabled(false) still stops it, and input() reports your keys,
so code that reacts to what the driver pressed works for this driver too.
drive(null) hands it back. Steer and throttle run from -1 to 1 (steer: left
to right); the space key is the handbrake, a jump or a climb, depending on the
vehicle.
VehicleInput keys = new VehicleInput(true, 1.0, -0.4, // throttle 1, steer 0.4 to the left
true, false, true, false, false, false, false); // forward, back, left, right, space, sprint, sneak
kart.drive(keys);
kart.carry(mannequin, 0); // somebody to see in the driver's seatcarry(entity, seat) sits a figure that isn't a player in a seat, a mannequin
or an NPC. It's for show: it doesn't drive and it isn't an occupant. A seat is
rebuilt when its chunk loads again, which drops it, so check getVehicle() and
carry it again when it has come off.
Moving the vehicle's own model#
dress is for the rider. perform plays one of the model's animations
instead of whatever its animations: table says:
bike.perform("wheelie"); // the whole bike tips back about the rear axle
// ... count your ticks ...
bike.rest(); // back to the state table, wheels where they werebike.perform("wheelie") // the whole bike tips back about the rear axle
// ... count your ticks ...
bike.rest() // back to the state table, wheels where they wereIt works on a vehicle with no animations: at all. A performed animation loops
until you call rest(), so you time the trick. It runs on real time even on a
vehicle with
animation-follows-speed,
and the wheels pick up where they were afterwards. One animation plays at a
time, so a trick that needs the wheels turning must turn them itself.
Every vehicle handle method is main thread only. ids, info and isRiding
are safe anywhere.
Icons in your own text#
String line = engine.icons().format(config.getString("welcome"));val line = engine.icons().format(config.getString("welcome"))Every :namespace:id: becomes its picture. An ID naming nothing is left
exactly as written.
An animated icon is animated by you redrawing it. Each frame is its own
character, so format and character give the first frame, which is right for
anything you send once. For text you send repeatedly, ask for the current frame:
// Once a tick, or as often as your scoreboard redraws.
sidebar.setLine(0, engine.icons().formatNow(":mypack:loading: Loading"));
engine.icons().characterNow(id).ifPresent(frame -> bar.setTitle(frame + " Boss"));// Once a tick, or as often as your scoreboard redraws.
sidebar.setLine(0, engine.icons().formatNow(":mypack:loading: Loading"))
engine.icons().characterNow(id).ifPresent { frame -> bar.setTitle("$frame Boss") }IconInfo has frames() (1 for a still), animated(), fps(), loops(),
frameAt(elapsedMillis), and character(frame) / codepoint(frame) for one
frame (from 0). Redrawing once a tick shows every frame of anything up to 20
fps. A looping icon is timed from the epoch, a one-shot from server start. The
PlaceholderAPI form %rpengine_icon_<namespace:id>% is always the current
frame.
IconInfo.aliases() and permission() apply to players typing the icon in
chat. format ignores both.
Text Effects in your own text#
A Text Effect is a colour: text drawn in exactly that colour wears the effect, whatever sent it. So the API hands you the colour, and two ways to write text in it.
TextEffects effects = engine.textEffects();
// Tags in your own config, resolved the way RP Engine's own files are.
player.sendMessage(effects.format(config.getString("welcome"))); // "Welcome to <fx:rainbow>Skyblock</fx>!"
// One effect by name: a content folder's before a Studio one.
effects.find("fire").ifPresent(fire -> {
meta.setDisplayName(effects.apply(fire.id(), "Flame Sword"));
// Adventure, on Paper: the colour is all it takes.
player.sendMessage(Component.text("Hot!", TextColor.color(fire.rgb())));
});val effects = engine.textEffects()
// Tags in your own config, resolved the way RP Engine's own files are.
player.sendMessage(effects.format(config.getString("welcome"))) // "Welcome to <fx:rainbow>Skyblock</fx>!"
// One effect by name: a content folder's before a Studio one.
effects.find("fire").ifPresent { fire ->
meta.setDisplayName(effects.apply(fire.id(), "Flame Sword"))
// Adventure, on Paper: the colour is all it takes.
player.sendMessage(Component.text("Hot!", TextColor.color(fire.rgb())))
}| Method | What it does |
|---|---|
ids() | Every Text Effect, both sources, sorted |
info(id) | A TextEffectInfo, by ID |
find(name) | By what somebody typed: mypack:fire, or a bare fire |
apply(id, text) | text in the effect, as a legacy string that resets after itself |
format(text) | Every <fx:name>...</fx> replaced. A tag naming nothing is left as written |
TextEffectInfo has id(), name(), color() (#rrggbb), rgb(),
source() (CONTENT or STUDIO) and preset(). apply and format write
legacy codes (§x§f§c...), which every Spigot API takes. A colour code inside
the text ends the effect; bold and italic do not. Safe from any thread.
VFX#
A VFX is a spell effect the engine plays in the world. Play one at a place, or on a player (at their feet, facing their way), and keep the handle if you want to end it early.
Vfx vfx = engine.vfx();
vfx.find("frost_nova").ifPresent(info -> {
vfx.play(info.id(), player).ifPresent(play -> {
// play.running() until it ends; play.stop() ends it now.
});
});val vfx = engine.vfx()
vfx.find("frost_nova").ifPresent { info ->
vfx.play(info.id(), player).ifPresent { play ->
// play.running() until it ends; play.stop() ends it now.
}
}| Method | What it does |
|---|---|
ids() | Every VFX, both sources, sorted |
info(id) | A VfxInfo, by ID |
find(name) | By what somebody typed: mypack:frost, or a bare frost |
play(id, location) | Plays it there, facing the location's yaw. Empty for an unknown ID, or below 1.21.4 |
play(id, player) | Plays it where the player stands, facing their way |
play(id, location, caster, options) | Plays it as a VfxOptions says (below); caster is whose placeholders a condition reads, and may be null |
play(id, player, options) | The same, cast on a player |
playing() | Every VfxPlay playing now |
stop(tag) / stop(player) | Ends the effects with that tag, or cast on that player, and says how many |
stopAll() | Ends them all, and says how many there were |
VfxOptions is every casting option:
loop(), loopFor(ticks), loopTimes(n), until(condition),
whileTrue(condition), stopWhen(play -> ...) (a check of your own, run every
checkEvery(ticks)), end(End.FINISH | End.CUT), speed, scale, turn,
offset(left, up, ahead), follow(entity), follow(vehicle), delay, vary,
seed, sounds, volume, viewers(Viewers.EVERYONE | Viewers.CASTER) and tag.
follow(vehicle) rides with a vehicle and turns with the way its body points,
not with where its driver is looking, so an exhaust stays at the tail through a
corner and an effect made in front of the caster plays in front of the vehicle.
Cast it at vehicle.location(), which carries that heading:
vfx.play(boost, kart.location(), driver, VfxOptions.builder()
.follow(kart)
.build());vfx.play(id, player, VfxOptions.builder()
.loop()
.stopWhen(play -> !combat.isFighting(player))
.follow(player)
.tag("combat-aura")
.build());VfxInfo has id(), name(), durationTicks(), source() (CONTENT or
STUDIO) and preset(). A VfxPlay has id(), location(), running(),
stop() (now), finish() (a loop plays out its fade), options(), age()
and passes(). A Studio VFX is shown only to the players whose pack has it. Main
thread only.
Custom mobs#
The custom mobs a content folder's entities/ declares
and the ones a Studio sync carries (studio:<id>) are one catalogue.
Mobs mobs = engine.mobs();
ContentId rhino = ContentId.parse("zoo:rhino").orElseThrow();
mobs.info(rhino).ifPresent(info -> info.animations()); // state -> animation name
Entity spawned = mobs.spawn(location, rhino).orElseThrow();
mobs.idOf(entity); // which custom mob this is, if any
mobs.state(entity); // what it is showing: MobState.WALK, ...
mobs.play(entity, MobState.ATTACK); // as if it had happened: a one-shot, then back
mobs.play(entity, "roar", 0); // any animation of its model, for its own length
ItemStack egg = mobs.egg(rhino, 1).orElseThrow(); // hatches that mob where usedval mobs = engine.mobs()
val rhino = ContentId.parse("zoo:rhino").orElseThrow()
mobs.info(rhino).ifPresent { it.animations() } // state -> animation name
val spawned = mobs.spawn(location, rhino).orElseThrow()
mobs.idOf(entity) // which custom mob this is, if any
mobs.state(entity) // what it is showing: MobState.WALK, ...
mobs.play(entity, MobState.ATTACK) // as if it had happened: a one-shot, then back
mobs.play(entity, "roar", 0.0) // any animation of its model, for its own length
val egg = mobs.egg(rhino, 1).orElseThrow() // hatches that mob where usedThe states are MobState, and their order is their precedence: DEATH,
SPAWN, ATTACK and HURT play once, and only a higher one cuts them short;
RUN, WALK and IDLE loop, and fall through to the next when the mob has no
animation for them. play(entity, state) is refused (it returns false) when a
higher one-shot is playing or the mob has nothing mapped for that state.
play(entity, name, seconds) holds the stage for that long (0 for the
animation's own length), then the mob goes back to what it is doing.
EntityInfo also says how a mob looks after itself: hurtFlash(),
followRange(), glowing(), fireImmune(), and three optional blocks:
bossBar() (color(), style(), range()), ranged() (projectile(),
damage(), cooldown(), range()) and spawning() (worlds(), biomes(),
replaces(), chance(), light, time, group and maxPerChunk()). The builder
takes each, so a mob you register from code
can have them too.
A projectile a custom mob fired carries the damage it was given. Setting a
different damage on it in your own EntityDamageByEntityEvent, at a priority
above NORMAL, wins.
Listen for MobStateChangeEvent to follow what a mob is doing. It fires when
what is playing changes, so a state the mob has no animation for never
appears. EntityDeathEvent still hands you the drops list, after the mob's own
drops: have replaced the vanilla loot.
Everything here touches an entity, so it is main thread only, or on Folia the thread that owns the mob or the place. Needs RP Engine 0.2.51 or newer.
Skills#
A custom mob's skills are MythicMobs' skill lines, and you extend the language the way MythicMobs' add-ons do: register a mechanic, a condition or a targeter by name, and every mob file can use it. Needs RP Engine 0.2.53 or newer.
MobSkills skills = engine.skills();
// - myplugin:smite{damage=6} @target ~onAttack 0.2
skills.registerMechanic("myplugin:smite", cast -> {
cast.target().ifPresent(target -> {
target.getWorld().strikeLightningEffect(target.getLocation());
if (target instanceof LivingEntity living) {
living.damage(cast.number("damage", 4, "d") * cast.power(), cast.caster());
}
});
return true;
}, "smite");
// ?myplugin:inarena{name=pit}
skills.registerCondition("myplugin:inarena", cast ->
arenas.contains(cast.string("name", ""), cast.location()));
// @myplugin:team{team=red}
skills.registerTargeter("myplugin:team", new SkillTargeter() {
@Override
public Collection<Entity> entities(SkillCast cast) {
return new ArrayList<>(teams.members(cast.string("team", "red")));
}
});
skills.cast(boss, "bosses:GroundSlam", player); // a metaskill, cooldown and conditions applying
skills.stance(boss); // Optional<String>
skills.stance(boss, "enraged");val skills = engine.skills()
// - myplugin:smite{damage=6} @target ~onAttack 0.2
skills.registerMechanic("myplugin:smite", { cast ->
cast.target().ifPresent { target ->
target.world.strikeLightningEffect(target.location)
(target as? LivingEntity)?.damage(cast.number("damage", 4.0, "d") * cast.power(), cast.caster())
}
true
}, "smite")
// ?myplugin:inarena{name=pit}
skills.registerCondition("myplugin:inarena") { cast ->
arenas.contains(cast.string("name", ""), cast.location())
}
// @myplugin:team{team=red}
skills.registerTargeter("myplugin:team", object : SkillTargeter {
override fun entities(cast: SkillCast): Collection<Entity> =
ArrayList(teams.members(cast.string("team", "red")))
})
skills.cast(boss, "bosses:GroundSlam", player) // a metaskill, cooldown and conditions applying
skills.stance(boss) // Optional<String>
skills.stance(boss, "enraged")SkillCast is what each one is handed: caster(), mob(), trigger(),
target() (the entity, when the target is one) and location() (always),
power(), stance(), and the line's options by any of their names, with
placeholders such as <caster.hp> filled in and a range like 2to5 picked:
string, number, integer and bool, plus fill(text) for text of your
own. A mechanic is called once per target, on the caster's thread. A
targeter's own options are read the same way; the shared ones (limit,
sort, the offsets, conditions) are applied to what it returns.
Names are looked up when a line runs, so registering in onEnable, after
RP Engine has loaded its content, is in time. Give yours a prefix
(myplugin:smite). A prefixed name nobody has registered yet is kept at load
with a note that it "is not registered yet" and runs from the moment you
register it, with no reload, where an unknown bare name is a typo and its line
is skipped. Registering a name that already exists replaces it, RP Engine's own
included. Names are case-insensitive.
Listen for MobSkillEvent to see a skill before it runs, or cancel it; see
Events.
Events#
All in ai.resourcepack.engine.api.event, and cancellable unless the row says
otherwise.
| Event | When |
|---|---|
ContentLoadEvent | Content finished loading and the packs are built. A reload replaces every definition, so rebuild anything you cached here. cause() is STARTUP or a reload. Not cancellable |
PackSendEvent | A pack went out to a player. pack() is the built pack; its uuid() is the id PlayerResourcePackStatusEvent reports on 1.20.3+, so you can match the two. Not cancellable |
RigPackBuildEvent | The emote rigs were rebaked: on every content load, or alone for a new skin or reloadRigs(). alone() and separate() say which. Not cancellable |
ItemUseEvent | A custom item was right- or left-clicked. block() and face() say what was clicked (null in the air). Cancelling cancels the vanilla use too |
ModelPlaceEvent | A model is about to be put down. getPlayer() is who is placing it, model() the model's id, block() the block it goes in (its location), and stack() the stack in their hand, so you can read what the item carries (null when a plugin placed it). Parking a vehicle from its item and hatching a custom mob from its egg fire it too, with the vehicle's or mob's id as model() |
ModelPlacedEvent | A model was put down and is standing. placement() is its handle and stack() the stack it came from, so you can copy what the item carries onto the placement's data(). Not cancellable |
ModelBreakEvent | A model is about to be broken. model(), location(), player() (null if nobody broke it), hitbox() (its Interaction entity, whose UUID tells two copies of one model apart) and placement(). Has a drop flag separate from cancelling |
ModelInteractEvent | A placed model was right-clicked, from your content folder or from Studio. Has hitbox(), placement() and clickedPosition(). Cancelling stops everything the engine would do with the click |
ModelAnimationEvent | A model is about to play an animation: getPlacement(), getAnimation(), getCause() and getPlayer() (who triggered it, if anyone) |
ModelAnimationEndEvent | One ended: finished, replaced or stopped. Not cancellable |
ModelSeatEvent | Somebody is about to sit on a chair, a seat bone or a vehicle seat. placement() or vehicle() says what it belongs to |
ModelBindEvent | A model is going on, or coming off, an entity that isn't ours (a boss, an NPC) |
VehicleEnterEvent | Somebody is about to get into a vehicle. Fires after ModelSeatEvent for the same seat |
VehicleExitEvent | Somebody got out or was taken out, with the reason: dismounted, ejected, quit, reloaded, removed, unloaded, shutdown. After RELOADED they're put back a tick later with no enter event. Not cancellable |
VehicleMoveEvent | A vehicle is about to move (once per tick, only while moving). Cancelling stops it like a wall. Use it for regions, not fuel |
VehicleStateEvent | What a vehicle is doing changed: set off, stopped, took off, went under. Not cancellable |
VehicleImpactEvent | A vehicle hit another vehicle, the world, or the ground. See Collisions and parts. Not cancellable |
VehicleBailEvent | A bad landing is about to throw a rider off. Cancelling keeps them aboard |
MobStateChangeEvent | A custom mob's animation state changed: previous() and state(), null for its rest pose. Not cancellable |
MobSkillEvent | A custom mob is about to cast a skill: one of its own skills: lines (skill() is the line as written) or a metaskill (skill() is its id), with trigger() (attack, timer, api and so on) and triggerEntity(). Fires after the line's chance, health and conditions passed. Cancelling skips it and spends no cooldown |
EntityDeathEvent | A custom entity died. Says what it was, killer(), and drops(): Bukkit's live drop list, so editing it changes the loot. Not cancellable |
PlayerLiquidEvent | Somebody went into or came out of one of your liquids. Not cancellable |
EmoteStartEvent | An emote is about to start |
EmoteEndEvent | An emote ended, with the reason: finished, stopped, moved, damaged, quit, shutdown |
EmoteRigSpawnEvent | A rig's displays were spawned. Carries the new entity ids. Not cancellable |
DialogSlotMoveEvent | A player is about to move a stack in a dialog's item slots. dialog(), from() and to() (slot keys like inv/5, ender/3, api.bank/0), moving() and onto() (the stack already there). The only dialog event: a button click runs a command instead, see When a player clicks a button |
The engine decides whether something can physically happen, never whether it is allowed to. Region protection, plot ownership and event worlds are rules about your server, and these events are where you enforce them.
@EventHandler
public void onPlace(ModelPlaceEvent event) {
if (!myRegions.mayBuild(event.getPlayer(), event.block())) {
event.setCancelled(true);
}
}
@EventHandler
public void onLoad(ContentLoadEvent event) {
menus.rebuild();
}@EventHandler
fun onPlace(event: ModelPlaceEvent) {
if (!myRegions.mayBuild(event.player, event.block())) {
event.isCancelled = true
}
}
@EventHandler
fun onLoad(event: ContentLoadEvent) {
menus.rebuild()
}A plugin loading after RP Engine misses the first load
The STARTUP event has already fired before your listener exists. Don't work
around it with a delayed task: query the API in your own onEnable, and use
the event for reloads after that.
Priority#
The engine reads every one of these events after all handlers have run, so
priority only orders you against other plugins. Listen at NORMAL unless you
are deliberately arbitrating with another plugin, and treat MONITOR as
read-only (a cancel there still counts).
Vanilla's own events are different. The engine listens to those at LOW with
ignoreCancelled = true, so cancelling a PlayerInteractEvent at LOWEST
stops a custom item's use before RP Engine sees the click, which is usually
what a protection plugin wants.
Shipping content in your own jar#
An addon carries its models, items, vehicles and emotes as an ordinary
content folder inside its jar, under
resources/content/<namespace>/, and installs it on enable:
try {
if (AddonContent.install(this, engine, "skateboards")) {
engine.reload(); // only when something actually changed
}
} catch (IOException e) {
getLogger().severe("Could not install content: " + e.getMessage());
getServer().getPluginManager().disablePlugin(this);
}try {
if (AddonContent.install(this, engine, "skateboards")) {
engine.reload() // only when something actually changed
}
} catch (e: IOException) {
logger.severe("Could not install content: ${e.message}")
server.pluginManager.disablePlugin(this)
}It copies into plugins/RPEngine/content/<namespace>/ and returns true only
when something changed, so restarting on the same jar copies and reloads
nothing. A reload rebuilds and resends every pack, so don't call it otherwise.
A new build updates its own files and leaves everything else alone. If the
server owner edited one of your shipped files, that edit survives the update:
the console names the file and says to delete it to take the new version. Use
your plugin's name as the namespace, and put RP Engine in depend.
Registering content from code#
A plugin can add content directly instead of shipping a folder. Claim a namespace, define into the handle you get back, and the pack builder picks it up on the next build.
ClaimResult claim = engine.registration()
.claim("myplugin", ContentSource.EMBEDDED);
claim.namespace().ifPresent(ns -> {
ns.define(ContentKind.ITEM, "ruby");
// ... and ns.release() when your plugin disables
});val claim = engine.registration()
.claim("myplugin", ContentSource.EMBEDDED)
claim.namespace().ifPresent { ns ->
ns.define(ContentKind.ITEM, "ruby")
// ... and ns.release() when your plugin disables
}If the claim fails, reason() says why and heldBy() who has it. Holding
myplugin can't define otherpack:thing, so two sources can't overwrite each
other. release() drops the namespace and everything in it. Content from a
plugin goes into the same registry under the same rules as a hand-written
folder.
Threading#
Reads about what the server holds (ids, info, all of ContentRegistry)
are safe from any thread. Anything touching a player, an entity or a world is
main thread only, and its javadoc says so.
On Folia there is no main thread, so "main thread" means the thread that
owns what the call touches: the player's or entity's own scheduler for
anything about them (player.getScheduler().run(...)), and the region
scheduler for anything at a place (Bukkit.getRegionScheduler().run(plugin, location, ...)), such as placing a model or playing a VFX at a spot. A call
from the global region or another region's thread is the same mistake a call
from an async thread is on Paper. Everything RP Engine does on its own already
follows this, so your plugin only has to for the calls it makes.