For developers
Emotes from code
An emote animates the player rather than a placed model. Their body is replaced with a rig wearing their own skin, they're made invisible for the duration, and they're put back exactly where they were afterwards.
Everything here needs rpai — see Plugin API for how to get it.
Play one#
EmoteResult result = rpai.emotes().play(player, "Bow");
if (!result.started()) {
player.sendMessage("Can't do that right now.");
}val result = rpai.emotes().play(player, "Bow")
if (!result.started()) {
player.sendMessage("Can't do that right now.")
}That's the whole happy path. The interesting part is the result.
Saying why it didn't work#
An emote can be refused for about thirty different reasons, and they don't have the same fix — "you're not on the ground" and "this pack was synced without your skin" send a player to completely different places. So the API hands back a reason, not a sentence, and you write the words:
EmoteResult result = rpai.emotes().play(player, "Bow");
if (result.started()) return;
player.sendMessage(switch (result.reason()) {
case NOT_ON_GROUND -> "You need to be standing on solid ground.";
case ALREADY_EMOTING -> "You're already doing one.";
case UNKNOWN_EMOTE -> "No emote called " + result.subject()
+ ". Try: " + String.join(", ", result.options());
case CAST_NOT_ONLINE -> result.subject() + " isn't online.";
default -> "Can't do that right now.";
});val result = rpai.emotes().play(player, "Bow")
if (result.started()) return
player.sendMessage(when (result.reason()) {
Reason.NOT_ON_GROUND -> "You need to be standing on solid ground."
Reason.ALREADY_EMOTING -> "You're already doing one."
Reason.UNKNOWN_EMOTE -> "No emote called ${result.subject()}" +
". Try: " + result.options().joinToString(", ")
Reason.CAST_NOT_ONLINE -> "${result.subject()} isn't online."
else -> "Can't do that right now."
})Always have a default branch
The reason list only ever grows, so always have an else / default
branch. A Kotlin when used as an expression needs one anyway; a Java
switch will happily compile without one and then say nothing at all the
first time we add a reason.
The extras on a result are worth knowing:
| Call | What it carries |
|---|---|
started() | Whether it's playing |
reason() | Why, typed. STARTED when it worked |
subject() | Who or what the reason is about — a player's name, or the words that named no emote |
options() | What would have worked: the emote names that exist, or the cast slots to fill |
borrowedSkin() | It's playing, but wearing the pack's fallback skin rather than theirs |
borrowedSkin() is worth surfacing once rather than refusing over. They're
emoting — just not as themselves, because they weren't in the party when the
pack was pushed. The fix is a re-sync they can choose to do later.
Check before you offer#
if (!rpai.emotes().canPerform(player)) {
// No rig baked for them in the pack they're wearing.
// They joined after the last Sync — nothing they did wrong.
}if (!rpai.emotes().canPerform(player)) {
// No rig baked for them in the pack they're wearing.
// They joined after the last Sync — nothing they did wrong.
}Why an emote needs the player to be in the pack
An emote renders somebody's skin, and a resource pack can't reach a live
one. So a pushed pack carries a baked copy of each recipient's skin, and a
player who wasn't in the party at push time has no rig to wear. That's the
one thing about emotes that surprises everybody, and canPerform is how you
find out before you build them a menu they can't use.
Duets and group emotes#
Some emotes are authored for more than one person — a handshake, a high five. They can't be played alone, and the cast has to be exactly the size the emote expects.
Optional<EmoteInfo> info = rpai.emotes().info("Handshake");
info.ifPresent(emote -> {
if (emote.needsCast()) {
// emote.castSlots() is ["partner"] — labels for a prompt, not names
rpai.emotes().play(player, "Handshake", List.of(partner));
}
});val emote = rpai.emotes().info("Handshake").orElse(null)
if (emote != null && emote.needsCast()) {
// emote.castSlots() is ["partner"] — labels for a prompt, not names
rpai.emotes().play(player, "Handshake", listOf(partner))
}The people you name are teleported into place, turned to face where the emote puts them, and put back afterwards. Nothing is spawned and nobody is moved until every participant and every destination has been checked — so a cast with one person missing costs nothing rather than half-starting.
An emote ends for everybody or for nobody. Anyone moving, quitting or being hit ends it for the whole group, because half a handshake is one person shaking air.
Building your own /emote command#
Emote names are free text with spaces in them, so "Slow clap Steve" is
either one emote or an emote and a player, and there's no separator to tell
them apart. perform resolves that for you — longest leading run of words
that names an emote wins:
@Override
public boolean onCommand(CommandSender sender, Command cmd, String label, String[] args) {
if (!(sender instanceof Player player)) return true;
EmoteResult result = rpai.emotes().perform(player, Arrays.asList(args));
player.sendMessage(describe(result)); // your switch from above
return true;
}
@Override
public List<String> onTabComplete(CommandSender sender, Command cmd, String label, String[] args) {
List<String> options = new ArrayList<>(rpai.emotes().ids());
options.addAll(rpai.emotes().castCandidates(sender, args));
return options;
}override fun onCommand(sender: CommandSender, cmd: Command, label: String, args: Array<String>): Boolean {
val player = sender as? Player ?: return true
val result = rpai.emotes().perform(player, args.toList())
player.sendMessage(describe(result)) // your when from above
return true
}
override fun onTabComplete(sender: CommandSender, cmd: Command, label: String, args: Array<String>): List<String> =
rpai.emotes().ids() + rpai.emotes().castCandidates(sender, args)castCandidates is empty unless the words so far name an emote that takes a
cast and there are slots left — so completing a solo emote offers nobody, and
a duet stops offering once its partner is typed.
Stopping one#
rpai.emotes().stop(player); // false if they weren't emoting
rpai.emotes().isEmoting(player.getUniqueId());rpai.emotes().stop(player) // false if they weren't emoting
rpai.emotes().isEmoting(player.uniqueId)Refusing an emote on your own terms#
The library refuses an emote in the air and in spectator, because those break
the emote itself. It has no opinion about arenas, minigames, regions, combat or
whose turn it is — those are yours, and
EmoteStartEvent is where you say so.
It used to refuse one in combat too. That's gone: being hit still ends an
emote that is running, but whether a fight should stop somebody starting one is
a gameplay rule, and this library doesn't get to guess the rules of a server it
doesn't own. Cancel EmoteStartEvent on your own combat timer to bring it
back.
@EventHandler
public void onEmote(EmoteStartEvent event) {
if (inMatch(event.getLead())) {
event.setCancelled(true);
event.getLead().sendMessage("Not during a match.");
}
}@EventHandler
fun onEmote(event: EmoteStartEvent) {
if (inMatch(event.lead)) {
event.isCancelled = true
event.lead.sendMessage("Not during a match.")
}
}Cancelling reaches the caller as Reason.CANCELLED with nothing attached —
the reason belongs to whoever cancelled, so say it yourself as above.