If {@code delay <= 0}, plays immediately on the current thread.
+ *
* @param location the location where the sound should be played
*/
public void play(@NonNull Location location) {
+ if (delay <= 0) {
+ Objects.requireNonNull(location.getWorld()).playSound(location, sound, volume, pitch);
+ return;
+ }
PluginContext.delay(() -> Objects.requireNonNull(location.getWorld())
.playSound(location, sound, volume, pitch), delay);
}
@@ -35,9 +41,15 @@ public void play(@NonNull Location location) {
/**
* Plays this sound directly to the specified player after the configured delay.
*
+ * If {@code delay <= 0}, plays immediately on the current thread.
+ *
* @param player the player who should hear the sound
*/
public void play(@NonNull Player player) {
+ if (delay <= 0) {
+ player.playSound(player.getLocation(), sound, volume, pitch);
+ return;
+ }
PluginContext.delay(() -> player.playSound(player.getLocation(), sound, volume, pitch), delay);
}
}
diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/sign/SignInputManager.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/sign/SignInputManager.java
index 1bff82a3..9c30d357 100644
--- a/bedrock/src/main/java/fr/moussax/bedrock/ui/sign/SignInputManager.java
+++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/sign/SignInputManager.java
@@ -7,7 +7,6 @@
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
-import org.bukkit.Bukkit;
import org.bukkit.craftbukkit.entity.CraftPlayer;
import org.bukkit.entity.Player;
import org.jspecify.annotations.NonNull;
@@ -68,7 +67,7 @@ public static void handleSignUpdate(@NonNull Player player, String @NonNull [] l
}
}
- Bukkit.getScheduler().runTask(PluginContext.get(), () -> {
+ PluginContext.run(() -> {
if (player.isOnline()) {
session.input().handleComplete(player, lines);
}
From 7585e92eb88dc4511d60dc1aab72ee8f8d33bd7f Mon Sep 17 00:00:00 2001
From: Moussa
Date: Wed, 30 Sep 2026 16:10:13 +0200
Subject: [PATCH 10/23] feat(commands): add sender context, branch path
matching, and explicit console feedback
Support sender-aware tab suggestions in TabSuggestionRegistry and filter player completions by visibility. Add pipe-delimited branch path matching in TabSuggestionBuilder, warn non-player senders in PlayerCommand, and add unit tests for command completion rules.
---
.../bedrock/commands/PlayerCommand.java | 7 +-
.../commands/TabSuggestionBuilder.java | 29 ++--
.../commands/TabSuggestionRegistry.java | 43 ++++--
.../bedrock/commands/TabSuggestionTest.java | 132 ++++++++++++++++++
.../commands/impl/GemsCommand.java | 5 +-
.../registry/CommandsRegistry.java | 3 +-
6 files changed, 190 insertions(+), 29 deletions(-)
create mode 100644 bedrock/src/test/java/fr/moussax/bedrock/commands/TabSuggestionTest.java
diff --git a/bedrock/src/main/java/fr/moussax/bedrock/commands/PlayerCommand.java b/bedrock/src/main/java/fr/moussax/bedrock/commands/PlayerCommand.java
index 140b19e1..3eca58c8 100644
--- a/bedrock/src/main/java/fr/moussax/bedrock/commands/PlayerCommand.java
+++ b/bedrock/src/main/java/fr/moussax/bedrock/commands/PlayerCommand.java
@@ -22,8 +22,8 @@ public abstract class PlayerCommand implements CommandExecutor {
/**
* {@inheritDoc}
*
- * If the sender is not a player, the command is rejected and
- * {@code false} is returned.
+ *
If the sender is not a player, a warning is sent to the sender and
+ * {@code true} is returned.
*/
@Override
public final boolean onCommand(
@@ -33,7 +33,8 @@ public final boolean onCommand(
String @NonNull [] arguments
) {
if (!(sender instanceof Player player)) {
- return false;
+ warn(sender, "This command can only be executed by in-game players.");
+ return true;
}
return execute(player, command, label, arguments);
diff --git a/bedrock/src/main/java/fr/moussax/bedrock/commands/TabSuggestionBuilder.java b/bedrock/src/main/java/fr/moussax/bedrock/commands/TabSuggestionBuilder.java
index d972a286..548e11d1 100644
--- a/bedrock/src/main/java/fr/moussax/bedrock/commands/TabSuggestionBuilder.java
+++ b/bedrock/src/main/java/fr/moussax/bedrock/commands/TabSuggestionBuilder.java
@@ -6,13 +6,7 @@
import org.bukkit.util.StringUtil;
import org.jspecify.annotations.NonNull;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Locale;
-import java.util.Objects;
-import java.util.Set;
+import java.util.*;
/**
* Produces positional tab completions for commands from {@link CommandArgument} declarations.
@@ -98,7 +92,7 @@ public List onTabComplete(
}
if (entry.rule().matches(args)) {
- aggregatedCandidates.addAll(resolveCandidates(entry.suggestions()));
+ aggregatedCandidates.addAll(resolveCandidates(sender, entry.suggestions()));
}
}
@@ -112,7 +106,7 @@ public List onTabComplete(
return matchedResults;
}
- private List resolveCandidates(List suggestions) {
+ private List resolveCandidates(@NonNull CommandSender sender, List suggestions) {
// ponytail: fast-path check avoiding collection allocation when no dynamic tokens are present
boolean hasDynamicKey = false;
for (String suggestion : suggestions) {
@@ -134,7 +128,7 @@ private List resolveCandidates(List suggestions) {
}
if (suggestion.startsWith("$")) {
- List provided = suggestionRegistry.resolve(suggestion);
+ List provided = suggestionRegistry.resolve(sender, suggestion);
if (provided != null) {
resolvedValues.addAll(provided);
}
@@ -163,15 +157,26 @@ private boolean matches(String[] input) {
return true;
}
- // ponytail: match ancestor chain sequentially leading up to position
int offset = position - path.size();
for (int index = 0; index < path.size(); index++) {
- if (!input[offset + index].equalsIgnoreCase(path.get(index))) {
+ if (!matchesStep(input[offset + index], path.get(index))) {
return false;
}
}
return true;
}
+
+ private static boolean matchesStep(String actual, String expected) {
+ if (expected.contains("|")) {
+ for (String branch : expected.split("\\|")) {
+ if (actual.equalsIgnoreCase(branch.strip())) {
+ return true;
+ }
+ }
+ return false;
+ }
+ return actual.equalsIgnoreCase(expected);
+ }
}
}
diff --git a/bedrock/src/main/java/fr/moussax/bedrock/commands/TabSuggestionRegistry.java b/bedrock/src/main/java/fr/moussax/bedrock/commands/TabSuggestionRegistry.java
index 0ef49ac5..9615ae80 100644
--- a/bedrock/src/main/java/fr/moussax/bedrock/commands/TabSuggestionRegistry.java
+++ b/bedrock/src/main/java/fr/moussax/bedrock/commands/TabSuggestionRegistry.java
@@ -1,5 +1,7 @@
package fr.moussax.bedrock.commands;
+import org.bukkit.command.CommandSender;
+import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;
import java.util.HashMap;
@@ -7,6 +9,7 @@
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
+import java.util.function.Function;
import java.util.function.Supplier;
/**
@@ -16,34 +19,46 @@
*/
public final class TabSuggestionRegistry {
- private final Map>> providers = new HashMap<>();
+ private final Map>> providers = new HashMap<>();
/**
- * Registers a dynamic tab completion provider under a symbolic key.
+ * Registers a sender-aware dynamic tab completion provider under a symbolic key.
*
* @param key symbolic suggestion key (such as {@code $players})
- * @param provider supplier evaluated whenever completion is requested
+ * @param provider function evaluated with the command sender whenever completion is requested
*/
- public void register(String key, Supplier> provider) {
+ public void register(@NonNull String key, @NonNull Function<@Nullable CommandSender, List> provider) {
Objects.requireNonNull(key, "key");
Objects.requireNonNull(provider, "provider");
providers.put(normalizeKey(key), provider);
}
/**
- * Resolves dynamic suggestions registered under a symbolic key.
+ * Registers a context-free dynamic tab completion provider under a symbolic key.
*
- * @param key symbolic suggestion key
+ * @param key symbolic suggestion key (such as {@code $players})
+ * @param provider supplier evaluated whenever completion is requested
+ */
+ public void register(@NonNull String key, @NonNull Supplier> provider) {
+ Objects.requireNonNull(provider, "provider");
+ register(key, _ -> provider.get());
+ }
+
+ /**
+ * Resolves dynamic suggestions registered under a symbolic key for a specific command sender.
+ *
+ * @param sender command sender requesting completion, or {@code null}
+ * @param key symbolic suggestion key
* @return list of suggestions provided by the registered key, or {@code null} when unregistered
*/
- public @Nullable List resolve(String key) {
- Supplier> provider = providers.get(normalizeKey(key));
+ public @Nullable List resolve(@Nullable CommandSender sender, @NonNull String key) {
+ Function<@Nullable CommandSender, List> provider = providers.get(normalizeKey(key));
if (provider == null) {
return null;
}
- List suggestions = provider.get();
+ List suggestions = provider.apply(sender);
if (suggestions == null) {
return List.of();
}
@@ -51,6 +66,16 @@ public void register(String key, Supplier> provider) {
return suggestions.stream().filter(Objects::nonNull).toList();
}
+ /**
+ * Resolves dynamic suggestions registered under a symbolic key without sender context.
+ *
+ * @param key symbolic suggestion key
+ * @return list of suggestions provided by the registered key, or {@code null} when unregistered
+ */
+ public @Nullable List resolve(@NonNull String key) {
+ return resolve(null, key);
+ }
+
// ponytail: kept — centralized case-insensitive key normalization
private static String normalizeKey(String key) {
return key.toLowerCase(Locale.ROOT);
diff --git a/bedrock/src/test/java/fr/moussax/bedrock/commands/TabSuggestionTest.java b/bedrock/src/test/java/fr/moussax/bedrock/commands/TabSuggestionTest.java
new file mode 100644
index 00000000..d4a448bf
--- /dev/null
+++ b/bedrock/src/test/java/fr/moussax/bedrock/commands/TabSuggestionTest.java
@@ -0,0 +1,132 @@
+package fr.moussax.bedrock.commands;
+
+import org.bukkit.command.Command;
+import org.bukkit.command.CommandSender;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Proxy;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class TabSuggestionTest {
+
+ @CommandArgument(position = 0, suggestions = {"start", "stop", "status"})
+ @CommandArgument(position = 1, path = {"start|stop"}, suggestions = {"$targets"})
+ @CommandArgument(position = 1, path = {"status"}, suggestions = {"verbose", "summary"})
+ private static class SampleBranchingCommand {
+ }
+
+ private CommandSender createMockSender(String name, boolean hasPermission) {
+ return (CommandSender) Proxy.newProxyInstance(
+ CommandSender.class.getClassLoader(),
+ new Class>[]{CommandSender.class},
+ (proxy, method, args) -> {
+ if ("hasPermission".equals(method.getName())) {
+ return hasPermission;
+ }
+ if ("getName".equals(method.getName())) {
+ return name;
+ }
+ return null;
+ }
+ );
+ }
+
+ private Command createMockCommand(String name) {
+ return new Command(name) {
+ @Override
+ public boolean execute(CommandSender sender, String commandLabel, String[] args) {
+ return true;
+ }
+ };
+ }
+
+ @Test
+ @DisplayName("Expects TabSuggestionRegistry to register and resolve dynamic suggestions case-insensitively")
+ void testRegistryResolution() {
+ TabSuggestionRegistry registry = new TabSuggestionRegistry();
+
+ registry.register("$STATIC", () -> List.of("alpha", "beta"));
+ registry.register("$sender", (sender) -> sender != null ? List.of(sender.getName()) : List.of());
+
+ assertEquals(List.of("alpha", "beta"), registry.resolve("$static"));
+ assertEquals(List.of("alpha", "beta"), registry.resolve("$STATIC"));
+
+ CommandSender sender = createMockSender("Steve", true);
+ assertEquals(List.of("Steve"), registry.resolve(sender, "$sender"));
+ assertNull(registry.resolve("$unknown"));
+ }
+
+ @Test
+ @DisplayName("Expects TabSuggestionBuilder to complete root argument matching prefix")
+ void testRootCompletion() {
+ TabSuggestionRegistry registry = new TabSuggestionRegistry();
+ TabSuggestionBuilder builder = new TabSuggestionBuilder(SampleBranchingCommand.class, registry);
+
+ CommandSender sender = createMockSender("Steve", true);
+ Command command = createMockCommand("sample");
+
+ List results = builder.onTabComplete(sender, command, "sample", new String[]{"st"});
+ assertEquals(List.of("start", "status", "stop"), results);
+
+ List specific = builder.onTabComplete(sender, command, "sample", new String[]{"sto"});
+ assertEquals(List.of("stop"), specific);
+ }
+
+ @Test
+ @DisplayName("Expects pipe-delimited branch path matching to complete targets for start and stop")
+ void testBranchPathCompletion() {
+ TabSuggestionRegistry registry = new TabSuggestionRegistry();
+ registry.register("$targets", sender -> List.of("server1", "server2"));
+
+ TabSuggestionBuilder builder = new TabSuggestionBuilder(SampleBranchingCommand.class, registry);
+ CommandSender sender = createMockSender("Steve", true);
+ Command command = createMockCommand("sample");
+
+ // After "start"
+ List startResults = builder.onTabComplete(sender, command, "sample", new String[]{"start", "ser"});
+ assertEquals(List.of("server1", "server2"), startResults);
+
+ // After "stop"
+ List stopResults = builder.onTabComplete(sender, command, "sample", new String[]{"stop", "ser"});
+ assertEquals(List.of("server1", "server2"), stopResults);
+
+ // After "status" (different path)
+ List statusResults = builder.onTabComplete(sender, command, "sample", new String[]{"status", "v"});
+ assertEquals(List.of("verbose"), statusResults);
+
+ // Unknown subcommand does not match
+ List unknownResults = builder.onTabComplete(sender, command, "sample", new String[]{"unknown", ""});
+ assertTrue(unknownResults.isEmpty());
+ }
+
+ @Test
+ @DisplayName("Expects PlayerCommand to reject non-player senders with a warning message and return true")
+ void testPlayerCommandNonPlayerRejection() {
+ java.util.concurrent.atomic.AtomicReference sentMessage = new java.util.concurrent.atomic.AtomicReference<>();
+ CommandSender consoleSender = (CommandSender) Proxy.newProxyInstance(
+ CommandSender.class.getClassLoader(),
+ new Class>[]{CommandSender.class},
+ (proxy, method, args) -> {
+ if ("sendMessage".equals(method.getName()) && args != null && args.length > 0) {
+ sentMessage.set(String.valueOf(args[0]));
+ }
+ return null;
+ }
+ );
+
+ PlayerCommand command = new PlayerCommand() {
+ @Override
+ protected boolean execute(org.bukkit.entity.Player player, Command command, String label, String[] arguments) {
+ return true;
+ }
+ };
+
+ boolean handled = command.onCommand(consoleSender, createMockCommand("test"), "test", new String[0]);
+ assertTrue(handled);
+ assertNotNull(sentMessage.get());
+ assertTrue(sentMessage.get().contains("in-game players"));
+ }
+}
diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/commands/impl/GemsCommand.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/commands/impl/GemsCommand.java
index 2eb25632..1c1d4991 100644
--- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/commands/impl/GemsCommand.java
+++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/commands/impl/GemsCommand.java
@@ -11,10 +11,7 @@
import static fr.moussax.bedrock.text.Messenger.warn;
@CommandArgument(position = 0, suggestions = {"add", "remove", "set", "reset", "resetall", "giveall", "help"})
-@CommandArgument(position = 1, path = {"add"}, suggestions = {"$players"})
-@CommandArgument(position = 1, path = {"remove"}, suggestions = {"$players"})
-@CommandArgument(position = 1, path = {"set"}, suggestions = {"$players"})
-@CommandArgument(position = 1, path = {"reset"}, suggestions = {"$players"})
+@CommandArgument(position = 1, path = {"add|remove|set|reset"}, suggestions = {"$players"})
public final class GemsCommand extends AdminCommand {
@Override
diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/registry/CommandsRegistry.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/registry/CommandsRegistry.java
index ef0ff73c..49739172 100644
--- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/registry/CommandsRegistry.java
+++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/registry/CommandsRegistry.java
@@ -66,8 +66,9 @@ public static void register(JavaPlugin plugin) {
private static TabSuggestionRegistry createSuggestionRegistry() {
TabSuggestionRegistry suggestions = new TabSuggestionRegistry();
- suggestions.register("$players", () -> Bukkit.getOnlinePlayers()
+ suggestions.register("$players", sender -> Bukkit.getOnlinePlayers()
.stream()
+ .filter(target -> !(sender instanceof Player player) || player.canSee(target))
.map(Player::getName)
.toList()
);
From 40e60282d628833671d57b6c30d55455a05a343c Mon Sep 17 00:00:00 2001
From: Moussa
Date: Wed, 30 Sep 2026 18:34:58 +0200
Subject: [PATCH 11/23] feat(title): introduce Title API and shared
TextAnimation engine
* Implement high-performance, flicker-free Title API (Title, TitleService, TitleComposer, PersistentTitle, TitleAlert, TitleBuilder, TimeableTitle, and TitlePacketSender) with in-place text updates and automatic ambient restoration.
* Introduce TextAnimation in fr.moussax.bedrock.ui.animation for typewriter, color shimmer waves, pulses, and custom frame sequences across both Title and Actionbar channels.
* Integrate Actionbar.animate(...) and add active animation tracking in ActionbarService and TitleService to suspend background HUD rendering during playback.
* Add comprehensive test suites for title compositions, packet diff-suppression, and text animation pipelines.
---
.../bedrock/scheduling/PluginContext.java | 7 +
.../bedrock/ui/actionbar/Actionbar.java | 48 +
.../ui/actionbar/ActionbarService.java | 29 +
.../bedrock/ui/animation/TextAnimation.java | 1111 +++++++++++++++++
.../bedrock/ui/title/PersistentTitle.java | 133 ++
.../bedrock/ui/title/TimeableTitle.java | 120 ++
.../fr/moussax/bedrock/ui/title/Title.java | 947 ++++++++++++++
.../moussax/bedrock/ui/title/TitleAlert.java | 246 ++++
.../bedrock/ui/title/TitleBuilder.java | 207 +++
.../bedrock/ui/title/TitleComposer.java | 175 +++
.../bedrock/ui/title/TitlePacketSender.java | 110 ++
.../bedrock/ui/title/TitleService.java | 254 ++++
.../ui/animation/TextAnimationTest.java | 242 ++++
.../moussax/bedrock/ui/title/TitleTest.java | 561 +++++++++
.../engine/player/PlayerListener.java | 4 +-
.../blightedSMP/registry/EventsRegistry.java | 2 +
16 files changed, 4195 insertions(+), 1 deletion(-)
create mode 100644 bedrock/src/main/java/fr/moussax/bedrock/ui/animation/TextAnimation.java
create mode 100644 bedrock/src/main/java/fr/moussax/bedrock/ui/title/PersistentTitle.java
create mode 100644 bedrock/src/main/java/fr/moussax/bedrock/ui/title/TimeableTitle.java
create mode 100644 bedrock/src/main/java/fr/moussax/bedrock/ui/title/Title.java
create mode 100644 bedrock/src/main/java/fr/moussax/bedrock/ui/title/TitleAlert.java
create mode 100644 bedrock/src/main/java/fr/moussax/bedrock/ui/title/TitleBuilder.java
create mode 100644 bedrock/src/main/java/fr/moussax/bedrock/ui/title/TitleComposer.java
create mode 100644 bedrock/src/main/java/fr/moussax/bedrock/ui/title/TitlePacketSender.java
create mode 100644 bedrock/src/main/java/fr/moussax/bedrock/ui/title/TitleService.java
create mode 100644 bedrock/src/test/java/fr/moussax/bedrock/ui/animation/TextAnimationTest.java
create mode 100644 bedrock/src/test/java/fr/moussax/bedrock/ui/title/TitleTest.java
diff --git a/bedrock/src/main/java/fr/moussax/bedrock/scheduling/PluginContext.java b/bedrock/src/main/java/fr/moussax/bedrock/scheduling/PluginContext.java
index 1b0398a5..09b6950c 100644
--- a/bedrock/src/main/java/fr/moussax/bedrock/scheduling/PluginContext.java
+++ b/bedrock/src/main/java/fr/moussax/bedrock/scheduling/PluginContext.java
@@ -24,6 +24,13 @@ public static void bind(@NonNull Plugin pluginInstance) {
plugin = pluginInstance;
}
+ /**
+ * Unbinds the current plugin instance from this context.
+ */
+ public static void unbind() {
+ plugin = null;
+ }
+
/**
* Retrieves the bound plugin instance.
*
diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/actionbar/Actionbar.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/actionbar/Actionbar.java
index 9d2fcb88..cc26f3d9 100644
--- a/bedrock/src/main/java/fr/moussax/bedrock/ui/actionbar/Actionbar.java
+++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/actionbar/Actionbar.java
@@ -1,12 +1,16 @@
package fr.moussax.bedrock.ui.actionbar;
+import fr.moussax.bedrock.ui.animation.TextAnimation;
import net.md_5.bungee.api.ChatMessageType;
import net.md_5.bungee.api.chat.TextComponent;
import org.bukkit.entity.Player;
import org.bukkit.plugin.Plugin;
+import org.bukkit.scheduler.BukkitTask;
import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
import java.time.Duration;
+import java.util.Collection;
import java.util.function.IntFunction;
import java.util.function.Supplier;
@@ -428,4 +432,48 @@ public static void clearAlerts(@NonNull Player player) {
service.clearAlerts(player);
}
}
+
+ /**
+ * Plays a high-performance text animation on the player's action bar.
+ *
+ * @param player target player
+ * @param animation text animation to play
+ * @return the running Bukkit task handle
+ */
+ public static BukkitTask animate(@NonNull Player player, @NonNull TextAnimation animation) {
+ return animate(player, animation, null);
+ }
+
+ /**
+ * Plays a high-performance text animation on the player's action bar with a completion callback.
+ *
+ * @param player target player
+ * @param animation text animation to play
+ * @param onComplete optional action executed upon animation completion
+ * @return the running Bukkit task handle
+ */
+ public static BukkitTask animate(@NonNull Player player, @NonNull TextAnimation animation, @Nullable Runnable onComplete) {
+ return animation.playActionbar(player, onComplete);
+ }
+
+ /**
+ * Plays a text animation across a collection of players' action bars.
+ *
+ * @param players target players
+ * @param animation text animation to play
+ */
+ public static void animate(@NonNull Collection extends Player> players, @NonNull TextAnimation animation) {
+ animate(players, animation, null);
+ }
+
+ /**
+ * Plays a text animation across a collection of players' action bars with a completion callback.
+ *
+ * @param players target players
+ * @param animation text animation to play
+ * @param onComplete optional action executed when all animations complete
+ */
+ public static void animate(@NonNull Collection extends Player> players, @NonNull TextAnimation animation, @Nullable Runnable onComplete) {
+ animation.playActionbar(players, onComplete);
+ }
}
diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/actionbar/ActionbarService.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/actionbar/ActionbarService.java
index 04350ccb..a69381b6 100644
--- a/bedrock/src/main/java/fr/moussax/bedrock/ui/actionbar/ActionbarService.java
+++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/actionbar/ActionbarService.java
@@ -38,12 +38,14 @@ public final class ActionbarService implements Listener {
@Setter
private static volatile ActionbarService instance;
+ @Getter
private final Plugin plugin;
private final Map composers = new ConcurrentHashMap<>();
private final Map globalSections = new ConcurrentHashMap<>();
private final Map> pluginSections = new ConcurrentHashMap<>();
private final Map lastSentTexts = new ConcurrentHashMap<>();
private final Map lastSentTimestamps = new ConcurrentHashMap<>();
+ private final Set animatingPlayers = ConcurrentHashMap.newKeySet();
private volatile String separator = " ";
private BukkitTask tickerTask;
@@ -441,6 +443,31 @@ public void onQuit(PlayerQuitEvent event) {
handleQuit(event.getPlayer());
}
+ /**
+ * Marks whether a player currently has an active text animation running on their action bar.
+ * When animating, periodic HUD rendering is temporarily suspended to prevent frame stutter.
+ *
+ * @param uuid target player UUID
+ * @param animating whether an animation is currently executing
+ */
+ public void setAnimating(@NonNull UUID uuid, boolean animating) {
+ if (animating) {
+ animatingPlayers.add(uuid);
+ } else {
+ animatingPlayers.remove(uuid);
+ }
+ }
+
+ /**
+ * Checks if a player currently has an active text animation running on their action bar.
+ *
+ * @param uuid target player UUID
+ * @return true if an animation is active
+ */
+ public boolean isAnimating(@NonNull UUID uuid) {
+ return animatingPlayers.contains(uuid);
+ }
+
/**
* Removes and cleans up cached composer and tracking state for a player.
*
@@ -448,6 +475,7 @@ public void onQuit(PlayerQuitEvent event) {
*/
public void handleQuit(@NonNull Player player) {
UUID uuid = player.getUniqueId();
+ animatingPlayers.remove(uuid);
composers.remove(uuid);
lastSentTexts.remove(uuid);
lastSentTimestamps.remove(uuid);
@@ -461,6 +489,7 @@ public void handleQuit(@NonNull Player player) {
*/
public void renderPlayer(@NonNull Player player) {
if (!player.isOnline()) return;
+ if (isAnimating(player.getUniqueId())) return;
ActionbarComposer composer = getOrCreateComposer(player.getUniqueId());
String content = composer.compile(player);
diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/animation/TextAnimation.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/animation/TextAnimation.java
new file mode 100644
index 00000000..ffe56774
--- /dev/null
+++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/animation/TextAnimation.java
@@ -0,0 +1,1111 @@
+package fr.moussax.bedrock.ui.animation;
+
+import fr.moussax.bedrock.scheduling.PluginContext;
+import fr.moussax.bedrock.ui.actionbar.ActionbarService;
+import fr.moussax.bedrock.ui.title.TimeableTitle;
+import fr.moussax.bedrock.ui.title.TitlePacketSender;
+import fr.moussax.bedrock.ui.title.TitleService;
+import net.md_5.bungee.api.ChatMessageType;
+import net.md_5.bungee.api.chat.TextComponent;
+import org.bukkit.Bukkit;
+import org.bukkit.Sound;
+import org.bukkit.entity.Player;
+import org.bukkit.plugin.Plugin;
+import org.bukkit.scheduler.BukkitRunnable;
+import org.bukkit.scheduler.BukkitTask;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.BiConsumer;
+import java.util.function.Consumer;
+
+/**
+ * High-performance, flicker-free text animation engine for Minecraft user interfaces.
+ *
+ * Supports sequenced frame animations across multiple UI channels including Action Bars,
+ * Titles, Subtitles, and custom UI consumers. Eliminates visual stutter and client opacity resets
+ * through in-place packet updates and automatic background HUD suspension during playback.
+ */
+public class TextAnimation {
+
+ /**
+ * An individual frame within a text animation sequence.
+ *
+ * @param text primary frame text (rendered on action bar or main title)
+ * @param subtitle optional subtitle text (used when rendering as a title)
+ * @param sound optional sound played when this frame renders
+ * @param soundPitch pitch for the frame sound
+ */
+ public record Frame(
+ @NonNull String text,
+ @Nullable String subtitle,
+ @Nullable Sound sound,
+ float soundPitch
+ ) {
+ public Frame(@NonNull String text) {
+ this(text, null, null, 1.0f);
+ }
+
+ public Frame(@NonNull String text, @Nullable Sound sound, float soundPitch) {
+ this(text, null, sound, soundPitch);
+ }
+
+ public Frame(@NonNull String text, @Nullable String subtitle) {
+ this(text, subtitle, null, 1.0f);
+ }
+
+ /**
+ * Alias for {@link #text()} suited for title-oriented contexts.
+ *
+ * @return primary text
+ */
+ public String title() {
+ return text;
+ }
+ }
+
+ private final List frames;
+ private final long tickInterval;
+ private final TimeableTitle finalTimes;
+ private final Duration finalStay;
+ private final Consumer onComplete;
+ private final TextAnimation nextAnimation;
+
+ protected TextAnimation(
+ List frames,
+ long tickInterval,
+ TimeableTitle finalTimes,
+ Duration finalStay,
+ Consumer onComplete,
+ TextAnimation nextAnimation
+ ) {
+ this.frames = List.copyOf(frames);
+ this.tickInterval = Math.max(1L, tickInterval);
+ this.finalTimes = finalTimes != null ? finalTimes : TimeableTitle.DEFAULT;
+ this.finalStay = finalStay != null ? finalStay : Duration.ofMillis(this.finalTimes.stay() * 50L);
+ this.onComplete = onComplete;
+ this.nextAnimation = nextAnimation;
+ }
+
+ /**
+ * Returns an unmodifiable list of all frames configured for this animation stage.
+ *
+ * @return animation frames
+ */
+ @NonNull
+ public List frames() {
+ return frames;
+ }
+
+ /**
+ * Returns the tick delay between consecutive frame dispatches.
+ *
+ * @return tick interval
+ */
+ public long tickInterval() {
+ return tickInterval;
+ }
+
+ /**
+ * Returns the timing parameters applied to title displays upon final frame completion.
+ *
+ * @return final title timings
+ */
+ @NonNull
+ public TimeableTitle finalTimes() {
+ return finalTimes;
+ }
+
+ /**
+ * Returns the duration the completed text persists on the action bar before restoring background HUDs.
+ *
+ * @return final stay duration
+ */
+ @NonNull
+ public Duration finalStay() {
+ return finalStay;
+ }
+
+ /**
+ * Returns the completion callback invoked for the target player upon completing this stage.
+ *
+ * @return completion callback or null
+ */
+ @Nullable
+ public Consumer onComplete() {
+ return onComplete;
+ }
+
+ /**
+ * Returns the chained next animation stage, if configured.
+ *
+ * @return next animation stage or null
+ */
+ @Nullable
+ public TextAnimation nextAnimation() {
+ return nextAnimation;
+ }
+
+ /**
+ * Creates a new fluent {@link Builder} to construct a custom text animation sequence.
+ *
+ * @return a new animation builder
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Chains this animation to be followed seamlessly by another animation upon completion.
+ *
+ * @param next the next animation to play
+ * @return chained animation sequence
+ */
+ public TextAnimation then(@NonNull TextAnimation next) {
+ Objects.requireNonNull(next, "next animation cannot be null");
+ TextAnimation chainedNext = (this.nextAnimation != null) ? this.nextAnimation.then(next) : next;
+ return new TextAnimation(this.frames, this.tickInterval, TimeableTitle.ANIMATION_FRAME, Duration.ZERO, this.onComplete, chainedNext);
+ }
+
+ /**
+ * Constructs a typewriter text-reveal animation where characters appear incrementally.
+ *
+ * @param fullText the complete text to reveal
+ * @param ticksPerChar tick delay between revealing each character
+ * @param clickSound sound played when each letter appears, or null for silent
+ * @return configured typewriter animation
+ */
+ public static TextAnimation typewriter(
+ @NonNull String fullText,
+ long ticksPerChar,
+ @Nullable Sound clickSound
+ ) {
+ return typewriter(fullText, null, ticksPerChar, clickSound, TimeableTitle.DEFAULT);
+ }
+
+ /**
+ * Constructs a typewriter text-reveal animation with a custom action bar final stay duration.
+ *
+ * @param fullText the complete text to reveal
+ * @param ticksPerChar tick delay between revealing each character
+ * @param clickSound sound played when each letter appears, or null for silent
+ * @param finalStay duration the completed text persists before restoring normal HUD
+ * @return configured typewriter animation
+ */
+ public static TextAnimation typewriter(
+ @NonNull String fullText,
+ long ticksPerChar,
+ @Nullable Sound clickSound,
+ @NonNull Duration finalStay
+ ) {
+ Builder builder = builder().interval(ticksPerChar).finalStay(finalStay);
+ buildTypewriterFrames(builder, fullText, null, clickSound);
+ return builder.build();
+ }
+
+ /**
+ * Constructs a typewriter text-reveal animation with static subtitle and title timings.
+ *
+ * @param fullTitle the complete title text to reveal
+ * @param subtitle static subtitle displayed underneath
+ * @param ticksPerChar tick delay between revealing each character
+ * @param clickSound sound played when each letter appears, or null for silent
+ * @param finalTimes display timings for the completed title
+ * @return configured typewriter animation
+ */
+ public static TextAnimation typewriter(
+ @NonNull String fullTitle,
+ @Nullable String subtitle,
+ long ticksPerChar,
+ @Nullable Sound clickSound,
+ @NonNull TimeableTitle finalTimes
+ ) {
+ Builder builder = builder().interval(ticksPerChar).finalTimes(finalTimes);
+ buildTypewriterFrames(builder, fullTitle, subtitle, clickSound);
+ return builder.build();
+ }
+
+ private static void buildTypewriterFrames(Builder builder, String text, @Nullable String subtitle, @Nullable Sound sound) {
+ String stripped = text.replaceAll("§[0-9a-fk-orA-FK-OR]", "");
+ int visibleLength = stripped.length();
+
+ if (visibleLength == 0) {
+ builder.frame(text, subtitle, sound, 1.2f);
+ } else {
+ for (int i = 1; i <= visibleLength; i++) {
+ String partial = substringWithColors(text, i);
+ builder.frame(partial, subtitle, sound, 1.2f);
+ }
+ }
+ }
+
+ /**
+ * Constructs a dynamic color-shimmer wave animation sliding across the letters of a text.
+ *
+ * @param text raw text
+ * @param baseColor base color code (e.g. "§5")
+ * @param highlightColor highlight shine peak color code (e.g. "§f")
+ * @param bold whether the text should be bold
+ * @param cycles number of complete shimmer wave cycles
+ * @return configured shimmer animation
+ */
+ public static TextAnimation shimmer(
+ @NonNull String text,
+ @NonNull String baseColor,
+ @NonNull String highlightColor,
+ boolean bold,
+ int cycles
+ ) {
+ return shimmer(text, null, baseColor, "§d", highlightColor, bold, cycles, TimeableTitle.DEFAULT);
+ }
+
+ /**
+ * Constructs a dynamic color-shimmer wave animation with intermediate mid-tone color.
+ *
+ * @param text raw text
+ * @param baseColor base color code (e.g. "§5")
+ * @param midColor intermediate shimmer color code (e.g. "§d")
+ * @param highlightColor highlight shine peak color code (e.g. "§f")
+ * @param bold whether the text should be bold
+ * @param cycles number of complete shimmer wave cycles
+ * @return configured shimmer animation
+ */
+ public static TextAnimation shimmer(
+ @NonNull String text,
+ @NonNull String baseColor,
+ @NonNull String midColor,
+ @NonNull String highlightColor,
+ boolean bold,
+ int cycles
+ ) {
+ return shimmer(text, null, baseColor, midColor, highlightColor, bold, cycles, TimeableTitle.DEFAULT);
+ }
+
+ /**
+ * Constructs a color-shimmer animation with a custom action bar final stay duration.
+ *
+ * @param text raw text
+ * @param baseColor base color code
+ * @param highlightColor highlight shine color code
+ * @param bold whether the text should be bold
+ * @param cycles number of complete shimmer wave cycles
+ * @param finalStay duration the completed text persists before restoring normal HUD
+ * @return configured shimmer animation
+ */
+ public static TextAnimation shimmer(
+ @NonNull String text,
+ @NonNull String baseColor,
+ @NonNull String highlightColor,
+ boolean bold,
+ int cycles,
+ @NonNull Duration finalStay
+ ) {
+ return shimmer(text, null, baseColor, "§d", highlightColor, bold, cycles, TimeableTitle.stay(finalStay));
+ }
+
+ /**
+ * Constructs a color-shimmer animation with intermediate mid-tone and a custom final stay duration.
+ *
+ * @param text raw text
+ * @param baseColor base color code
+ * @param midColor intermediate shimmer color code
+ * @param highlightColor highlight shine color code
+ * @param bold whether the text should be bold
+ * @param cycles number of complete shimmer wave cycles
+ * @param finalStay duration the completed text persists before restoring normal HUD
+ * @return configured shimmer animation
+ */
+ public static TextAnimation shimmer(
+ @NonNull String text,
+ @NonNull String baseColor,
+ @NonNull String midColor,
+ @NonNull String highlightColor,
+ boolean bold,
+ int cycles,
+ @NonNull Duration finalStay
+ ) {
+ return shimmer(text, null, baseColor, midColor, highlightColor, bold, cycles, TimeableTitle.stay(finalStay));
+ }
+
+ /**
+ * Constructs a dynamic color-shimmer wave animation with subtitle and title timings.
+ *
+ * @param text raw text
+ * @param subtitle static subtitle displayed underneath
+ * @param baseColor base color code
+ * @param highlightColor highlight shine peak color code
+ * @param bold whether the text should be bold
+ * @param cycles number of complete shimmer wave cycles
+ * @param finalTimes display timings for the finale
+ * @return configured shimmer animation
+ */
+ public static TextAnimation shimmer(
+ @NonNull String text,
+ @Nullable String subtitle,
+ @NonNull String baseColor,
+ @NonNull String highlightColor,
+ boolean bold,
+ int cycles,
+ @NonNull TimeableTitle finalTimes
+ ) {
+ return shimmer(text, subtitle, baseColor, "§d", highlightColor, bold, cycles, finalTimes);
+ }
+
+ /**
+ * Constructs a dynamic color-shimmer wave animation with full color palette, subtitle, and timings.
+ *
+ * @param text raw text
+ * @param subtitle static subtitle displayed underneath
+ * @param baseColor base color code
+ * @param midColor intermediate shimmer color code
+ * @param highlightColor highlight shine peak color code
+ * @param bold whether the text should be bold
+ * @param cycles number of complete shimmer wave cycles
+ * @param finalTimes display timings for the finale
+ * @return configured shimmer animation
+ */
+ public static TextAnimation shimmer(
+ @NonNull String text,
+ @Nullable String subtitle,
+ @NonNull String baseColor,
+ @NonNull String midColor,
+ @NonNull String highlightColor,
+ boolean bold,
+ int cycles,
+ @NonNull TimeableTitle finalTimes
+ ) {
+ Builder builder = builder().interval(1L).finalTimes(finalTimes);
+ String prefix = bold ? "§l" : "";
+ int length = text.length();
+
+ for (int cycle = 0; cycle < cycles; cycle++) {
+ for (int highlight = -2; highlight < length + 2; highlight++) {
+ StringBuilder frameBuilder = new StringBuilder();
+ for (int index = 0; index < length; index++) {
+ char character = text.charAt(index);
+ if (index == highlight) {
+ frameBuilder.append(highlightColor).append(prefix).append(character);
+ } else if (Math.abs(index - highlight) == 1) {
+ frameBuilder.append(midColor).append(prefix).append(character);
+ } else {
+ frameBuilder.append(baseColor).append(prefix).append(character);
+ }
+ }
+ builder.frame(frameBuilder.toString(), subtitle);
+ }
+ }
+
+ return builder.build();
+ }
+
+ /**
+ * Constructs a flashing pulse animation alternating between a primary and highlight color.
+ *
+ * @param text message text
+ * @param primaryColor standard color code (e.g. "§c")
+ * @param flashColor bright pulse color code (e.g. "§f")
+ * @param ticksPerPulse tick delay per pulse phase
+ * @param pulses number of complete pulses
+ * @param sound sound played on each flash, or null for silent
+ * @return configured pulse animation
+ */
+ public static TextAnimation pulse(
+ @NonNull String text,
+ @NonNull String primaryColor,
+ @NonNull String flashColor,
+ long ticksPerPulse,
+ int pulses,
+ @Nullable Sound sound
+ ) {
+ Builder builder = builder().interval(ticksPerPulse);
+ for (int i = 0; i < pulses; i++) {
+ builder.frame(flashColor + text, sound, 1.2f);
+ builder.frame(primaryColor + text, null, 1.0f);
+ }
+ return builder.build();
+ }
+
+ // =========================================================================
+ // Actionbar Playback
+ // =========================================================================
+
+ /**
+ * Plays this text animation on the target player's action bar.
+ *
+ * @param player target player
+ * @return the running Bukkit task handle
+ */
+ public BukkitTask playActionbar(@NonNull Player player) {
+ return playActionbar(player, null);
+ }
+
+ /**
+ * Plays this text animation on the target player's action bar with a completion callback.
+ *
+ * @param player target player
+ * @param extraOnComplete additional action executed upon completion
+ * @return the running Bukkit task handle
+ */
+ public BukkitTask playActionbar(@NonNull Player player, @Nullable Runnable extraOnComplete) {
+ return playActionbar(resolvePlugin(), player, extraOnComplete);
+ }
+
+ /**
+ * Plays this text animation on the target player's action bar using the specified plugin for scheduling.
+ *
+ * @param plugin owning plugin
+ * @param player target player
+ * @return the running Bukkit task handle
+ */
+ public BukkitTask playActionbar(@NonNull Plugin plugin, @NonNull Player player) {
+ return playActionbar(plugin, player, null);
+ }
+
+ /**
+ * Plays this text animation on the target player's action bar using the specified plugin and completion callback.
+ *
+ * @param plugin owning plugin
+ * @param player target player
+ * @param extraOnComplete additional action executed upon completion
+ * @return the running Bukkit task handle
+ */
+ public BukkitTask playActionbar(@NonNull Plugin plugin, @NonNull Player player, @Nullable Runnable extraOnComplete) {
+ Objects.requireNonNull(plugin, "plugin cannot be null");
+ Objects.requireNonNull(player, "player cannot be null");
+
+ if (frames.isEmpty()) {
+ if (nextAnimation != null) {
+ return nextAnimation.playActionbar(plugin, player, extraOnComplete);
+ }
+ if (extraOnComplete != null) extraOnComplete.run();
+ return null;
+ }
+
+ if (!player.isOnline()) {
+ if (extraOnComplete != null) extraOnComplete.run();
+ return null;
+ }
+
+ ActionbarService service = ActionbarService.getInstance();
+ if (service != null) {
+ service.setAnimating(player.getUniqueId(), true);
+ }
+
+ Frame firstFrame = frames.getFirst();
+ sendRawActionbar(player, firstFrame.text());
+ if (firstFrame.sound() != null) {
+ player.playSound(player.getLocation(), firstFrame.sound(), 1.0f, firstFrame.soundPitch());
+ }
+
+ if (frames.size() == 1) {
+ if (nextAnimation != null) {
+ if (onComplete != null) onComplete.accept(player);
+ return nextAnimation.playActionbar(plugin, player, extraOnComplete);
+ }
+ if (onComplete != null) onComplete.accept(player);
+ long stayTicks = Math.max(0L, finalStay.toMillis() / 50L);
+ if (Bukkit.getServer() != null && stayTicks > 0) {
+ return Bukkit.getScheduler().runTaskLater(plugin, () -> finishActionbar(player, extraOnComplete), stayTicks);
+ } else {
+ finishActionbar(player, extraOnComplete);
+ return null;
+ }
+ }
+
+ return new BukkitRunnable() {
+ private int frameIndex = 1;
+
+ @Override
+ public void run() {
+ if (!player.isOnline()) {
+ cleanupActionbar(player);
+ cancel();
+ return;
+ }
+
+ if (frameIndex < frames.size() - 1) {
+ Frame frame = frames.get(frameIndex);
+ sendRawActionbar(player, frame.text());
+ if (frame.sound() != null) {
+ player.playSound(player.getLocation(), frame.sound(), 1.0f, frame.soundPitch());
+ }
+ frameIndex++;
+ } else {
+ Frame last = frames.getLast();
+ cancel();
+
+ sendRawActionbar(player, last.text());
+ if (last.sound() != null) {
+ player.playSound(player.getLocation(), last.sound(), 1.0f, last.soundPitch());
+ }
+ if (onComplete != null) {
+ onComplete.accept(player);
+ }
+
+ if (nextAnimation != null) {
+ nextAnimation.playActionbar(plugin, player, extraOnComplete);
+ } else {
+ long stayTicks = Math.max(0L, finalStay.toMillis() / 50L);
+ if (Bukkit.getServer() != null && stayTicks > 0) {
+ Bukkit.getScheduler().runTaskLater(plugin, () -> finishActionbar(player, extraOnComplete), stayTicks);
+ } else {
+ finishActionbar(player, extraOnComplete);
+ }
+ }
+ }
+ }
+ }.runTaskTimer(plugin, tickInterval, tickInterval);
+ }
+
+ /**
+ * Plays this text animation across a collection of players' action bars.
+ *
+ * @param players target players
+ */
+ public void playActionbar(@NonNull Collection extends Player> players) {
+ playActionbar(players, null);
+ }
+
+ /**
+ * Plays this text animation across a collection of players' action bars with a completion callback.
+ *
+ * @param players target players
+ * @param extraOnComplete action executed when all players complete the animation
+ */
+ public void playActionbar(@NonNull Collection extends Player> players, @Nullable Runnable extraOnComplete) {
+ playActionbar(resolvePlugin(), players, extraOnComplete);
+ }
+
+ /**
+ * Plays this text animation across a collection of players' action bars using the specified plugin.
+ *
+ * @param plugin owning plugin
+ * @param players target players
+ * @param extraOnComplete action executed when all players complete the animation
+ */
+ public void playActionbar(
+ @NonNull Plugin plugin,
+ @NonNull Collection extends Player> players,
+ @Nullable Runnable extraOnComplete
+ ) {
+ Objects.requireNonNull(plugin, "plugin cannot be null");
+ Objects.requireNonNull(players, "players cannot be null");
+
+ List extends Player> targetPlayers = players.stream()
+ .filter(p -> p != null && p.isOnline())
+ .toList();
+
+ if (targetPlayers.isEmpty()) {
+ if (extraOnComplete != null) extraOnComplete.run();
+ return;
+ }
+
+ AtomicInteger remaining = new AtomicInteger(targetPlayers.size());
+ Runnable barrier = (extraOnComplete != null) ? () -> {
+ if (remaining.decrementAndGet() == 0) {
+ extraOnComplete.run();
+ }
+ } : null;
+
+ for (Player player : targetPlayers) {
+ playActionbar(plugin, player, barrier);
+ }
+ }
+
+ /**
+ * Delegate interface for custom action bar packet dispatching or test assertions.
+ */
+ @FunctionalInterface
+ public interface ActionbarHandler {
+ void sendActionbar(@NonNull Player player, @NonNull String text);
+ }
+
+ private static volatile ActionbarHandler customActionbarHandler;
+
+ /**
+ * Overrides the action bar packet handler for testing or custom packet pipelines.
+ *
+ * @param handler custom action bar handler, or null to revert to default Spigot dispatch
+ */
+ public static void setCustomActionbarHandler(@Nullable ActionbarHandler handler) {
+ customActionbarHandler = handler;
+ }
+
+ private static void sendRawActionbar(@NonNull Player player, @NonNull String text) {
+ ActionbarHandler handler = customActionbarHandler;
+ if (handler != null) {
+ handler.sendActionbar(player, text);
+ return;
+ }
+ player.spigot().sendMessage(ChatMessageType.ACTION_BAR, new TextComponent(text));
+ }
+
+ private static void finishActionbar(@NonNull Player player, @Nullable Runnable extraOnComplete) {
+ ActionbarService service = ActionbarService.getInstance();
+ if (service != null) {
+ service.setAnimating(player.getUniqueId(), false);
+ service.renderPlayer(player);
+ }
+ if (extraOnComplete != null) {
+ extraOnComplete.run();
+ }
+ }
+
+ private static void cleanupActionbar(@NonNull Player player) {
+ ActionbarService service = ActionbarService.getInstance();
+ if (service != null) {
+ service.setAnimating(player.getUniqueId(), false);
+ }
+ }
+
+ // =========================================================================
+ // Title Playback
+ // =========================================================================
+
+ /**
+ * Plays this text animation on the target player as an in-place title sequence.
+ *
+ * @param player target player
+ * @return the running Bukkit task handle
+ */
+ public BukkitTask playTitle(@NonNull Player player) {
+ return playTitle(player, null);
+ }
+
+ /**
+ * Plays this text animation on the target player as a title sequence with a completion callback.
+ *
+ * @param player target player
+ * @param extraOnComplete additional action executed upon completion
+ * @return the running Bukkit task handle
+ */
+ public BukkitTask playTitle(@NonNull Player player, @Nullable Runnable extraOnComplete) {
+ return playTitle(resolvePlugin(), player, extraOnComplete);
+ }
+
+ /**
+ * Plays this text animation on the target player as a title sequence using the specified plugin for scheduling.
+ *
+ * @param plugin owning plugin
+ * @param player target player
+ * @return the running Bukkit task handle
+ */
+ public BukkitTask playTitle(@NonNull Plugin plugin, @NonNull Player player) {
+ return playTitle(plugin, player, null);
+ }
+
+ /**
+ * Plays this text animation on the target player as a title sequence using the specified plugin and completion callback.
+ *
+ * @param plugin owning plugin
+ * @param player target player
+ * @param extraOnComplete additional action executed upon completion
+ * @return the running Bukkit task handle
+ */
+ public BukkitTask playTitle(@NonNull Plugin plugin, @NonNull Player player, @Nullable Runnable extraOnComplete) {
+ Objects.requireNonNull(plugin, "plugin cannot be null");
+ Objects.requireNonNull(player, "player cannot be null");
+
+ if (frames.isEmpty()) {
+ if (nextAnimation != null) {
+ return nextAnimation.playTitle(plugin, player, extraOnComplete);
+ }
+ if (extraOnComplete != null) extraOnComplete.run();
+ return null;
+ }
+
+ if (!player.isOnline()) {
+ if (extraOnComplete != null) extraOnComplete.run();
+ return null;
+ }
+
+ TitleService titleService = TitleService.getInstance();
+ if (titleService != null) {
+ titleService.setAnimating(player.getUniqueId(), true);
+ }
+
+ // Initialize display with sufficient stay to cover the entire sequence with zero fade-in
+ int totalTicks = (int) (frames.size() * tickInterval);
+ Frame firstFrame = frames.getFirst();
+ TitlePacketSender.sendFull(player, firstFrame.title(), firstFrame.subtitle(), 0, totalTicks + 60, 0);
+
+ if (firstFrame.sound() != null) {
+ player.playSound(player.getLocation(), firstFrame.sound(), 1.0f, firstFrame.soundPitch());
+ }
+
+ if (frames.size() == 1) {
+ if (nextAnimation != null) {
+ if (onComplete != null) onComplete.accept(player);
+ return nextAnimation.playTitle(plugin, player, extraOnComplete);
+ }
+ TitlePacketSender.sendFull(player, firstFrame.title(), firstFrame.subtitle(),
+ finalTimes.fadeIn(), finalTimes.stay(), finalTimes.fadeOut());
+ if (onComplete != null) onComplete.accept(player);
+
+ long finishDelay = finalTimes.stay() + finalTimes.fadeOut();
+ if (Bukkit.getServer() != null && finishDelay > 0) {
+ return Bukkit.getScheduler().runTaskLater(plugin, () -> finishTitle(player, extraOnComplete), finishDelay);
+ } else {
+ finishTitle(player, extraOnComplete);
+ return null;
+ }
+ }
+
+ return new BukkitRunnable() {
+ private int frameIndex = 1;
+
+ @Override
+ public void run() {
+ if (!player.isOnline()) {
+ cleanupTitle(player);
+ cancel();
+ return;
+ }
+
+ if (frameIndex < frames.size() - 1) {
+ Frame frame = frames.get(frameIndex);
+ // Pure text update: no ClientboundSetTitlesAnimationPacket is sent, preventing opacity reset
+ TitlePacketSender.sendTextOnly(player, frame.title(), frame.subtitle());
+ if (frame.sound() != null) {
+ player.playSound(player.getLocation(), frame.sound(), 1.0f, frame.soundPitch());
+ }
+ frameIndex++;
+ } else {
+ Frame last = frames.getLast();
+ cancel();
+
+ if (nextAnimation != null) {
+ // In-place transfer to the next animation
+ TitlePacketSender.sendTextOnly(player, last.title(), last.subtitle());
+ if (last.sound() != null) {
+ player.playSound(player.getLocation(), last.sound(), 1.0f, last.soundPitch());
+ }
+ if (onComplete != null) {
+ onComplete.accept(player);
+ }
+ nextAnimation.playTitle(plugin, player, extraOnComplete);
+ } else {
+ // Climax frame: apply final sustain and fade-out timings
+ TitlePacketSender.sendFull(player, last.title(), last.subtitle(),
+ finalTimes.fadeIn(), finalTimes.stay(), finalTimes.fadeOut());
+ if (last.sound() != null) {
+ player.playSound(player.getLocation(), last.sound(), 1.0f, last.soundPitch());
+ }
+ if (onComplete != null) {
+ onComplete.accept(player);
+ }
+
+ long finishDelay = finalTimes.stay() + finalTimes.fadeOut();
+ if (Bukkit.getServer() != null && finishDelay > 0) {
+ Bukkit.getScheduler().runTaskLater(plugin, () -> finishTitle(player, extraOnComplete), finishDelay);
+ } else {
+ finishTitle(player, extraOnComplete);
+ }
+ }
+ }
+ }
+ }.runTaskTimer(plugin, tickInterval, tickInterval);
+ }
+
+ /**
+ * Plays this text animation as a title across a collection of players.
+ *
+ * @param players target players
+ */
+ public void playTitle(@NonNull Collection extends Player> players) {
+ playTitle(players, null);
+ }
+
+ /**
+ * Plays this text animation as a title across a collection of players with a completion callback.
+ *
+ * @param players target players
+ * @param extraOnComplete action executed when all players complete the animation
+ */
+ public void playTitle(@NonNull Collection extends Player> players, @Nullable Runnable extraOnComplete) {
+ playTitle(resolvePlugin(), players, extraOnComplete);
+ }
+
+ /**
+ * Plays this text animation as a title across a collection of players using the specified plugin.
+ *
+ * @param plugin owning plugin
+ * @param players target players
+ * @param extraOnComplete action executed when all players complete the animation
+ */
+ public void playTitle(
+ @NonNull Plugin plugin,
+ @NonNull Collection extends Player> players,
+ @Nullable Runnable extraOnComplete
+ ) {
+ Objects.requireNonNull(plugin, "plugin cannot be null");
+ Objects.requireNonNull(players, "players cannot be null");
+
+ List extends Player> targetPlayers = players.stream()
+ .filter(p -> p != null && p.isOnline())
+ .toList();
+
+ if (targetPlayers.isEmpty()) {
+ if (extraOnComplete != null) extraOnComplete.run();
+ return;
+ }
+
+ AtomicInteger remaining = new AtomicInteger(targetPlayers.size());
+ Runnable barrier = (extraOnComplete != null) ? () -> {
+ if (remaining.decrementAndGet() == 0) {
+ extraOnComplete.run();
+ }
+ } : null;
+
+ for (Player player : targetPlayers) {
+ playTitle(plugin, player, barrier);
+ }
+ }
+
+ private static void finishTitle(@NonNull Player player, @Nullable Runnable extraOnComplete) {
+ TitleService service = TitleService.getInstance();
+ if (service != null) {
+ service.setAnimating(player.getUniqueId(), false);
+ service.renderPlayer(player);
+ }
+ if (extraOnComplete != null) {
+ extraOnComplete.run();
+ }
+ }
+
+ private static void cleanupTitle(@NonNull Player player) {
+ TitleService service = TitleService.getInstance();
+ if (service != null) {
+ service.setAnimating(player.getUniqueId(), false);
+ }
+ }
+
+ // =========================================================================
+ // Generic Frame Consumer Playback
+ // =========================================================================
+
+ /**
+ * Plays this text animation dispatching each rendered frame to a custom consumer.
+ *
+ * @param player target player
+ * @param frameConsumer consumer receiving each frame during playback
+ * @return the running Bukkit task handle
+ */
+ public BukkitTask play(@NonNull Player player, @NonNull BiConsumer frameConsumer) {
+ return play(player, frameConsumer, null);
+ }
+
+ /**
+ * Plays this text animation dispatching each rendered frame to a custom consumer with completion callback.
+ *
+ * @param player target player
+ * @param frameConsumer consumer receiving each frame during playback
+ * @param extraOnComplete action executed upon completion
+ * @return the running Bukkit task handle
+ */
+ public BukkitTask play(
+ @NonNull Player player,
+ @NonNull BiConsumer frameConsumer,
+ @Nullable Runnable extraOnComplete
+ ) {
+ return play(resolvePlugin(), player, frameConsumer, extraOnComplete);
+ }
+
+ /**
+ * Plays this text animation dispatching each rendered frame to a custom consumer using the specified plugin.
+ *
+ * @param plugin owning plugin
+ * @param player target player
+ * @param frameConsumer consumer receiving each frame during playback
+ * @param extraOnComplete action executed upon completion
+ * @return the running Bukkit task handle
+ */
+ public BukkitTask play(
+ @NonNull Plugin plugin,
+ @NonNull Player player,
+ @NonNull BiConsumer frameConsumer,
+ @Nullable Runnable extraOnComplete
+ ) {
+ Objects.requireNonNull(plugin, "plugin cannot be null");
+ Objects.requireNonNull(player, "player cannot be null");
+ Objects.requireNonNull(frameConsumer, "frameConsumer cannot be null");
+
+ if (frames.isEmpty()) {
+ if (nextAnimation != null) {
+ return nextAnimation.play(plugin, player, frameConsumer, extraOnComplete);
+ }
+ if (extraOnComplete != null) extraOnComplete.run();
+ return null;
+ }
+
+ if (!player.isOnline()) {
+ if (extraOnComplete != null) extraOnComplete.run();
+ return null;
+ }
+
+ Frame firstFrame = frames.getFirst();
+ frameConsumer.accept(player, firstFrame);
+ if (firstFrame.sound() != null) {
+ player.playSound(player.getLocation(), firstFrame.sound(), 1.0f, firstFrame.soundPitch());
+ }
+
+ if (frames.size() == 1) {
+ if (nextAnimation != null) {
+ if (onComplete != null) onComplete.accept(player);
+ return nextAnimation.play(plugin, player, frameConsumer, extraOnComplete);
+ }
+ if (onComplete != null) onComplete.accept(player);
+ if (extraOnComplete != null) extraOnComplete.run();
+ return null;
+ }
+
+ return new BukkitRunnable() {
+ private int frameIndex = 1;
+
+ @Override
+ public void run() {
+ if (!player.isOnline()) {
+ cancel();
+ return;
+ }
+
+ if (frameIndex < frames.size() - 1) {
+ Frame frame = frames.get(frameIndex);
+ frameConsumer.accept(player, frame);
+ if (frame.sound() != null) {
+ player.playSound(player.getLocation(), frame.sound(), 1.0f, frame.soundPitch());
+ }
+ frameIndex++;
+ } else {
+ Frame last = frames.getLast();
+ cancel();
+ frameConsumer.accept(player, last);
+ if (last.sound() != null) {
+ player.playSound(player.getLocation(), last.sound(), 1.0f, last.soundPitch());
+ }
+ if (onComplete != null) {
+ onComplete.accept(player);
+ }
+ if (nextAnimation != null) {
+ nextAnimation.play(plugin, player, frameConsumer, extraOnComplete);
+ } else if (extraOnComplete != null) {
+ extraOnComplete.run();
+ }
+ }
+ }
+ }.runTaskTimer(plugin, tickInterval, tickInterval);
+ }
+
+ // =========================================================================
+ // Utilities & Resolution
+ // =========================================================================
+
+ private static Plugin resolvePlugin() {
+ ActionbarService actionbarService = ActionbarService.getInstance();
+ if (actionbarService != null && actionbarService.getPlugin() != null) {
+ return actionbarService.getPlugin();
+ }
+ TitleService titleService = TitleService.getInstance();
+ if (titleService != null && titleService.getPlugin() != null) {
+ return titleService.getPlugin();
+ }
+ return PluginContext.get();
+ }
+
+ /**
+ * Extracts a substring containing a given number of visible characters while preserving color formatting codes.
+ *
+ * @param text formatted string
+ * @param visibleChars maximum visible character count
+ * @return truncated formatted string
+ */
+ public static String substringWithColors(@NonNull String text, int visibleChars) {
+ StringBuilder result = new StringBuilder();
+ int count = 0;
+ for (int i = 0; i < text.length(); i++) {
+ char c = text.charAt(i);
+ if (c == '§' && i + 1 < text.length()) {
+ result.append(c).append(text.charAt(i + 1));
+ i++;
+ continue;
+ }
+ result.append(c);
+ count++;
+ if (count >= visibleChars) {
+ break;
+ }
+ }
+ return result.toString();
+ }
+
+ /**
+ * Fluent builder for {@link TextAnimation}.
+ */
+ public static class Builder {
+ protected final List frames = new ArrayList<>();
+ protected long tickInterval = 1L;
+ protected TimeableTitle finalTimes = TimeableTitle.DEFAULT;
+ protected Duration finalStay = null;
+ protected Consumer onComplete;
+
+ public Builder frame(@NonNull String text) {
+ return frame(text, (String) null);
+ }
+
+ public Builder frame(@NonNull String text, @Nullable Sound sound, float pitch) {
+ frames.add(new Frame(text, null, sound, pitch));
+ return this;
+ }
+
+ public Builder frame(@NonNull String text, @Nullable String subtitle) {
+ frames.add(new Frame(text, subtitle, null, 1.0f));
+ return this;
+ }
+
+ public Builder frame(@NonNull String text, @Nullable String subtitle, @Nullable Sound sound, float pitch) {
+ frames.add(new Frame(text, subtitle, sound, pitch));
+ return this;
+ }
+
+ public Builder interval(long tickInterval) {
+ this.tickInterval = tickInterval;
+ return this;
+ }
+
+ public Builder interval(@NonNull Duration interval) {
+ Objects.requireNonNull(interval, "interval cannot be null");
+ this.tickInterval = Math.max(1L, interval.toMillis() / 50L);
+ return this;
+ }
+
+ public Builder finalTimes(@NonNull TimeableTitle finalTimes) {
+ this.finalTimes = Objects.requireNonNull(finalTimes, "finalTimes cannot be null");
+ return this;
+ }
+
+ public Builder finalTimes(@NonNull Duration stayDuration) {
+ return finalTimes(TimeableTitle.stay(stayDuration));
+ }
+
+ public Builder finalStay(@NonNull Duration finalStay) {
+ this.finalStay = Objects.requireNonNull(finalStay, "finalStay cannot be null");
+ return this;
+ }
+
+ public Builder onComplete(@Nullable Consumer onComplete) {
+ this.onComplete = onComplete;
+ return this;
+ }
+
+ public TextAnimation build() {
+ Duration stay = (finalStay != null) ? finalStay : Duration.ofMillis(finalTimes.stay() * 50L);
+ return new TextAnimation(frames, tickInterval, finalTimes, stay, onComplete, null);
+ }
+ }
+}
diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/title/PersistentTitle.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/title/PersistentTitle.java
new file mode 100644
index 00000000..e56e182c
--- /dev/null
+++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/title/PersistentTitle.java
@@ -0,0 +1,133 @@
+package fr.moussax.bedrock.ui.title;
+
+import org.bukkit.entity.Player;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
+import java.time.Duration;
+import java.util.Objects;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+/**
+ * Represents an ambient or persistent title display maintained continuously on a player's screen.
+ *
+ * Designed for minigame HUDs (waiting lobbies, spectator view, boss encounters, mission objectives).
+ * When higher-priority modal alerts or countdowns finish, the persistent title is automatically
+ * restored without flickering or manual bookkeeping.
+ *
+ * @param titleSupplier computes the main title string for a player
+ * @param subtitleSupplier computes the subtitle string for a player
+ * @param times timing configuration used during display and periodic refresh
+ */
+public record PersistentTitle(
+ @NonNull Function titleSupplier,
+ @NonNull Function subtitleSupplier,
+ @NonNull TimeableTitle times
+) {
+
+ public PersistentTitle {
+ Objects.requireNonNull(titleSupplier, "titleSupplier cannot be null");
+ Objects.requireNonNull(subtitleSupplier, "subtitleSupplier cannot be null");
+ Objects.requireNonNull(times, "times cannot be null");
+ }
+
+ /**
+ * Constructs a static persistent title with constant text strings and default permanent timings.
+ *
+ * @param title constant title text
+ * @param subtitle constant subtitle text
+ * @return persistent title instance
+ */
+ public static PersistentTitle of(@Nullable String title, @Nullable String subtitle) {
+ return of(title, subtitle, TimeableTitle.PERMANENT);
+ }
+
+ /**
+ * Constructs a static persistent title with constant text strings and custom timings.
+ *
+ * @param title constant title text
+ * @param subtitle constant subtitle text
+ * @param times custom display timings
+ * @return persistent title instance
+ */
+ public static PersistentTitle of(@Nullable String title, @Nullable String subtitle, @NonNull TimeableTitle times) {
+ String finalTitle = title != null ? title : "";
+ String finalSubtitle = subtitle != null ? subtitle : "";
+ return new PersistentTitle(_ -> finalTitle, _ -> finalSubtitle, times);
+ }
+
+ /**
+ * Constructs a static persistent title with constant text strings and a custom stay duration.
+ *
+ * @param title constant title text
+ * @param subtitle constant subtitle text
+ * @param stayDuration duration to remain visible between periodic refreshes
+ * @return persistent title instance
+ */
+ public static PersistentTitle of(@Nullable String title, @Nullable String subtitle, @NonNull Duration stayDuration) {
+ return of(title, subtitle, TimeableTitle.of(Duration.ZERO, stayDuration, Duration.ofMillis(500)));
+ }
+
+ /**
+ * Constructs a persistent title evaluating dynamic parameterless suppliers on each render cycle with default permanent timings.
+ *
+ * @param titleSupplier supplier for title text
+ * @param subtitleSupplier supplier for subtitle text
+ * @return persistent title instance
+ */
+ public static PersistentTitle of(
+ @NonNull Supplier<@Nullable String> titleSupplier,
+ @NonNull Supplier<@Nullable String> subtitleSupplier
+ ) {
+ return of(titleSupplier, subtitleSupplier, TimeableTitle.PERMANENT);
+ }
+
+ /**
+ * Constructs a persistent title evaluating dynamic parameterless suppliers with custom timings.
+ *
+ * @param titleSupplier supplier for title text
+ * @param subtitleSupplier supplier for subtitle text
+ * @param times custom display timings
+ * @return persistent title instance
+ */
+ public static PersistentTitle of(
+ @NonNull Supplier<@Nullable String> titleSupplier,
+ @NonNull Supplier<@Nullable String> subtitleSupplier,
+ @NonNull TimeableTitle times
+ ) {
+ Objects.requireNonNull(titleSupplier, "titleSupplier cannot be null");
+ Objects.requireNonNull(subtitleSupplier, "subtitleSupplier cannot be null");
+ return new PersistentTitle(_ -> titleSupplier.get(), _ -> subtitleSupplier.get(), times);
+ }
+
+ /**
+ * Constructs a persistent title evaluating per-player dynamic functions on each render cycle with default permanent timings.
+ *
+ * @param titleSupplier per-player title function
+ * @param subtitleSupplier per-player subtitle function
+ * @return persistent title instance
+ */
+ public static PersistentTitle of(
+ @NonNull Function titleSupplier,
+ @NonNull Function subtitleSupplier
+ ) {
+ return new PersistentTitle(titleSupplier, subtitleSupplier, TimeableTitle.PERMANENT);
+ }
+
+ /**
+ * Constructs a persistent title with per-player dynamic functions and custom timing parameters.
+ *
+ * @param titleSupplier per-player title function
+ * @param subtitleSupplier per-player subtitle function
+ * @param times custom timing specification
+ * @return persistent title instance
+ */
+ public static PersistentTitle of(
+ @NonNull Function titleSupplier,
+ @NonNull Function subtitleSupplier,
+ @NonNull TimeableTitle times
+ ) {
+ return new PersistentTitle(titleSupplier, subtitleSupplier, times);
+ }
+}
diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/title/TimeableTitle.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/title/TimeableTitle.java
new file mode 100644
index 00000000..9cb5e204
--- /dev/null
+++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/title/TimeableTitle.java
@@ -0,0 +1,120 @@
+package fr.moussax.bedrock.ui.title;
+
+import org.jspecify.annotations.NonNull;
+
+import java.time.Duration;
+
+/**
+ * Encapsulates display timing parameters for Minecraft titles and subtitles.
+ *
+ * All timing values are specified in Minecraft server ticks (20 ticks = 1 second).
+ *
+ * @param fadeIn number of ticks to transition from transparent to full opacity
+ * @param stay number of ticks the title remains at full opacity
+ * @param fadeOut number of ticks to transition from full opacity to transparent
+ */
+public record TimeableTitle(int fadeIn, int stay, int fadeOut) {
+
+ /**
+ * Standard cinematic display: 0.5s fade-in, 3s stay, 1s fade-out (10, 60, 20).
+ */
+ public static final TimeableTitle DEFAULT = new TimeableTitle(10, 60, 20);
+
+ /**
+ * Urgent alert display: instant appearance, 1.5s stay, 0.5s fade-out (0, 30, 10).
+ */
+ public static final TimeableTitle ALERT = new TimeableTitle(0, 30, 10);
+
+ /**
+ * Instant appearance without fade: 0 fade-in, 1.5s stay, 0.5s fade-out (0, 30, 10).
+ */
+ public static final TimeableTitle INSTANT = new TimeableTitle(0, 30, 10);
+
+ /**
+ * Subtle banner display: 0.25s fade-in, 1.75s stay, 0.5s fade-out (5, 35, 10).
+ */
+ public static final TimeableTitle SUBTITLE_ONLY = new TimeableTitle(5, 35, 10);
+
+ /**
+ * Epic or boss encounter display: 1.0s fade-in, 4.5s stay, 1.5s fade-out (20, 90, 30).
+ */
+ public static final TimeableTitle PROLONGED = new TimeableTitle(20, 90, 30);
+
+ /**
+ * Instant flash pulse: 0 fade-in, 0.5s stay, 0.25s fade-out (0, 10, 5).
+ */
+ public static final TimeableTitle FLASH = new TimeableTitle(0, 10, 5);
+
+ /**
+ * Permanent / ambient display refreshed continuously: 0 fade-in, 5s stay, 0.5s fade-out (0, 100, 10).
+ */
+ public static final TimeableTitle PERMANENT = new TimeableTitle(0, 100, 10);
+
+ /**
+ * In-place animation frame: 0 fade-in, 2s stay, 0 fade-out (0, 40, 0).
+ */
+ public static final TimeableTitle ANIMATION_FRAME = new TimeableTitle(0, 40, 0);
+
+ public TimeableTitle {
+ if (fadeIn < 0) fadeIn = 0;
+ if (stay < 0) stay = 0;
+ if (fadeOut < 0) fadeOut = 0;
+ }
+
+ /**
+ * Returns the total lifecycle duration of the title in ticks.
+ *
+ * @return sum of fade-in, stay, and fade-out ticks
+ */
+ public int totalTicks() {
+ return fadeIn + stay + fadeOut;
+ }
+
+ /**
+ * Creates a timing specification using tick values.
+ *
+ * @param fadeIn fade-in ticks
+ * @param stay stay ticks
+ * @param fadeOut fade-out ticks
+ * @return the timing configuration
+ */
+ public static TimeableTitle of(int fadeIn, int stay, int fadeOut) {
+ return new TimeableTitle(fadeIn, stay, fadeOut);
+ }
+
+ /**
+ * Creates a timing specification with a custom stay duration, using default 10-tick fade-in and 20-tick fade-out.
+ *
+ * @param stayTicks stay ticks
+ * @return the timing configuration
+ */
+ public static TimeableTitle stay(int stayTicks) {
+ return new TimeableTitle(10, stayTicks, 20);
+ }
+
+ /**
+ * Creates a timing specification using Java {@link Duration} values.
+ *
+ * @param fadeIn fade-in duration
+ * @param stay stay duration
+ * @param fadeOut fade-out duration
+ * @return the timing configuration
+ */
+ public static TimeableTitle of(@NonNull Duration fadeIn, @NonNull Duration stay, @NonNull Duration fadeOut) {
+ return new TimeableTitle(toTicks(fadeIn), toTicks(stay), toTicks(fadeOut));
+ }
+
+ /**
+ * Creates a timing specification with a custom stay {@link Duration} and default fades.
+ *
+ * @param stay stay duration
+ * @return the timing configuration
+ */
+ public static TimeableTitle stay(@NonNull Duration stay) {
+ return new TimeableTitle(10, toTicks(stay), 20);
+ }
+
+ private static int toTicks(@NonNull Duration duration) {
+ return Math.max(0, (int) Math.round(duration.toMillis() / 50.0));
+ }
+}
diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/title/Title.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/title/Title.java
new file mode 100644
index 00000000..0b50fdb7
--- /dev/null
+++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/title/Title.java
@@ -0,0 +1,947 @@
+package fr.moussax.bedrock.ui.title;
+
+import fr.moussax.bedrock.scheduling.PluginContext;
+import fr.moussax.bedrock.sound.SoundCue;
+import fr.moussax.bedrock.ui.animation.TextAnimation;
+import org.bukkit.Bukkit;
+import org.bukkit.Sound;
+import org.bukkit.entity.Player;
+import org.bukkit.plugin.Plugin;
+import org.bukkit.scheduler.BukkitRunnable;
+import org.bukkit.scheduler.BukkitTask;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
+import java.time.Duration;
+import java.util.Collection;
+import java.util.List;
+import java.util.Objects;
+import java.util.function.Function;
+import java.util.function.IntFunction;
+import java.util.function.Supplier;
+
+/**
+ * Static entry point for displaying and composing on-screen Minecraft titles and subtitles.
+ *
+ * Supports one-shot displays, persistent ambient HUDs (for minigames and lobbies),
+ * prioritized modal alerts with automatic persistent restoration, automated countdowns,
+ * and high-performance, flicker-free title animations.
+ */
+public final class Title {
+
+ private Title() {
+ }
+
+ /**
+ * Initializes or retrieves the shared title service for the given plugin.
+ *
+ * @param plugin owning plugin
+ * @param periodTicks interval in ticks between periodic HUD evaluation passes
+ * @return the active title service
+ */
+ @NonNull
+ public static TitleService initialize(@NonNull Plugin plugin, long periodTicks) {
+ TitleService service = TitleService.getOrCreate(plugin);
+ service.start(periodTicks);
+ return service;
+ }
+
+ /**
+ * Creates a new fluent {@link TitleBuilder}.
+ *
+ * @return a new title builder instance
+ */
+ public static TitleBuilder builder() {
+ return new TitleBuilder();
+ }
+
+ /**
+ * Sends a main title to a player with standard default timings (0.5s in, 3s stay, 1s out).
+ *
+ * @param player target player
+ * @param title title text
+ */
+ public static void send(@NonNull Player player, @NonNull String title) {
+ send(player, title, null, TimeableTitle.DEFAULT, (SoundCue) null);
+ }
+
+ /**
+ * Sends a title and subtitle to a player with standard default timings.
+ *
+ * @param player target player
+ * @param title title text
+ * @param subtitle subtitle text
+ */
+ public static void send(@NonNull Player player, @NonNull String title, @Nullable String subtitle) {
+ send(player, title, subtitle, TimeableTitle.DEFAULT, (SoundCue) null);
+ }
+
+ /**
+ * Sends a title and subtitle to a player with custom timing parameters.
+ *
+ * @param player target player
+ * @param title title text
+ * @param subtitle subtitle text
+ * @param times timing parameters
+ */
+ public static void send(@NonNull Player player, @NonNull String title, @Nullable String subtitle, @NonNull TimeableTitle times) {
+ send(player, title, subtitle, times, (SoundCue) null);
+ }
+
+ /**
+ * Sends a title and subtitle accompanied by a Bukkit sound effect.
+ *
+ * @param player target player
+ * @param title title text
+ * @param subtitle subtitle text
+ * @param times timing parameters
+ * @param sound sound to play
+ */
+ public static void send(
+ @NonNull Player player,
+ @NonNull String title,
+ @Nullable String subtitle,
+ @NonNull TimeableTitle times,
+ @Nullable Sound sound
+ ) {
+ builder().title(title).subtitle(subtitle).times(times).sound(sound).send(player);
+ }
+
+ /**
+ * Sends a title and subtitle accompanied by a {@link SoundCue}.
+ *
+ * @param player target player
+ * @param title title text
+ * @param subtitle subtitle text
+ * @param times timing parameters
+ * @param soundCue sound cue to play
+ */
+ public static void send(
+ @NonNull Player player,
+ @NonNull String title,
+ @Nullable String subtitle,
+ @NonNull TimeableTitle times,
+ @Nullable SoundCue soundCue
+ ) {
+ builder().title(title).subtitle(subtitle).times(times).sound(soundCue).send(player);
+ }
+
+ /**
+ * Sends a title and subtitle to a player with a custom stay duration.
+ *
+ * @param player target player
+ * @param title title text
+ * @param subtitle subtitle text
+ * @param duration stay duration
+ */
+ public static void send(@NonNull Player player, @NonNull String title, @Nullable String subtitle, @NonNull Duration duration) {
+ send(player, title, subtitle, TimeableTitle.stay(duration), (SoundCue) null);
+ }
+
+ /**
+ * Sends a title and subtitle accompanied by a Bukkit sound effect and stay duration.
+ *
+ * @param player target player
+ * @param title title text
+ * @param subtitle subtitle text
+ * @param duration stay duration
+ * @param sound sound to play
+ */
+ public static void send(
+ @NonNull Player player,
+ @NonNull String title,
+ @Nullable String subtitle,
+ @NonNull Duration duration,
+ @Nullable Sound sound
+ ) {
+ send(player, title, subtitle, TimeableTitle.stay(duration), sound);
+ }
+
+ /**
+ * Sends a title and subtitle accompanied by a {@link SoundCue} and stay duration.
+ *
+ * @param player target player
+ * @param title title text
+ * @param subtitle subtitle text
+ * @param duration stay duration
+ * @param soundCue sound cue to play
+ */
+ public static void send(
+ @NonNull Player player,
+ @NonNull String title,
+ @Nullable String subtitle,
+ @NonNull Duration duration,
+ @Nullable SoundCue soundCue
+ ) {
+ send(player, title, subtitle, TimeableTitle.stay(duration), soundCue);
+ }
+
+ /**
+ * Sends a main title to a collection of players with standard default timings.
+ *
+ * @param players target players
+ * @param title title text
+ */
+ public static void send(@NonNull Collection extends Player> players, @NonNull String title) {
+ send(players, title, null, TimeableTitle.DEFAULT);
+ }
+
+ /**
+ * Sends a title and subtitle to a collection of players with standard default timings.
+ *
+ * @param players target players
+ * @param title title text
+ * @param subtitle subtitle text
+ */
+ public static void send(@NonNull Collection extends Player> players, @NonNull String title, @Nullable String subtitle) {
+ send(players, title, subtitle, TimeableTitle.DEFAULT);
+ }
+
+ /**
+ * Sends a title and subtitle to a collection of players with custom timing parameters.
+ *
+ * @param players target players
+ * @param title title text
+ * @param subtitle subtitle text
+ * @param times timing parameters
+ */
+ public static void send(
+ @NonNull Collection extends Player> players,
+ @NonNull String title,
+ @Nullable String subtitle,
+ @NonNull TimeableTitle times
+ ) {
+ Objects.requireNonNull(players, "players cannot be null");
+ for (Player player : players) {
+ if (player != null && player.isOnline()) {
+ send(player, title, subtitle, times);
+ }
+ }
+ }
+
+ /**
+ * Sends a title and subtitle to a collection of players with custom stay duration.
+ *
+ * @param players target players
+ * @param title title text
+ * @param subtitle subtitle text
+ * @param duration stay duration
+ */
+ public static void send(
+ @NonNull Collection extends Player> players,
+ @NonNull String title,
+ @Nullable String subtitle,
+ @NonNull Duration duration
+ ) {
+ send(players, title, subtitle, TimeableTitle.stay(duration));
+ }
+
+ /**
+ * Sends a subtitle-only banner to a player using subtle timings.
+ *
+ * @param player target player
+ * @param subtitle subtitle text
+ */
+ public static void sendSubtitle(@NonNull Player player, @NonNull String subtitle) {
+ sendSubtitle(player, subtitle, TimeableTitle.SUBTITLE_ONLY);
+ }
+
+ /**
+ * Sends a subtitle-only banner to a player with custom timings.
+ *
+ * @param player target player
+ * @param subtitle subtitle text
+ * @param times timing parameters
+ */
+ public static void sendSubtitle(@NonNull Player player, @NonNull String subtitle, @NonNull TimeableTitle times) {
+ builder().title(" ").subtitle(subtitle).times(times).send(player);
+ }
+
+ /**
+ * Sends a subtitle-only banner to a player with a custom stay duration.
+ *
+ * @param player target player
+ * @param subtitle subtitle text
+ * @param duration stay duration
+ */
+ public static void sendSubtitle(@NonNull Player player, @NonNull String subtitle, @NonNull Duration duration) {
+ sendSubtitle(player, subtitle, TimeableTitle.stay(duration));
+ }
+
+ /**
+ * Broadcasts a title to all online players with standard default timings.
+ *
+ * @param title title text
+ */
+ public static void broadcast(@NonNull String title) {
+ broadcast(title, null, TimeableTitle.DEFAULT);
+ }
+
+ /**
+ * Broadcasts a title and subtitle to all online players with standard default timings.
+ *
+ * @param title title text
+ * @param subtitle subtitle text
+ */
+ public static void broadcast(@NonNull String title, @Nullable String subtitle) {
+ broadcast(title, subtitle, TimeableTitle.DEFAULT);
+ }
+
+ /**
+ * Broadcasts a title and subtitle to all online players with custom timings.
+ *
+ * @param title title text
+ * @param subtitle subtitle text
+ * @param times timing parameters
+ */
+ public static void broadcast(@NonNull String title, @Nullable String subtitle, @NonNull TimeableTitle times) {
+ builder().title(title).subtitle(subtitle).times(times).broadcast();
+ }
+
+ /**
+ * Broadcasts a title and subtitle to all online players with custom stay duration.
+ *
+ * @param title title text
+ * @param subtitle subtitle text
+ * @param duration stay duration
+ */
+ public static void broadcast(@NonNull String title, @Nullable String subtitle, @NonNull Duration duration) {
+ broadcast(title, subtitle, TimeableTitle.stay(duration));
+ }
+
+ /**
+ * Sets a persistent ambient title on a player with static text strings.
+ *
+ * @param player target player
+ * @param title persistent title text
+ * @param subtitle persistent subtitle text
+ */
+ public static void setPersistent(@NonNull Player player, @Nullable String title, @Nullable String subtitle) {
+ setPersistent(player, PersistentTitle.of(title, subtitle));
+ }
+
+ /**
+ * Sets a persistent ambient title on a player with dynamic text suppliers.
+ *
+ * @param player target player
+ * @param titleSupplier dynamic title supplier
+ * @param subtitleSupplier dynamic subtitle supplier
+ */
+ public static void setPersistent(
+ @NonNull Player player,
+ @NonNull Supplier<@Nullable String> titleSupplier,
+ @NonNull Supplier<@Nullable String> subtitleSupplier
+ ) {
+ setPersistent(player, PersistentTitle.of(titleSupplier, subtitleSupplier));
+ }
+
+ /**
+ * Sets a persistent ambient title on a player with dynamic per-player functions.
+ *
+ * @param player target player
+ * @param titleSupplier dynamic per-player title function
+ * @param subtitleSupplier dynamic per-player subtitle function
+ */
+ public static void setPersistent(
+ @NonNull Player player,
+ @NonNull Function titleSupplier,
+ @NonNull Function subtitleSupplier
+ ) {
+ setPersistent(player, PersistentTitle.of(titleSupplier, subtitleSupplier));
+ }
+
+ /**
+ * Sets a persistent ambient title on a player with a custom display refresh duration.
+ *
+ * @param player target player
+ * @param title persistent title text
+ * @param subtitle persistent subtitle text
+ * @param duration display stay duration
+ */
+ public static void setPersistent(@NonNull Player player, @Nullable String title, @Nullable String subtitle, @NonNull Duration duration) {
+ setPersistent(player, PersistentTitle.of(title, subtitle, duration));
+ }
+
+ /**
+ * Sets a persistent ambient title on a player with dynamic text suppliers and custom stay duration.
+ *
+ * @param player target player
+ * @param titleSupplier dynamic title supplier
+ * @param subtitleSupplier dynamic subtitle supplier
+ * @param duration display stay duration
+ */
+ public static void setPersistent(
+ @NonNull Player player,
+ @NonNull Supplier<@Nullable String> titleSupplier,
+ @NonNull Supplier<@Nullable String> subtitleSupplier,
+ @NonNull Duration duration
+ ) {
+ setPersistent(player, PersistentTitle.of(titleSupplier, subtitleSupplier, TimeableTitle.stay(duration)));
+ }
+
+ /**
+ * Sets a persistent ambient title on a player using a {@link PersistentTitle} configuration.
+ *
+ * @param player target player
+ * @param persistentTitle persistent title configuration
+ */
+ public static void setPersistent(@NonNull Player player, @NonNull PersistentTitle persistentTitle) {
+ TitleService service = getServiceOrLazy();
+ if (service != null) {
+ service.setPersistent(player, persistentTitle);
+ } else {
+ // Standalone fallback
+ String title = persistentTitle.titleSupplier().apply(player);
+ String subtitle = persistentTitle.subtitleSupplier().apply(player);
+ player.sendTitle(title != null ? title : " ", subtitle != null ? subtitle : "",
+ persistentTitle.times().fadeIn(), persistentTitle.times().stay(), persistentTitle.times().fadeOut());
+ }
+ }
+
+ /**
+ * Sets a persistent ambient title on a collection of players.
+ *
+ * @param players target players
+ * @param persistentTitle persistent title configuration
+ */
+ public static void setPersistent(@NonNull Collection extends Player> players, @NonNull PersistentTitle persistentTitle) {
+ Objects.requireNonNull(players, "players cannot be null");
+ for (Player player : players) {
+ if (player != null && player.isOnline()) {
+ setPersistent(player, persistentTitle);
+ }
+ }
+ }
+
+ /**
+ * Removes any active persistent ambient title from a player.
+ *
+ * @param player target player
+ */
+ public static void clearPersistent(@NonNull Player player) {
+ TitleService service = getServiceOrLazy();
+ if (service != null) {
+ service.clearPersistent(player);
+ }
+ }
+
+ /**
+ * Removes active persistent ambient titles from a collection of players.
+ *
+ * @param players target players
+ */
+ public static void clearPersistent(@NonNull Collection extends Player> players) {
+ Objects.requireNonNull(players, "players cannot be null");
+ for (Player player : players) {
+ if (player != null && player.isOnline()) {
+ clearPersistent(player);
+ }
+ }
+ }
+
+ /**
+ * Checks if a player has an active persistent ambient title.
+ *
+ * @param player target player
+ * @return true if a persistent title is active
+ */
+ public static boolean hasPersistent(@NonNull Player player) {
+ TitleService service = getServiceOrLazy();
+ return service != null && service.hasPersistent(player);
+ }
+
+ /**
+ * Displays a prioritized modal alert to a player for a given duration.
+ *
+ * Overriding any active persistent title while active. Upon expiration, the persistent title
+ * is automatically restored without a flicker.
+ *
+ * @param player target player
+ * @param title alert title text
+ * @param subtitle alert subtitle text
+ * @param duration display duration
+ */
+ public static void alert(@NonNull Player player, @NonNull String title, @Nullable String subtitle, @NonNull Duration duration) {
+ alert(player, title, subtitle, 0, duration, TimeableTitle.ALERT);
+ }
+
+ /**
+ * Displays an urgent alert title to a player with instant appearance and a warning bass sound cue.
+ *
+ * @param player target player
+ * @param title alert title text
+ * @param subtitle alert subtitle text
+ */
+ public static void alert(@NonNull Player player, @NonNull String title, @Nullable String subtitle) {
+ builder()
+ .title(title)
+ .subtitle(subtitle)
+ .times(TimeableTitle.ALERT)
+ .sound(Sound.BLOCK_NOTE_BLOCK_BASS, 1.0f, 0.8f)
+ .send(player);
+ }
+
+ /**
+ * Displays a prioritized modal alert to a player with custom priority and duration.
+ *
+ * @param player target player
+ * @param title alert title text
+ * @param subtitle alert subtitle text
+ * @param priority alert priority (higher values take precedence)
+ * @param duration display duration
+ */
+ public static void alert(
+ @NonNull Player player,
+ @NonNull String title,
+ @Nullable String subtitle,
+ int priority,
+ @NonNull Duration duration
+ ) {
+ alert(player, title, subtitle, priority, duration, TimeableTitle.ALERT);
+ }
+
+ /**
+ * Displays a prioritized modal alert to a player with custom priority, duration, and timings.
+ *
+ * @param player target player
+ * @param title alert title text
+ * @param subtitle alert subtitle text
+ * @param priority alert priority
+ * @param duration display duration
+ * @param times timing parameters
+ */
+ public static void alert(
+ @NonNull Player player,
+ @NonNull String title,
+ @Nullable String subtitle,
+ int priority,
+ @NonNull Duration duration,
+ @NonNull TimeableTitle times
+ ) {
+ alert(player, TitleAlert.of(title, subtitle, priority, duration, times));
+ }
+
+ /**
+ * Queues a prepared {@link TitleAlert} modal alert on a player.
+ *
+ * @param player target player
+ * @param alert title alert
+ */
+ public static void alert(@NonNull Player player, @NonNull TitleAlert alert) {
+ TitleService service = getServiceOrLazy();
+ if (service != null) {
+ service.sendAlert(player, alert);
+ } else {
+ String title = alert.title(player);
+ String subtitle = alert.subtitle(player);
+ builder().title(title != null ? title : " ").subtitle(subtitle).times(alert.times()).send(player);
+ }
+ }
+
+ /**
+ * Displays a prioritized modal alert to a collection of players.
+ *
+ * @param players target players
+ * @param alert modal alert
+ */
+ public static void alert(@NonNull Collection extends Player> players, @NonNull TitleAlert alert) {
+ Objects.requireNonNull(players, "players cannot be null");
+ for (Player player : players) {
+ if (player != null && player.isOnline()) {
+ alert(player, alert);
+ }
+ }
+ }
+
+ /**
+ * Displays a prioritized modal alert to a collection of players for a given duration.
+ *
+ * @param players target players
+ * @param title alert title text
+ * @param subtitle alert subtitle text
+ * @param duration display duration
+ */
+ public static void alert(
+ @NonNull Collection extends Player> players,
+ @NonNull String title,
+ @Nullable String subtitle,
+ @NonNull Duration duration
+ ) {
+ alert(players, TitleAlert.of(title, subtitle, 0, duration));
+ }
+
+ /**
+ * Broadcasts a modal alert to all online players.
+ *
+ * @param alert modal alert
+ */
+ public static void broadcastAlert(@NonNull TitleAlert alert) {
+ alert(Bukkit.getOnlinePlayers(), alert);
+ }
+
+ /**
+ * Broadcasts a modal alert to all online players for a given duration.
+ *
+ * @param title alert title text
+ * @param subtitle alert subtitle text
+ * @param duration display duration
+ */
+ public static void broadcastAlert(@NonNull String title, @Nullable String subtitle, @NonNull Duration duration) {
+ broadcastAlert(TitleAlert.of(title, subtitle, 0, duration));
+ }
+
+ /**
+ * Clears all active modal alerts on a player and immediately restores underlying persistent titles.
+ *
+ * @param player target player
+ */
+ public static void clearAlerts(@NonNull Player player) {
+ TitleService service = getServiceOrLazy();
+ if (service != null) {
+ service.clearAlerts(player);
+ }
+ }
+
+ /**
+ * Clears all active modal alerts on a collection of players and restores persistent titles.
+ *
+ * @param players target players
+ */
+ public static void clearAlerts(@NonNull Collection extends Player> players) {
+ Objects.requireNonNull(players, "players cannot be null");
+ for (Player player : players) {
+ if (player != null && player.isOnline()) {
+ clearAlerts(player);
+ }
+ }
+ }
+
+ /**
+ * Clears and resets the active title displayed to a player.
+ *
+ * @param player target player
+ */
+ public static void clear(@NonNull Player player) {
+ Objects.requireNonNull(player, "player cannot be null");
+ TitleService service = TitleService.getInstance();
+ if (service != null) {
+ service.clearAll(player);
+ } else {
+ player.resetTitle();
+ }
+ }
+
+ /**
+ * Clears and resets the active title displayed to a collection of players.
+ *
+ * @param players target players
+ */
+ public static void clear(@NonNull Collection extends Player> players) {
+ Objects.requireNonNull(players, "players cannot be null");
+ for (Player player : players) {
+ if (player != null && player.isOnline()) {
+ clear(player);
+ }
+ }
+ }
+
+ /**
+ * Clears and resets the active title displayed to a player. Alias for {@link #clear(Player)}.
+ *
+ * @param player target player
+ */
+ public static void reset(@NonNull Player player) {
+ clear(player);
+ }
+
+ /**
+ * Clears and resets the active title for all online players.
+ */
+ public static void clearAll() {
+ for (Player player : Bukkit.getOnlinePlayers()) {
+ clear(player);
+ }
+ }
+
+ /**
+ * Displays a prolonged announcement title to a player with a celebratory completion sound.
+ *
+ * @param player target player
+ * @param title announcement title text
+ * @param subtitle announcement subtitle text
+ */
+ public static void announcement(@NonNull Player player, @NonNull String title, @Nullable String subtitle) {
+ builder()
+ .title(title)
+ .subtitle(subtitle)
+ .times(TimeableTitle.PROLONGED)
+ .sound(Sound.UI_TOAST_CHALLENGE_COMPLETE, 1.0f, 1.0f)
+ .send(player);
+ }
+
+ /**
+ * Plays a high-performance, flicker-free title animation on a player.
+ *
+ * @param player target player
+ * @param animation text animation to execute
+ * @return the running Bukkit task handle
+ */
+ public static BukkitTask animate(@NonNull Player player, @NonNull TextAnimation animation) {
+ return animate(player, animation, null);
+ }
+
+ /**
+ * Plays a high-performance title animation on a player with a completion callback.
+ *
+ * @param player target player
+ * @param animation text animation to execute
+ * @param onComplete optional action executed upon animation completion
+ * @return the running Bukkit task handle
+ */
+ public static BukkitTask animate(@NonNull Player player, @NonNull TextAnimation animation, @Nullable Runnable onComplete) {
+ return animation.playTitle(player, onComplete);
+ }
+
+ /**
+ * Plays a title animation across a collection of players.
+ *
+ * @param players target players
+ * @param animation text animation to execute
+ */
+ public static void animate(@NonNull Collection extends Player> players, @NonNull TextAnimation animation) {
+ animate(players, animation, null);
+ }
+
+ /**
+ * Plays a title animation across a collection of players with a completion callback.
+ *
+ * @param players target players
+ * @param animation text animation to execute
+ * @param onComplete optional action executed when all animations complete
+ */
+ public static void animate(@NonNull Collection extends Player> players, @NonNull TextAnimation animation, @Nullable Runnable onComplete) {
+ animation.playTitle(players, onComplete);
+ }
+
+ /**
+ * Executes an automated animated countdown title sequence on a player's screen (e.g. 3.. 2.. 1.. GO!).
+ *
+ * Decrements each second with an audible pling sound of ascending pitch, culminating
+ * in the final completion title, subtitle, and callback.
+ *
+ * @param player target player
+ * @param seconds countdown duration in seconds
+ * @param completionTitle final title displayed when countdown reaches 0
+ * @param completionSubtitle final subtitle displayed when countdown reaches 0
+ * @param onComplete optional action to execute when countdown completes
+ * @return the active BukkitTask managing the countdown
+ */
+ public static BukkitTask countdown(
+ @NonNull Player player,
+ int seconds,
+ @NonNull String completionTitle,
+ @Nullable String completionSubtitle,
+ @Nullable Runnable onComplete
+ ) {
+ if (seconds <= 0) {
+ builder().title(completionTitle).subtitle(completionSubtitle).times(TimeableTitle.of(0, 30, 10)).send(player);
+ if (onComplete != null) {
+ onComplete.run();
+ }
+ return null;
+ }
+ return countdown(player, seconds, remaining -> switch (remaining) {
+ case 1 -> "§a§l" + remaining;
+ case 2 -> "§e§l" + remaining;
+ default -> "§c§l" + remaining;
+ }, completionTitle, completionSubtitle, Sound.BLOCK_NOTE_BLOCK_PLING, Sound.BLOCK_NOTE_BLOCK_PLING, onComplete);
+ }
+
+ /**
+ * Executes an automated animated countdown title sequence on a player's screen with custom formatting and sound.
+ *
+ * @param player target player
+ * @param seconds countdown duration in seconds
+ * @param secondFormatter function formatting the title text for each second (receives remaining seconds)
+ * @param completionTitle final title displayed when countdown reaches 0
+ * @param completionSubtitle final subtitle displayed when countdown reaches 0
+ * @param tickSound sound played each second, or null for silent
+ * @param completionSound sound played at countdown completion, or null for silent
+ * @param onComplete optional action to execute when countdown completes
+ * @return the active BukkitTask managing the countdown
+ */
+ public static BukkitTask countdown(
+ @NonNull Player player,
+ int seconds,
+ @NonNull IntFunction secondFormatter,
+ @NonNull String completionTitle,
+ @Nullable String completionSubtitle,
+ @Nullable Sound tickSound,
+ @Nullable Sound completionSound,
+ @Nullable Runnable onComplete
+ ) {
+ return countdown(List.of(player), seconds, secondFormatter, completionTitle, completionSubtitle, tickSound, completionSound, onComplete);
+ }
+
+ /**
+ * Executes an automated animated countdown title sequence across multiple players simultaneously.
+ *
+ * @param players target players
+ * @param seconds countdown duration in seconds
+ * @param completionTitle final title displayed when countdown reaches 0
+ * @param completionSubtitle final subtitle displayed when countdown reaches 0
+ * @param onComplete optional action to execute when countdown completes
+ * @return the active BukkitTask managing the countdown
+ */
+ public static BukkitTask countdown(
+ @NonNull Collection extends Player> players,
+ int seconds,
+ @NonNull String completionTitle,
+ @Nullable String completionSubtitle,
+ @Nullable Runnable onComplete
+ ) {
+ if (seconds <= 0) {
+ for (Player player : players) {
+ if (player.isOnline()) {
+ builder().title(completionTitle).subtitle(completionSubtitle).times(TimeableTitle.of(0, 30, 10)).send(player);
+ }
+ }
+ if (onComplete != null) {
+ onComplete.run();
+ }
+ return null;
+ }
+ return countdown(players, seconds, remaining -> switch (remaining) {
+ case 1 -> "§a§l" + remaining;
+ case 2 -> "§e§l" + remaining;
+ default -> "§c§l" + remaining;
+ }, completionTitle, completionSubtitle, Sound.BLOCK_NOTE_BLOCK_PLING, Sound.BLOCK_NOTE_BLOCK_PLING, onComplete);
+ }
+
+ /**
+ * Executes an automated animated countdown title sequence across multiple players with custom formatting and sounds.
+ *
+ * @param players target players
+ * @param seconds countdown duration in seconds
+ * @param secondFormatter function formatting the title text for each second (receives remaining seconds)
+ * @param completionTitle final title displayed when countdown reaches 0
+ * @param completionSubtitle final subtitle displayed when countdown reaches 0
+ * @param tickSound sound played each second, or null for silent
+ * @param completionSound sound played at countdown completion, or null for silent
+ * @param onComplete optional action to execute when countdown completes
+ * @return the active BukkitTask managing the countdown
+ */
+ public static BukkitTask countdown(
+ @NonNull Collection extends Player> players,
+ int seconds,
+ @NonNull IntFunction secondFormatter,
+ @NonNull String completionTitle,
+ @Nullable String completionSubtitle,
+ @Nullable Sound tickSound,
+ @Nullable Sound completionSound,
+ @Nullable Runnable onComplete
+ ) {
+ Objects.requireNonNull(players, "players cannot be null");
+ Objects.requireNonNull(secondFormatter, "secondFormatter cannot be null");
+ Objects.requireNonNull(completionTitle, "completionTitle cannot be null");
+
+ if (seconds <= 0) {
+ for (Player player : players) {
+ if (player.isOnline()) {
+ builder()
+ .title(completionTitle)
+ .subtitle(completionSubtitle)
+ .times(TimeableTitle.of(0, 30, 10))
+ .send(player);
+ }
+ }
+ if (onComplete != null) {
+ onComplete.run();
+ }
+ return null;
+ }
+
+ Plugin plugin = resolvePlugin();
+
+ return new BukkitRunnable() {
+ private int remaining = seconds;
+
+ @Override
+ public void run() {
+ boolean anyOnline = false;
+ for (Player player : players) {
+ if (player.isOnline()) {
+ anyOnline = true;
+ break;
+ }
+ }
+ if (!anyOnline) {
+ cancel();
+ return;
+ }
+
+ if (remaining > 0) {
+ String titleText = secondFormatter.apply(remaining);
+ float pitch = Math.min(2.0f, 1.0f + (seconds - remaining) * 0.25f);
+
+ for (Player player : players) {
+ if (player.isOnline()) {
+ TitleBuilder builder = builder()
+ .title(titleText)
+ .subtitle("")
+ .times(TimeableTitle.of(0, 22, 5));
+ if (tickSound != null) {
+ builder.sound(tickSound, 1.0f, pitch);
+ }
+ builder.send(player);
+ }
+ }
+
+ remaining--;
+ } else {
+ for (Player player : players) {
+ if (player.isOnline()) {
+ TitleBuilder builder = builder()
+ .title(completionTitle)
+ .subtitle(completionSubtitle)
+ .times(TimeableTitle.of(0, 30, 10));
+ if (completionSound != null) {
+ builder.sound(completionSound, 1.0f, 2.0f);
+ }
+ builder.send(player);
+ }
+ }
+
+ cancel();
+
+ if (onComplete != null) {
+ onComplete.run();
+ }
+ }
+ }
+ }.runTaskTimer(plugin, 0L, 20L);
+ }
+
+ private static Plugin resolvePlugin() {
+ TitleService service = TitleService.getInstance();
+ if (service != null && service.getPlugin() != null) {
+ return service.getPlugin();
+ }
+ return PluginContext.get();
+ }
+
+ private static TitleService getServiceOrLazy() {
+ TitleService service = TitleService.getInstance();
+ if (service == null) {
+ try {
+ Plugin plugin = resolvePlugin();
+ if (plugin != null) {
+ service = TitleService.getOrCreate(plugin);
+ service.start(10L);
+ }
+ } catch (Exception ignored) {
+ }
+ }
+ return service;
+ }
+}
diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/title/TitleAlert.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/title/TitleAlert.java
new file mode 100644
index 00000000..de0fdcb7
--- /dev/null
+++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/title/TitleAlert.java
@@ -0,0 +1,246 @@
+package fr.moussax.bedrock.ui.title;
+
+import fr.moussax.bedrock.sound.SoundCue;
+import org.bukkit.Sound;
+import org.bukkit.entity.Player;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
+import java.time.Duration;
+import java.util.Objects;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+/**
+ * Represents a high-priority, temporary modal title display.
+ *
+ * Modal alerts temporarily override persistent titles or lower-priority alerts.
+ * When the alert expires, underlying persistent titles are automatically restored.
+ */
+public final class TitleAlert implements Comparable {
+
+ private final Function titleSupplier;
+ private final Function subtitleSupplier;
+ private final int priority;
+ private final long expiresAt;
+ private final TimeableTitle times;
+ private final SoundCue soundCue;
+ private boolean soundPlayed = false;
+
+ /**
+ * Constructs a modal title alert with explicit parameters.
+ *
+ * @param titleSupplier title text function
+ * @param subtitleSupplier subtitle text function
+ * @param priority alert precedence priority
+ * @param durationMillis lifespan in milliseconds
+ * @param times timing configuration
+ * @param soundCue optional sound cue played on initial render
+ */
+ public TitleAlert(
+ @NonNull Function titleSupplier,
+ @NonNull Function subtitleSupplier,
+ int priority,
+ long durationMillis,
+ @NonNull TimeableTitle times,
+ @Nullable SoundCue soundCue
+ ) {
+ this.titleSupplier = Objects.requireNonNull(titleSupplier, "titleSupplier cannot be null");
+ this.subtitleSupplier = Objects.requireNonNull(subtitleSupplier, "subtitleSupplier cannot be null");
+ this.priority = priority;
+ this.expiresAt = System.currentTimeMillis() + Math.max(0L, durationMillis);
+ this.times = Objects.requireNonNull(times, "times cannot be null");
+ this.soundCue = soundCue;
+ }
+
+ /**
+ * Creates a default-priority modal alert with default alert timings.
+ *
+ * @param title title text
+ * @param subtitle subtitle text
+ * @param duration display duration
+ * @return new title alert
+ */
+ public static TitleAlert of(@Nullable String title, @Nullable String subtitle, @NonNull Duration duration) {
+ return of(title, subtitle, 0, duration, TimeableTitle.ALERT, (SoundCue) null);
+ }
+
+ /**
+ * Creates a modal alert with custom priority and default alert timings.
+ *
+ * @param title title text
+ * @param subtitle subtitle text
+ * @param priority alert priority
+ * @param duration display duration
+ * @return new title alert
+ */
+ public static TitleAlert of(@Nullable String title, @Nullable String subtitle, int priority, @NonNull Duration duration) {
+ return of(title, subtitle, priority, duration, TimeableTitle.ALERT, (SoundCue) null);
+ }
+
+ /**
+ * Creates a modal alert with custom priority and timings.
+ *
+ * @param title title text
+ * @param subtitle subtitle text
+ * @param priority alert priority
+ * @param duration display duration
+ * @param times timing configuration
+ * @return new title alert
+ */
+ public static TitleAlert of(
+ @Nullable String title,
+ @Nullable String subtitle,
+ int priority,
+ @NonNull Duration duration,
+ @NonNull TimeableTitle times
+ ) {
+ return of(title, subtitle, priority, duration, times, (SoundCue) null);
+ }
+
+ /**
+ * Creates a modal alert with custom priority, timings, and an associated sound cue.
+ *
+ * @param title title text
+ * @param subtitle subtitle text
+ * @param priority alert priority
+ * @param duration display duration
+ * @param times timing configuration
+ * @param soundCue sound cue played when shown
+ * @return new title alert
+ */
+ public static TitleAlert of(
+ @Nullable String title,
+ @Nullable String subtitle,
+ int priority,
+ @NonNull Duration duration,
+ @NonNull TimeableTitle times,
+ @Nullable SoundCue soundCue
+ ) {
+ String finalTitle = title != null ? title : "";
+ String finalSubtitle = subtitle != null ? subtitle : "";
+ return new TitleAlert(_ -> finalTitle, _ -> finalSubtitle, priority, duration.toMillis(), times, soundCue);
+ }
+
+ /**
+ * Creates a modal alert with custom priority, timings, and an associated Bukkit sound.
+ *
+ * @param title title text
+ * @param subtitle subtitle text
+ * @param priority alert priority
+ * @param duration display duration
+ * @param times timing configuration
+ * @param sound sound to play when displayed
+ * @return new title alert
+ */
+ public static TitleAlert of(
+ @Nullable String title,
+ @Nullable String subtitle,
+ int priority,
+ @NonNull Duration duration,
+ @NonNull TimeableTitle times,
+ @Nullable Sound sound
+ ) {
+ SoundCue soundCue = sound != null ? new SoundCue(sound, 1.0f, 1.0f, 0L) : null;
+ return of(title, subtitle, priority, duration, times, soundCue);
+ }
+
+ /**
+ * Creates a dynamic modal alert evaluating dynamic suppliers.
+ *
+ * @param titleSupplier title text supplier
+ * @param subtitleSupplier subtitle text supplier
+ * @param priority alert priority
+ * @param duration display duration
+ * @param times timing configuration
+ * @return new title alert
+ */
+ public static TitleAlert of(
+ @NonNull Supplier<@Nullable String> titleSupplier,
+ @NonNull Supplier<@Nullable String> subtitleSupplier,
+ int priority,
+ @NonNull Duration duration,
+ @NonNull TimeableTitle times
+ ) {
+ Objects.requireNonNull(titleSupplier, "titleSupplier cannot be null");
+ Objects.requireNonNull(subtitleSupplier, "subtitleSupplier cannot be null");
+ return new TitleAlert(_ -> titleSupplier.get(), _ -> subtitleSupplier.get(), priority, duration.toMillis(), times, null);
+ }
+
+ /**
+ * Checks whether this alert has reached the end of its lifespan.
+ *
+ * @return true if expired
+ */
+ public boolean isExpired() {
+ return System.currentTimeMillis() >= expiresAt;
+ }
+
+ /**
+ * Returns the remaining time in milliseconds before expiration.
+ *
+ * @return remaining milliseconds
+ */
+ public long remainingMillis() {
+ return Math.max(0L, expiresAt - System.currentTimeMillis());
+ }
+
+ /**
+ * Evaluates the title string for the given player.
+ *
+ * @param player viewing player
+ * @return evaluated title string
+ */
+ public @Nullable String title(@Nullable Player player) {
+ return titleSupplier.apply(player);
+ }
+
+ /**
+ * Evaluates the subtitle string for the given player.
+ *
+ * @param player viewing player
+ * @return evaluated subtitle string
+ */
+ public @Nullable String subtitle(@Nullable Player player) {
+ return subtitleSupplier.apply(player);
+ }
+
+ /**
+ * Returns the alert priority.
+ *
+ * @return priority value
+ */
+ public int priority() {
+ return priority;
+ }
+
+ /**
+ * Returns the timing configuration for this alert.
+ *
+ * @return timing configuration
+ */
+ public TimeableTitle times() {
+ return times;
+ }
+
+ /**
+ * Plays the associated sound cue if not already played.
+ *
+ * @param player target player
+ */
+ public void playSoundIfNeeded(@NonNull Player player) {
+ if (!soundPlayed && soundCue != null) {
+ soundPlayed = true;
+ soundCue.play(player);
+ }
+ }
+
+ @Override
+ public int compareTo(@NonNull TitleAlert other) {
+ int precedence = Integer.compare(other.priority, this.priority);
+ if (precedence != 0) {
+ return precedence;
+ }
+ return Long.compare(this.expiresAt, other.expiresAt);
+ }
+}
diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/title/TitleBuilder.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/title/TitleBuilder.java
new file mode 100644
index 00000000..4ea7f54d
--- /dev/null
+++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/title/TitleBuilder.java
@@ -0,0 +1,207 @@
+package fr.moussax.bedrock.ui.title;
+
+import fr.moussax.bedrock.sound.SoundCue;
+import org.bukkit.Bukkit;
+import org.bukkit.Sound;
+import org.bukkit.entity.Player;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
+import java.time.Duration;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Objects;
+
+/**
+ * Fluent builder for constructing and dispatching titles to players.
+ */
+public final class TitleBuilder {
+
+ private String title = "";
+ private String subtitle = "";
+ private TimeableTitle times = TimeableTitle.DEFAULT;
+ private SoundCue soundCue;
+
+ TitleBuilder() {
+ }
+
+ /**
+ * Sets the main title text.
+ *
+ * @param title text to display in the main title
+ * @return this builder
+ */
+ public TitleBuilder title(@Nullable String title) {
+ this.title = title != null ? title : "";
+ return this;
+ }
+
+ /**
+ * Sets the subtitle text.
+ *
+ * @param subtitle text to display below the main title
+ * @return this builder
+ */
+ public TitleBuilder subtitle(@Nullable String subtitle) {
+ this.subtitle = subtitle != null ? subtitle : "";
+ return this;
+ }
+
+ /**
+ * Sets the timing parameters for this title.
+ *
+ * @param times timing configuration
+ * @return this builder
+ */
+ public TitleBuilder times(@NonNull TimeableTitle times) {
+ this.times = Objects.requireNonNull(times, "times");
+ return this;
+ }
+
+ /**
+ * Sets the display timing parameters using server tick counts.
+ *
+ * @param fadeIn fade-in ticks
+ * @param stay stay ticks
+ * @param fadeOut fade-out ticks
+ * @return this builder
+ */
+ public TitleBuilder times(int fadeIn, int stay, int fadeOut) {
+ return times(TimeableTitle.of(fadeIn, stay, fadeOut));
+ }
+
+ /**
+ * Sets the display timing parameters using {@link Duration} values.
+ *
+ * @param fadeIn fade-in duration
+ * @param stay stay duration
+ * @param fadeOut fade-out duration
+ * @return this builder
+ */
+ public TitleBuilder times(@NonNull Duration fadeIn, @NonNull Duration stay, @NonNull Duration fadeOut) {
+ return times(TimeableTitle.of(fadeIn, stay, fadeOut));
+ }
+
+ /**
+ * Sets a custom stay duration with default fade transitions.
+ *
+ * @param stayDuration duration the title remains visible
+ * @return this builder
+ */
+ public TitleBuilder stay(@NonNull Duration stayDuration) {
+ return times(TimeableTitle.stay(stayDuration));
+ }
+
+ /**
+ * Sets a custom stay duration in server ticks with default fade transitions.
+ *
+ * @param stayTicks ticks the title remains visible
+ * @return this builder
+ */
+ public TitleBuilder stay(int stayTicks) {
+ return times(TimeableTitle.stay(stayTicks));
+ }
+
+ /**
+ * Associates a sound to play when this title is displayed.
+ *
+ * @param soundCue sound cue to play
+ * @return this builder
+ */
+ public TitleBuilder sound(@Nullable SoundCue soundCue) {
+ this.soundCue = soundCue;
+ return this;
+ }
+
+ /**
+ * Associates a Bukkit sound with volume 1.0 and pitch 1.0 to play when this title is displayed.
+ *
+ * @param sound sound to play
+ * @return this builder
+ */
+ public TitleBuilder sound(@Nullable Sound sound) {
+ return sound(sound, 1.0f, 1.0f);
+ }
+
+ /**
+ * Associates a Bukkit sound with custom volume and pitch to play when this title is displayed.
+ *
+ * @param sound sound to play
+ * @param volume sound volume
+ * @param pitch sound pitch
+ * @return this builder
+ */
+ public TitleBuilder sound(@Nullable Sound sound, float volume, float pitch) {
+ if (sound == null) {
+ this.soundCue = null;
+ return this;
+ }
+ this.soundCue = new SoundCue(sound, volume, pitch, 0L);
+ return this;
+ }
+
+ public @NonNull String getTitle() {
+ return title;
+ }
+
+ public @NonNull String getSubtitle() {
+ return subtitle;
+ }
+
+ public @NonNull TimeableTitle getTimes() {
+ return times;
+ }
+
+ public @Nullable SoundCue getSoundCue() {
+ return soundCue;
+ }
+
+ /**
+ * Dispatches the configured title to the specified player.
+ *
+ * @param player player to receive the title
+ */
+ public void send(@NonNull Player player) {
+ Objects.requireNonNull(player, "player");
+
+ // Minecraft clients skip rendering the subtitle if the main title is empty or null.
+ // A single space " " satisfies client constraints while rendering only the subtitle visually.
+ String effectiveTitle = title.isEmpty() && !subtitle.isEmpty() ? " " : title;
+
+ player.sendTitle(effectiveTitle, subtitle, times.fadeIn(), times.stay(), times.fadeOut());
+
+ if (soundCue != null) {
+ soundCue.play(player);
+ }
+ }
+
+ /**
+ * Dispatches the configured title to multiple players.
+ *
+ * @param players collection of players to receive the title
+ */
+ public void send(@NonNull Collection extends Player> players) {
+ Objects.requireNonNull(players, "players");
+ for (Player player : players) {
+ if (player != null && player.isOnline()) {
+ send(player);
+ }
+ }
+ }
+
+ /**
+ * Dispatches the configured title to an array of players.
+ *
+ * @param players array of players to receive the title
+ */
+ public void send(@NonNull Player... players) {
+ send(Arrays.asList(players));
+ }
+
+ /**
+ * Dispatches the configured title to all online players on the server.
+ */
+ public void broadcast() {
+ send(Bukkit.getOnlinePlayers());
+ }
+}
diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/title/TitleComposer.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/title/TitleComposer.java
new file mode 100644
index 00000000..69413f36
--- /dev/null
+++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/title/TitleComposer.java
@@ -0,0 +1,175 @@
+package fr.moussax.bedrock.ui.title;
+
+import org.bukkit.entity.Player;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
+import java.time.Duration;
+import java.util.Objects;
+import java.util.PriorityQueue;
+
+/**
+ * Manages per-player title state composition, combining persistent ambient HUD titles
+ * and temporary modal alerts with seamless automatic restoration and diff-suppression.
+ */
+public final class TitleComposer {
+
+ private final PriorityQueue modalAlerts = new PriorityQueue<>();
+ private final Object alertLock = new Object();
+
+ private volatile PersistentTitle persistentTitle;
+
+ private String lastSentTitle;
+ private String lastSentSubtitle;
+ private TimeableTitle lastSentTimes;
+ private long lastTimingSentTimestamp = 0L;
+
+ /**
+ * Sets or replaces the persistent ambient title for this player.
+ *
+ * @param persistent persistent title configuration, or null to clear
+ */
+ public void setPersistentTitle(@Nullable PersistentTitle persistent) {
+ this.persistentTitle = persistent;
+ }
+
+ /**
+ * Removes the persistent ambient title.
+ */
+ public void clearPersistentTitle() {
+ this.persistentTitle = null;
+ }
+
+ /**
+ * Checks if a persistent title is currently active.
+ *
+ * @return true if a persistent title is configured
+ */
+ public boolean hasPersistentTitle() {
+ return persistentTitle != null;
+ }
+
+ /**
+ * Queues a temporary modal alert to override existing titles.
+ *
+ * @param alert modal alert to queue
+ */
+ public void sendModalAlert(@NonNull TitleAlert alert) {
+ Objects.requireNonNull(alert, "alert cannot be null");
+ synchronized (alertLock) {
+ modalAlerts.add(alert);
+ }
+ }
+
+ /**
+ * Queues a temporary modal alert with specified priority and duration.
+ *
+ * @param title title text
+ * @param subtitle subtitle text
+ * @param priority alert priority
+ * @param duration display duration
+ */
+ public void sendModalAlert(@Nullable String title, @Nullable String subtitle, int priority, @NonNull Duration duration) {
+ sendModalAlert(TitleAlert.of(title, subtitle, priority, duration));
+ }
+
+ /**
+ * Clears all active modal alerts and immediately restores any underlying persistent title.
+ */
+ public void clearAlerts() {
+ synchronized (alertLock) {
+ modalAlerts.clear();
+ }
+ }
+
+ /**
+ * Clears all modal alerts, removes the persistent title, and resets display state.
+ *
+ * @param player viewing player
+ */
+ public void clear(@NonNull Player player) {
+ clearAlerts();
+ clearPersistentTitle();
+ render(player);
+ }
+
+ /**
+ * Renders the current composite title state to the given player.
+ *
+ * Packet diff suppression ensures that identical titles are not spammed across the network,
+ * while timing cache tracking prevents redundant timing packets that would reset client animations.
+ *
+ * @param player target player
+ */
+ public void render(@NonNull Player player) {
+ if (!player.isOnline()) {
+ return;
+ }
+
+ TitleAlert activeAlert;
+ synchronized (alertLock) {
+ modalAlerts.removeIf(TitleAlert::isExpired);
+ activeAlert = modalAlerts.peek();
+ }
+
+ String rawTitle;
+ String rawSubtitle;
+ TimeableTitle times;
+
+ if (activeAlert != null) {
+ rawTitle = activeAlert.title(player);
+ rawSubtitle = activeAlert.subtitle(player);
+ times = activeAlert.times();
+ activeAlert.playSoundIfNeeded(player);
+ } else if (persistentTitle != null) {
+ rawTitle = persistentTitle.titleSupplier().apply(player);
+ rawSubtitle = persistentTitle.subtitleSupplier().apply(player);
+ times = persistentTitle.times();
+ } else {
+ rawTitle = null;
+ rawSubtitle = null;
+ times = null;
+ }
+
+ if (rawTitle == null && rawSubtitle == null) {
+ if (lastSentTitle != null || lastSentSubtitle != null) {
+ player.resetTitle();
+ lastSentTitle = null;
+ lastSentSubtitle = null;
+ lastSentTimes = null;
+ lastTimingSentTimestamp = 0L;
+ }
+ return;
+ }
+
+ String title = (rawTitle != null && !rawTitle.isEmpty())
+ ? rawTitle
+ : (rawSubtitle != null && !rawSubtitle.isEmpty() ? " " : "");
+ String subtitle = rawSubtitle != null ? rawSubtitle : "";
+
+ long now = System.currentTimeMillis();
+ boolean timesChanged = !Objects.equals(times, lastSentTimes);
+ boolean textChanged = !Objects.equals(title, lastSentTitle) || !Objects.equals(subtitle, lastSentSubtitle);
+
+ long stayMillis = times.stay() * 50L;
+ boolean nearExpiration = (now - lastTimingSentTimestamp) >= Math.max(1000L, (stayMillis * 3) / 4);
+
+ if (timesChanged) {
+ TitlePacketSender.sendFull(player, title, subtitle, times.fadeIn(), times.stay(), times.fadeOut());
+ lastSentTimes = times;
+ lastSentTitle = title;
+ lastSentSubtitle = subtitle;
+ lastTimingSentTimestamp = now;
+ } else if (nearExpiration && (activeAlert != null || persistentTitle != null)) {
+ // Stay is nearing expiration. Refresh stay duration with 0 fade-in to prevent opacity dipping.
+ TitlePacketSender.sendFull(player, title, subtitle, 0, times.stay(), times.fadeOut());
+ lastSentTitle = title;
+ lastSentSubtitle = subtitle;
+ lastTimingSentTimestamp = now;
+ } else if (textChanged) {
+ TitlePacketSender.sendTextOnly(player, title, subtitle);
+ lastSentTitle = title;
+ lastSentSubtitle = subtitle;
+ }
+ }
+}
diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/title/TitlePacketSender.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/title/TitlePacketSender.java
new file mode 100644
index 00000000..b2e85875
--- /dev/null
+++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/title/TitlePacketSender.java
@@ -0,0 +1,110 @@
+package fr.moussax.bedrock.ui.title;
+
+import net.minecraft.network.chat.Component;
+import net.minecraft.network.protocol.game.ClientboundSetSubtitleTextPacket;
+import net.minecraft.network.protocol.game.ClientboundSetTitleTextPacket;
+import org.bukkit.craftbukkit.entity.CraftPlayer;
+import org.bukkit.craftbukkit.util.CraftChatMessage;
+import org.bukkit.entity.Player;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Low-level packet dispatcher for title and subtitle packets.
+ *
+ * Dispatches text-only packets without re-sending timing animation packets,
+ * preventing Minecraft client opacity resets and visual flickering during in-place text updates.
+ */
+public final class TitlePacketSender {
+
+ /**
+ * Delegate interface for custom packet handling or test assertions.
+ */
+ @FunctionalInterface
+ public interface PacketHandler {
+ void sendText(@NonNull Player player, @Nullable String title, @Nullable String subtitle);
+ }
+
+ private static volatile PacketHandler customHandler;
+
+ private TitlePacketSender() {
+ }
+
+ /**
+ * Overrides the text packet handler for testing or custom packet pipelines.
+ *
+ * @param handler custom packet handler, or null to revert to default NMS dispatch
+ */
+ public static void setCustomHandler(@Nullable PacketHandler handler) {
+ customHandler = handler;
+ }
+
+ /**
+ * Sends in-place title and subtitle text packets to a player without touching client animation timings.
+ *
+ * On production Spigot servers, dispatches {@link ClientboundSetTitleTextPacket} and
+ * {@link ClientboundSetSubtitleTextPacket} directly via the player's network connection.
+ * This avoids sending {@link net.minecraft.network.protocol.game.ClientboundSetTitlesAnimationPacket},
+ * which resets client fade-in opacity and causes flickering.
+ *
+ * @param player target player
+ * @param title title text
+ * @param subtitle subtitle text
+ */
+ public static void sendTextOnly(@NonNull Player player, @Nullable String title, @Nullable String subtitle) {
+ if (!player.isOnline()) {
+ return;
+ }
+
+ PacketHandler handler = customHandler;
+ if (handler != null) {
+ handler.sendText(player, title, subtitle);
+ return;
+ }
+
+ if (player instanceof CraftPlayer craftPlayer) {
+ var handle = craftPlayer.getHandle();
+ if (handle == null) {
+ return;
+ }
+ var connection = handle.connection;
+ if (title != null) {
+ Component[] components = CraftChatMessage.fromString(title);
+ Component component = (components != null && components.length > 0) ? components[0] : Component.empty();
+ connection.send(new ClientboundSetTitleTextPacket(component));
+ }
+ if (subtitle != null) {
+ Component[] components = CraftChatMessage.fromString(subtitle);
+ Component component = (components != null && components.length > 0) ? components[0] : Component.empty();
+ connection.send(new ClientboundSetSubtitleTextPacket(component));
+ }
+ return;
+ }
+
+ player.sendTitle(title != null ? title : "", subtitle != null ? subtitle : "", 0, 70, 0);
+ }
+
+ /**
+ * Sends complete title display parameters including fade timings and text.
+ *
+ * @param player target player
+ * @param title title text
+ * @param subtitle subtitle text
+ * @param fadeIn fade-in ticks
+ * @param stay stay ticks
+ * @param fadeOut fade-out ticks
+ */
+ public static void sendFull(
+ @NonNull Player player,
+ @Nullable String title,
+ @Nullable String subtitle,
+ int fadeIn,
+ int stay,
+ int fadeOut
+ ) {
+ if (!player.isOnline()) {
+ return;
+ }
+ player.sendTitle(title != null ? title : "", subtitle != null ? subtitle : "", fadeIn, stay, fadeOut);
+ }
+}
diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/title/TitleService.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/title/TitleService.java
new file mode 100644
index 00000000..9240872a
--- /dev/null
+++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/title/TitleService.java
@@ -0,0 +1,254 @@
+package fr.moussax.bedrock.ui.title;
+
+import fr.moussax.bedrock.utils.debug.Log;
+import lombok.Getter;
+import lombok.Setter;
+import org.bukkit.Bukkit;
+import org.bukkit.entity.Player;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.HandlerList;
+import org.bukkit.event.Listener;
+import org.bukkit.event.player.PlayerQuitEvent;
+import org.bukkit.plugin.Plugin;
+import org.bukkit.scheduler.BukkitTask;
+import org.jspecify.annotations.NonNull;
+
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Manages player title lifecycles, composers, modal alert queues, and periodic rendering passes.
+ *
+ * Centralizes title packet dispatch to eliminate flicker, coordinate modal alert priorities,
+ * and seamlessly restore persistent titles (e.g. minigame HUDs) upon alert expiration.
+ */
+public final class TitleService implements Listener {
+
+ @Getter
+ @Setter
+ private static volatile TitleService instance;
+
+ @Getter
+ private final Plugin plugin;
+ private final Map composers = new ConcurrentHashMap<>();
+ private final Set animatingPlayers = ConcurrentHashMap.newKeySet();
+ private BukkitTask tickerTask;
+ private volatile boolean running = false;
+ private long currentPeriodTicks = 10L;
+
+ /**
+ * Constructs a title service and registers quit listeners with Bukkit.
+ *
+ * @param plugin owning plugin
+ */
+ public TitleService(@NonNull Plugin plugin) {
+ this.plugin = Objects.requireNonNull(plugin, "plugin cannot be null");
+ if (instance == null) {
+ instance = this;
+ }
+ Bukkit.getPluginManager().registerEvents(this, plugin);
+ }
+
+ /**
+ * Obtains the shared {@link TitleService} singleton, or constructs one if not yet initialized.
+ *
+ * @param plugin owning plugin
+ * @return active title service instance
+ */
+ @NonNull
+ public static TitleService getOrCreate(@NonNull Plugin plugin) {
+ if (instance == null) {
+ synchronized (TitleService.class) {
+ if (instance == null) {
+ new TitleService(plugin);
+ }
+ }
+ }
+ return instance;
+ }
+
+ /**
+ * Starts or updates the periodic title render task.
+ *
+ * @param periodTicks interval between periodic renders in server ticks
+ */
+ public void start(long periodTicks) {
+ if (running && tickerTask != null && !tickerTask.isCancelled()) {
+ if (periodTicks < currentPeriodTicks) {
+ this.currentPeriodTicks = periodTicks;
+ tickerTask.cancel();
+ this.tickerTask = Bukkit.getScheduler().runTaskTimer(plugin, this::tickAll, 0L, periodTicks);
+ }
+ return;
+ }
+
+ this.currentPeriodTicks = periodTicks;
+ this.running = true;
+
+ if (tickerTask != null) {
+ tickerTask.cancel();
+ }
+
+ this.tickerTask = Bukkit.getScheduler().runTaskTimer(plugin, this::tickAll, 0L, periodTicks);
+ }
+
+ /**
+ * Stops the periodic render task and cleans up all active composers.
+ */
+ public void stop() {
+ running = false;
+ if (tickerTask != null) {
+ tickerTask.cancel();
+ tickerTask = null;
+ }
+ composers.clear();
+ HandlerList.unregisterAll(this);
+ }
+
+ /**
+ * Retrieves or creates a title composer for the specified player UUID.
+ *
+ * @param uuid player unique ID
+ * @return player's title composer
+ */
+ @NonNull
+ public TitleComposer getOrCreateComposer(@NonNull UUID uuid) {
+ return composers.computeIfAbsent(uuid, _ -> new TitleComposer());
+ }
+
+ /**
+ * Sets a persistent ambient title on a player and immediately renders it.
+ *
+ * @param player target player
+ * @param persistentTitle persistent title configuration
+ */
+ public void setPersistent(@NonNull Player player, @NonNull PersistentTitle persistentTitle) {
+ TitleComposer composer = getOrCreateComposer(player.getUniqueId());
+ composer.setPersistentTitle(persistentTitle);
+ renderPlayer(player);
+ }
+
+ /**
+ * Clears any active persistent title from a player.
+ *
+ * @param player target player
+ */
+ public void clearPersistent(@NonNull Player player) {
+ TitleComposer composer = composers.get(player.getUniqueId());
+ if (composer != null) {
+ composer.clearPersistentTitle();
+ renderPlayer(player);
+ }
+ }
+
+ /**
+ * Checks if a player has an active persistent title.
+ *
+ * @param player target player
+ * @return true if persistent title is active
+ */
+ public boolean hasPersistent(@NonNull Player player) {
+ TitleComposer composer = composers.get(player.getUniqueId());
+ return composer != null && composer.hasPersistentTitle();
+ }
+
+ /**
+ * Queues a modal alert and immediately forces a render pass.
+ *
+ * @param player target player
+ * @param alert modal alert
+ */
+ public void sendAlert(@NonNull Player player, @NonNull TitleAlert alert) {
+ TitleComposer composer = getOrCreateComposer(player.getUniqueId());
+ composer.sendModalAlert(alert);
+ renderPlayer(player);
+ }
+
+ /**
+ * Clears all active modal alerts and restores any persistent title.
+ *
+ * @param player target player
+ */
+ public void clearAlerts(@NonNull Player player) {
+ TitleComposer composer = composers.get(player.getUniqueId());
+ if (composer != null) {
+ composer.clearAlerts();
+ renderPlayer(player);
+ }
+ }
+
+ /**
+ * Clears both alerts and persistent titles for a player, resetting the client title.
+ *
+ * @param player target player
+ */
+ public void clearAll(@NonNull Player player) {
+ TitleComposer composer = composers.get(player.getUniqueId());
+ if (composer != null) {
+ composer.clear(player);
+ } else {
+ player.resetTitle();
+ }
+ }
+
+ /**
+ * Marks whether a player currently has an active text animation running on their title.
+ * When animating, periodic persistent title rendering is suspended to prevent frame stutter.
+ *
+ * @param uuid target player UUID
+ * @param animating whether an animation is currently executing
+ */
+ public void setAnimating(@NonNull UUID uuid, boolean animating) {
+ if (animating) {
+ animatingPlayers.add(uuid);
+ } else {
+ animatingPlayers.remove(uuid);
+ }
+ }
+
+ /**
+ * Checks if a player currently has an active text animation running on their title.
+ *
+ * @param uuid target player UUID
+ * @return true if an animation is active
+ */
+ public boolean isAnimating(@NonNull UUID uuid) {
+ return animatingPlayers.contains(uuid);
+ }
+
+ /**
+ * Immediately evaluates and renders title updates for a player.
+ *
+ * @param player target player
+ */
+ public void renderPlayer(@NonNull Player player) {
+ if (!player.isOnline()) {
+ return;
+ }
+ if (isAnimating(player.getUniqueId())) {
+ return;
+ }
+ TitleComposer composer = getOrCreateComposer(player.getUniqueId());
+ composer.render(player);
+ }
+
+ @EventHandler
+ public void onQuit(PlayerQuitEvent event) {
+ UUID uuid = event.getPlayer().getUniqueId();
+ animatingPlayers.remove(uuid);
+ composers.remove(uuid);
+ }
+
+ private void tickAll() {
+ for (Player player : Bukkit.getOnlinePlayers()) {
+ try {
+ renderPlayer(player);
+ } catch (Exception exception) {
+ Log.warn("TitleService", "Failed to render title for " + player.getName() + ": " + exception.getMessage());
+ }
+ }
+ }
+}
diff --git a/bedrock/src/test/java/fr/moussax/bedrock/ui/animation/TextAnimationTest.java b/bedrock/src/test/java/fr/moussax/bedrock/ui/animation/TextAnimationTest.java
new file mode 100644
index 00000000..85ece4b2
--- /dev/null
+++ b/bedrock/src/test/java/fr/moussax/bedrock/ui/animation/TextAnimationTest.java
@@ -0,0 +1,242 @@
+package fr.moussax.bedrock.ui.animation;
+
+import fr.moussax.bedrock.ui.actionbar.Actionbar;
+import fr.moussax.bedrock.ui.actionbar.ActionbarService;
+import fr.moussax.bedrock.ui.title.TimeableTitle;
+import fr.moussax.bedrock.ui.title.Title;
+import fr.moussax.bedrock.ui.title.TitlePacketSender;
+import fr.moussax.bedrock.ui.title.TitleService;
+import org.bukkit.Location;
+import org.bukkit.entity.Player;
+import org.bukkit.plugin.Plugin;
+import fr.moussax.bedrock.scheduling.PluginContext;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Proxy;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class TextAnimationTest {
+
+ @BeforeEach
+ void setUp() {
+ PluginContext.bind(createMockPlugin());
+ }
+
+ @AfterEach
+ void tearDown() {
+ TitlePacketSender.setCustomHandler(null);
+ TextAnimation.setCustomActionbarHandler(null);
+ ActionbarService.setInstance(null);
+ TitleService.setInstance(null);
+ PluginContext.unbind();
+ }
+
+ private Player createMockPlayer(UUID uuid, List titleRef) {
+ return (Player) Proxy.newProxyInstance(
+ Player.class.getClassLoader(),
+ new Class>[]{Player.class},
+ (_, method, args) -> {
+ String name = method.getName();
+ if ("getUniqueId".equals(name)) return uuid;
+ if ("isOnline".equals(name)) return true;
+ if ("getName".equals(name)) return "TestPlayer";
+ if ("getLocation".equals(name)) return new Location(null, 0, 0, 0);
+ if ("playSound".equals(name)) return null;
+ if ("sendTitle".equals(name) && args != null && args.length > 0) {
+ titleRef.add((String) args[0]);
+ return null;
+ }
+ return null;
+ }
+ );
+ }
+
+ private Plugin createMockPlugin() {
+ return (Plugin) Proxy.newProxyInstance(
+ Plugin.class.getClassLoader(),
+ new Class>[]{Plugin.class},
+ (_, _, _) -> null
+ );
+ }
+
+ @Test
+ @DisplayName("Expects typewriter factory to incrementally reveal characters while preserving color codes")
+ void testTypewriterFrameGeneration() {
+ String input = "§aAB§cCD";
+ TextAnimation anim = TextAnimation.typewriter(input, 2L, null, Duration.ofSeconds(1));
+
+ assertEquals(2L, anim.tickInterval());
+ assertEquals(Duration.ofSeconds(1), anim.finalStay());
+ assertEquals(4, anim.frames().size());
+
+ assertEquals("§aA", anim.frames().get(0).text());
+ assertEquals("§aAB", anim.frames().get(1).text());
+ assertEquals("§aAB§cC", anim.frames().get(2).text());
+ assertEquals("§aAB§cCD", anim.frames().get(3).text());
+ assertNull(anim.frames().getFirst().sound());
+ }
+
+ @Test
+ @DisplayName("Expects shimmer factory to generate dynamic color sweep frames")
+ void testShimmerFrameGeneration() {
+ TextAnimation anim = TextAnimation.shimmer("WIN", "§5", "§d", "§f", true, 1, TimeableTitle.ALERT);
+
+ assertEquals(1L, anim.tickInterval());
+ assertEquals(TimeableTitle.ALERT, anim.finalTimes());
+ assertFalse(anim.frames().isEmpty());
+
+ // For 3 characters and highlight from -2 to 4, each cycle has 7 frames
+ assertEquals(7, anim.frames().size());
+
+ // Peak highlight at index 0 should give highlightColor on first letter and bold prefix
+ TextAnimation.Frame peakFrame = anim.frames().get(2); // highlight == 0
+ assertTrue(peakFrame.text().contains("§f§lW"));
+ }
+
+ @Test
+ @DisplayName("Expects pulse factory to alternate between highlight and primary color frames")
+ void testPulseFrameGeneration() {
+ TextAnimation pulse = TextAnimation.pulse("ALERT", "§c", "§f", 5L, 3, null);
+
+ assertEquals(5L, pulse.tickInterval());
+ assertEquals(6, pulse.frames().size()); // 3 flashes * 2 phases
+
+ assertEquals("§fALERT", pulse.frames().get(0).text());
+ assertEquals("§cALERT", pulse.frames().get(1).text());
+ assertNull(pulse.frames().get(0).sound());
+ assertNull(pulse.frames().get(1).sound());
+ }
+
+ @Test
+ @DisplayName("Expects chained animations to preserve all intermediate stages via then()")
+ void testChainingPreservesAllStages() {
+ TextAnimation stage1 = TextAnimation.builder().frame("1").build();
+ TextAnimation stage2 = TextAnimation.builder().frame("2").build();
+ TextAnimation stage3 = TextAnimation.builder().frame("3").build();
+
+ TextAnimation chained = stage1.then(stage2).then(stage3);
+
+ assertEquals("1", chained.frames().getFirst().text());
+ assertNotNull(chained.nextAnimation());
+ assertEquals("2", chained.nextAnimation().frames().getFirst().text());
+ assertNotNull(chained.nextAnimation().nextAnimation());
+ assertEquals("3", chained.nextAnimation().nextAnimation().frames().getFirst().text());
+ }
+
+ @Test
+ @DisplayName("Expects playActionbar to dispatch text frames via actionbar packet handler")
+ void testPlayActionbarSingleFrame() {
+ UUID uuid = UUID.randomUUID();
+ List actionbarPackets = new ArrayList<>();
+ List titlePackets = new ArrayList<>();
+ Player player = createMockPlayer(uuid, titlePackets);
+
+ TextAnimation.setCustomActionbarHandler((_, text) -> actionbarPackets.add(text));
+
+ AtomicBoolean completed = new AtomicBoolean(false);
+ TextAnimation anim = TextAnimation.builder()
+ .frame("§eHello Actionbar")
+ .finalStay(Duration.ZERO)
+ .build();
+
+ Actionbar.animate(player, anim, () -> completed.set(true));
+
+ assertFalse(actionbarPackets.isEmpty());
+ assertTrue(actionbarPackets.getFirst().contains("Hello Actionbar"));
+ assertTrue(completed.get());
+ }
+
+ @Test
+ @DisplayName("Expects playTitle to dispatch text frames to player title sender")
+ void testPlayTitleSingleFrame() {
+ UUID uuid = UUID.randomUUID();
+ List titlePackets = new ArrayList<>();
+ Player player = createMockPlayer(uuid, titlePackets);
+
+ List textOnlyTitles = new ArrayList<>();
+ TitlePacketSender.setCustomHandler((_, title, _) -> {
+ if (title != null) textOnlyTitles.add(title);
+ });
+
+ AtomicBoolean completed = new AtomicBoolean(false);
+ TextAnimation anim = TextAnimation.builder()
+ .frame("§6Victory!", "§7Subtitle")
+ .finalTimes(TimeableTitle.of(0, 0, 0))
+ .build();
+
+ Title.animate(player, anim, () -> completed.set(true));
+
+ assertTrue(titlePackets.contains("§6Victory!"));
+ assertTrue(completed.get());
+ }
+
+ @Test
+ @DisplayName("Expects generic frame consumer to receive all frames during play")
+ void testGenericFrameConsumer() {
+ UUID uuid = UUID.randomUUID();
+ Player player = createMockPlayer(uuid, new ArrayList<>());
+ Plugin plugin = createMockPlugin();
+
+ List consumed = new ArrayList<>();
+ AtomicBoolean completed = new AtomicBoolean(false);
+
+ TextAnimation anim = TextAnimation.builder()
+ .frame("A")
+ .build();
+
+ anim.play(plugin, player, (_, frame) -> consumed.add(frame.text()), () -> completed.set(true));
+
+ assertEquals("A", consumed.getFirst());
+ assertTrue(completed.get());
+ }
+
+ @Test
+ @DisplayName("Expects custom builder configuration to apply intervals, finalStay, and finalTimes")
+ void testBuilderConfiguration() {
+ TextAnimation anim = TextAnimation.builder()
+ .frame("Test 1")
+ .frame("Test 2", "Sub 2")
+ .interval(Duration.ofMillis(100))
+ .finalStay(Duration.ofSeconds(3))
+ .finalTimes(TimeableTitle.PROLONGED)
+ .build();
+
+ assertEquals(2L, anim.tickInterval());
+ assertEquals(Duration.ofSeconds(3), anim.finalStay());
+ assertEquals(TimeableTitle.PROLONGED, anim.finalTimes());
+ assertEquals(2, anim.frames().size());
+ assertEquals("Test 1", anim.frames().get(0).title());
+ assertNull(anim.frames().get(0).subtitle());
+ assertEquals("Test 2", anim.frames().get(1).title());
+ assertEquals("Sub 2", anim.frames().get(1).subtitle());
+ }
+
+ @Test
+ @DisplayName("Expects collection animation dispatch to filter nulls and handle offline players")
+ void testCollectionDispatchNullSafety() {
+ UUID uuid = UUID.randomUUID();
+ List actionbarPackets = new ArrayList<>();
+ List titlePackets = new ArrayList<>();
+ Player player = createMockPlayer(uuid, titlePackets);
+ TextAnimation.setCustomActionbarHandler((_, text) -> actionbarPackets.add(text));
+
+ List players = new ArrayList<>();
+ players.add(null);
+ players.add(player);
+
+ TextAnimation anim = TextAnimation.builder().frame("Test").finalStay(Duration.ZERO).build();
+ AtomicBoolean completed = new AtomicBoolean(false);
+
+ assertDoesNotThrow(() -> anim.playActionbar(createMockPlugin(), players, () -> completed.set(true)));
+ assertTrue(completed.get());
+ }
+}
diff --git a/bedrock/src/test/java/fr/moussax/bedrock/ui/title/TitleTest.java b/bedrock/src/test/java/fr/moussax/bedrock/ui/title/TitleTest.java
new file mode 100644
index 00000000..a5827037
--- /dev/null
+++ b/bedrock/src/test/java/fr/moussax/bedrock/ui/title/TitleTest.java
@@ -0,0 +1,561 @@
+package fr.moussax.bedrock.ui.title;
+
+import fr.moussax.bedrock.ui.animation.TextAnimation;
+import org.bukkit.Location;
+import org.bukkit.entity.Player;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Proxy;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class TitleTest {
+
+ private record TitleCall(String title, String subtitle, int fadeIn, int stay, int fadeOut) {
+ }
+
+ private record SimpleTitleCall(String title, String subtitle) {
+ }
+
+ private Player createMockPlayer(
+ AtomicReference titleRef,
+ AtomicReference simpleTitleRef,
+ AtomicBoolean resetRef
+ ) {
+ return (Player) Proxy.newProxyInstance(
+ Player.class.getClassLoader(),
+ new Class>[]{Player.class},
+ (proxy, method, args) -> {
+ String name = method.getName();
+ if ("sendTitle".equals(name) && args != null) {
+ if (args.length == 5) {
+ titleRef.set(new TitleCall(
+ (String) args[0],
+ (String) args[1],
+ (int) args[2],
+ (int) args[3],
+ (int) args[4]
+ ));
+ return null;
+ }
+ if (args.length == 2) {
+ simpleTitleRef.set(new SimpleTitleCall(
+ (String) args[0],
+ (String) args[1]
+ ));
+ return null;
+ }
+ }
+ if ("resetTitle".equals(name)) {
+ resetRef.set(true);
+ return null;
+ }
+ if ("isOnline".equals(name)) {
+ return true;
+ }
+ if ("getLocation".equals(name)) {
+ return new Location(null, 0, 0, 0);
+ }
+ if ("playSound".equals(name)) {
+ return null;
+ }
+ return null;
+ }
+ );
+ }
+
+ @Test
+ @DisplayName("Expects TimeableTitle presets and clamp behavior to be consistent")
+ void testTimeableTitleBasics() {
+ assertEquals(10, TimeableTitle.DEFAULT.fadeIn());
+ assertEquals(60, TimeableTitle.DEFAULT.stay());
+ assertEquals(20, TimeableTitle.DEFAULT.fadeOut());
+ assertEquals(90, TimeableTitle.DEFAULT.totalTicks());
+
+ assertEquals(0, TimeableTitle.ALERT.fadeIn());
+ assertEquals(30, TimeableTitle.ALERT.stay());
+ assertEquals(10, TimeableTitle.ALERT.fadeOut());
+
+ assertEquals(0, TimeableTitle.INSTANT.fadeIn());
+ assertEquals(5, TimeableTitle.SUBTITLE_ONLY.fadeIn());
+ assertEquals(0, TimeableTitle.PERMANENT.fadeIn());
+ assertEquals(100, TimeableTitle.PERMANENT.stay());
+ assertEquals(0, TimeableTitle.ANIMATION_FRAME.fadeIn());
+ assertEquals(0, TimeableTitle.ANIMATION_FRAME.fadeOut());
+
+ // Clamping negative values
+ TimeableTitle clamped = TimeableTitle.of(-5, -10, -2);
+ assertEquals(0, clamped.fadeIn());
+ assertEquals(0, clamped.stay());
+ assertEquals(0, clamped.fadeOut());
+ assertEquals(0, clamped.totalTicks());
+ }
+
+ @Test
+ @DisplayName("Expects TimeableTitle duration conversion to convert milliseconds to ticks accurately")
+ void testTimeableTitleDurationConversion() {
+ TimeableTitle times = TimeableTitle.of(
+ Duration.ofMillis(500), // 10 ticks
+ Duration.ofSeconds(3), // 60 ticks
+ Duration.ofSeconds(1) // 20 ticks
+ );
+
+ assertEquals(10, times.fadeIn());
+ assertEquals(60, times.stay());
+ assertEquals(20, times.fadeOut());
+
+ TimeableTitle stayOnly = TimeableTitle.stay(Duration.ofSeconds(2)); // 40 ticks
+ assertEquals(10, stayOnly.fadeIn());
+ assertEquals(40, stayOnly.stay());
+ assertEquals(20, stayOnly.fadeOut());
+ }
+
+ @Test
+ @DisplayName("Expects TitleBuilder to substitute single space when title is empty to guarantee subtitle rendering")
+ void testSubtitleOnlySpaceSubstitution() {
+ AtomicReference titleRef = new AtomicReference<>();
+ AtomicReference simpleTitleRef = new AtomicReference<>();
+ AtomicBoolean resetRef = new AtomicBoolean(false);
+ Player player = createMockPlayer(titleRef, simpleTitleRef, resetRef);
+
+ Title.sendSubtitle(player, "§e+100 Coins", TimeableTitle.SUBTITLE_ONLY);
+
+ TitleCall call = titleRef.get();
+ assertNotNull(call);
+ assertEquals(" ", call.title(), "Empty title with non-empty subtitle must be a single space");
+ assertEquals("§e+100 Coins", call.subtitle());
+ assertEquals(5, call.fadeIn());
+ assertEquals(35, call.stay());
+ assertEquals(10, call.fadeOut());
+ }
+
+ @Test
+ @DisplayName("Expects TitleBuilder to dispatch full title and subtitle with custom timings")
+ void testFullTitleDispatch() {
+ AtomicReference titleRef = new AtomicReference<>();
+ AtomicReference simpleTitleRef = new AtomicReference<>();
+ AtomicBoolean resetRef = new AtomicBoolean(false);
+ Player player = createMockPlayer(titleRef, simpleTitleRef, resetRef);
+
+ Title.builder()
+ .title("§a§lVICTORY!")
+ .subtitle("§7Dungeon Floor 7")
+ .times(TimeableTitle.PROLONGED)
+ .send(player);
+
+ TitleCall call = titleRef.get();
+ assertNotNull(call);
+ assertEquals("§a§lVICTORY!", call.title());
+ assertEquals("§7Dungeon Floor 7", call.subtitle());
+ assertEquals(20, call.fadeIn());
+ assertEquals(90, call.stay());
+ assertEquals(30, call.fadeOut());
+ }
+
+ @Test
+ @DisplayName("Expects Title.clear to call player.resetTitle()")
+ void testTitleClear() {
+ AtomicReference titleRef = new AtomicReference<>();
+ AtomicReference simpleTitleRef = new AtomicReference<>();
+ AtomicBoolean resetRef = new AtomicBoolean(false);
+ Player player = createMockPlayer(titleRef, simpleTitleRef, resetRef);
+
+ Title.clear(player);
+ assertTrue(resetRef.get());
+ }
+
+ @Test
+ @DisplayName("Expects TitleComposer to render persistent title and diff-suppress duplicate sends")
+ void testTitleComposerPersistent() {
+ AtomicReference titleRef = new AtomicReference<>();
+ AtomicReference simpleTitleRef = new AtomicReference<>();
+ AtomicBoolean resetRef = new AtomicBoolean(false);
+ Player player = createMockPlayer(titleRef, simpleTitleRef, resetRef);
+
+ TitleComposer composer = new TitleComposer();
+ composer.setPersistentTitle(PersistentTitle.of("§6§lLOBBY", "§7Waiting for players..."));
+
+ // First render initializes timing and text
+ composer.render(player);
+ assertNotNull(titleRef.get());
+ assertEquals("§6§lLOBBY", titleRef.get().title());
+ assertEquals("§7Waiting for players...", titleRef.get().subtitle());
+
+ // Subsequent render with unchanged text suppresses packet dispatch
+ titleRef.set(null);
+ simpleTitleRef.set(null);
+ composer.render(player);
+ assertNull(titleRef.get(), "Duplicate packet must be suppressed");
+ assertNull(simpleTitleRef.get(), "Duplicate packet must be suppressed");
+ }
+
+ @Test
+ @DisplayName("Expects TitleComposer to override persistent title with alert and restore it upon expiration")
+ void testTitleComposerAlertOverrideAndRestore() throws InterruptedException {
+ AtomicReference titleRef = new AtomicReference<>();
+ AtomicReference simpleTitleRef = new AtomicReference<>();
+ AtomicBoolean resetRef = new AtomicBoolean(false);
+ Player player = createMockPlayer(titleRef, simpleTitleRef, resetRef);
+
+ TitleComposer composer = new TitleComposer();
+ composer.setPersistentTitle(PersistentTitle.of("§6§lLOBBY", "§7Waiting for players..."));
+
+ // Render baseline persistent title
+ composer.render(player);
+ assertEquals("§6§lLOBBY", titleRef.get().title());
+
+ // Send short modal alert (50ms)
+ titleRef.set(null);
+ composer.sendModalAlert("§c§lALERT!", "§fIncoming raid!", 10, Duration.ofMillis(50));
+ composer.render(player);
+
+ // Alert should immediately display with alert timings
+ assertNotNull(titleRef.get());
+ assertEquals("§c§lALERT!", titleRef.get().title());
+ assertEquals("§fIncoming raid!", titleRef.get().subtitle());
+
+ // Wait for alert to expire
+ Thread.sleep(60L);
+
+ // Render pass after expiration: Persistent title must be automatically restored!
+ titleRef.set(null);
+ simpleTitleRef.set(null);
+ composer.render(player);
+
+ assertNotNull(titleRef.get(), "Persistent title must be restored when alert expires");
+ assertEquals("§6§lLOBBY", titleRef.get().title());
+ assertEquals("§7Waiting for players...", titleRef.get().subtitle());
+ }
+
+ @AfterEach
+ void tearDown() {
+ TitlePacketSender.setCustomHandler(null);
+ }
+
+ @Test
+ @DisplayName("Expects TitleComposer to update text in-place without re-sending timing packets when timings are unchanged")
+ void testInPlaceTextUpdateWithoutTimesReset() {
+ AtomicReference titleRef = new AtomicReference<>();
+ AtomicReference simpleTitleRef = new AtomicReference<>();
+ AtomicBoolean resetRef = new AtomicBoolean(false);
+ Player player = createMockPlayer(titleRef, simpleTitleRef, resetRef);
+
+ AtomicReference textOnlyTitle = new AtomicReference<>();
+ TitlePacketSender.setCustomHandler((p, t, s) -> textOnlyTitle.set(t));
+
+ TitleComposer composer = new TitleComposer();
+ final List counter = new ArrayList<>(List.of("Frame 1"));
+ composer.setPersistentTitle(PersistentTitle.of(() -> counter.getFirst(), () -> "Subtitle"));
+
+ // Frame 1: Initial full packet with timings
+ composer.render(player);
+ assertNotNull(titleRef.get());
+ assertEquals("Frame 1", titleRef.get().title());
+
+ // Frame 2: Only text changes, timings remain identical
+ titleRef.set(null);
+ counter.set(0, "Frame 2");
+ composer.render(player);
+
+ // Crucial test: titleRef (times packet) must be null, TitlePacketSender text-only must be called!
+ assertNull(titleRef.get(), "Times packet must not be resent when timings are unchanged");
+ assertEquals("Frame 2", textOnlyTitle.get(), "In-place text update packet must be sent");
+ }
+
+ @Test
+ @DisplayName("Expects multiple modal alerts to respect priority precedence and sequentially restore lower priority alerts")
+ void testMultipleModalAlertsPriorityAndSequentialRestoration() throws InterruptedException {
+ AtomicReference titleRef = new AtomicReference<>();
+ AtomicReference simpleTitleRef = new AtomicReference<>();
+ AtomicBoolean resetRef = new AtomicBoolean(false);
+ Player player = createMockPlayer(titleRef, simpleTitleRef, resetRef);
+
+ AtomicReference textOnlyTitle = new AtomicReference<>();
+ TitlePacketSender.setCustomHandler((p, t, s) -> textOnlyTitle.set(t));
+
+ TitleComposer composer = new TitleComposer();
+ composer.setPersistentTitle(PersistentTitle.of("§6§lBASE", "§7Base Subtitle"));
+
+ // Queue Alert A: Low priority (1), long duration (600ms)
+ composer.sendModalAlert("§aLow Alert", "Subtitle A", 1, Duration.ofMillis(600));
+
+ // Queue Alert B: Highest priority (10), short duration (60ms)
+ composer.sendModalAlert("§cHighest Alert", "Subtitle B", 10, Duration.ofMillis(60));
+
+ // Queue Alert C: Medium priority (5), medium duration (300ms)
+ composer.sendModalAlert("§eMedium Alert", "Subtitle C", 5, Duration.ofMillis(300));
+
+ // Initial render: Highest Alert (B) must win
+ composer.render(player);
+ assertEquals("§cHighest Alert", titleRef.get().title());
+
+ // Wait for B to expire (90ms)
+ Thread.sleep(90L);
+ titleRef.set(null);
+ textOnlyTitle.set(null);
+ composer.render(player);
+
+ // Medium Alert (C) must now be displayed (in-place text update)
+ assertEquals("§eMedium Alert", textOnlyTitle.get());
+
+ // Wait for C to expire (additional 240ms -> total ~330ms)
+ Thread.sleep(240L);
+ titleRef.set(null);
+ textOnlyTitle.set(null);
+ composer.render(player);
+
+ // Low Alert (A) must now be displayed (in-place text update)
+ assertEquals("§aLow Alert", textOnlyTitle.get());
+
+ // Wait for A to expire (additional 300ms -> total ~630ms)
+ Thread.sleep(300L);
+ titleRef.set(null);
+ textOnlyTitle.set(null);
+ composer.render(player);
+
+ // Finally, underlying persistent title must be restored (with PERMANENT timings)
+ assertNotNull(titleRef.get());
+ assertEquals("§6§lBASE", titleRef.get().title());
+ }
+
+ @Test
+ @DisplayName("Expects clearAlerts to dismiss active alerts and immediately restore persistent title")
+ void testClearAlertsPreservesPersistentTitle() {
+ AtomicReference titleRef = new AtomicReference<>();
+ AtomicReference simpleTitleRef = new AtomicReference<>();
+ AtomicBoolean resetRef = new AtomicBoolean(false);
+ Player player = createMockPlayer(titleRef, simpleTitleRef, resetRef);
+
+ TitleComposer composer = new TitleComposer();
+ composer.setPersistentTitle(PersistentTitle.of("§6§lLOBBY", "§7Waiting..."));
+
+ composer.render(player);
+ assertEquals("§6§lLOBBY", titleRef.get().title());
+
+ // Send alert
+ titleRef.set(null);
+ composer.sendModalAlert("§cALERT", "Warning", 5, Duration.ofSeconds(10));
+ composer.render(player);
+ assertEquals("§cALERT", titleRef.get().title());
+
+ // Clear only alerts
+ titleRef.set(null);
+ composer.clearAlerts();
+ composer.render(player);
+
+ // Persistent title should immediately reappear
+ assertNotNull(titleRef.get());
+ assertEquals("§6§lLOBBY", titleRef.get().title());
+ assertTrue(composer.hasPersistentTitle());
+ }
+
+ @Test
+ @DisplayName("Expects modal alert with null or empty title to substitute single space for subtitle visibility")
+ void testSubtitleOnlyModalAlertSubstitutesSpace() {
+ AtomicReference titleRef = new AtomicReference<>();
+ AtomicReference simpleTitleRef = new AtomicReference<>();
+ AtomicBoolean resetRef = new AtomicBoolean(false);
+ Player player = createMockPlayer(titleRef, simpleTitleRef, resetRef);
+
+ TitleComposer composer = new TitleComposer();
+ composer.sendModalAlert(null, "§e+50 EXP", 0, Duration.ofSeconds(2));
+ composer.render(player);
+
+ assertNotNull(titleRef.get());
+ assertEquals(" ", titleRef.get().title(), "Must be single space to ensure subtitle renders on client");
+ assertEquals("§e+50 EXP", titleRef.get().subtitle());
+ }
+
+ @Test
+ @DisplayName("Expects offline players to be completely ignored during render")
+ void testOfflinePlayerIgnored() {
+ AtomicReference titleRef = new AtomicReference<>();
+ AtomicReference simpleTitleRef = new AtomicReference<>();
+ AtomicBoolean resetRef = new AtomicBoolean(false);
+
+ Player offlinePlayer = (Player) Proxy.newProxyInstance(
+ Player.class.getClassLoader(),
+ new Class>[]{Player.class},
+ (proxy, method, args) -> {
+ if ("isOnline".equals(method.getName())) return false;
+ if ("sendTitle".equals(method.getName())) {
+ fail("sendTitle must never be called on offline player");
+ }
+ return null;
+ }
+ );
+
+ TitleComposer composer = new TitleComposer();
+ composer.setPersistentTitle(PersistentTitle.of("Title", "Sub"));
+ composer.render(offlinePlayer); // Should be safely ignored
+ }
+
+ @Test
+ @DisplayName("Expects Title.send with Duration to calculate stay ticks accurately")
+ void testSendWithDuration() {
+ AtomicReference titleRef = new AtomicReference<>();
+ AtomicReference simpleTitleRef = new AtomicReference<>();
+ AtomicBoolean resetRef = new AtomicBoolean(false);
+ Player player = createMockPlayer(titleRef, simpleTitleRef, resetRef);
+
+ Title.send(player, "§aTitle", "§7Subtitle", Duration.ofSeconds(4));
+
+ TitleCall call = titleRef.get();
+ assertNotNull(call);
+ assertEquals("§aTitle", call.title());
+ assertEquals("§7Subtitle", call.subtitle());
+ assertEquals(10, call.fadeIn());
+ assertEquals(80, call.stay()); // 4 seconds * 20 = 80 ticks
+ assertEquals(20, call.fadeOut());
+ }
+
+ @Test
+ @DisplayName("Expects Title.send and Title.alert to dispatch to collections of players")
+ void testCollectionDispatch() {
+ AtomicReference titleRef1 = new AtomicReference<>();
+ AtomicReference simpleRef1 = new AtomicReference<>();
+ AtomicBoolean reset1 = new AtomicBoolean(false);
+ Player player1 = createMockPlayer(titleRef1, simpleRef1, reset1);
+
+ AtomicReference titleRef2 = new AtomicReference<>();
+ AtomicReference simpleRef2 = new AtomicReference<>();
+ AtomicBoolean reset2 = new AtomicBoolean(false);
+ Player player2 = createMockPlayer(titleRef2, simpleRef2, reset2);
+
+ List players = List.of(player1, player2);
+
+ Title.send(players, "§6Global", "§fMessage", Duration.ofSeconds(2));
+ assertNotNull(titleRef1.get());
+ assertNotNull(titleRef2.get());
+ assertEquals("§6Global", titleRef1.get().title());
+ assertEquals("§6Global", titleRef2.get().title());
+ assertEquals(40, titleRef1.get().stay());
+ assertEquals(40, titleRef2.get().stay());
+ }
+
+ @Test
+ @DisplayName("Expects zero-second countdown to fire completion title and callback immediately")
+ void testZeroSecondCountdownFiresImmediately() {
+ AtomicReference titleRef = new AtomicReference<>();
+ AtomicReference simpleTitleRef = new AtomicReference<>();
+ AtomicBoolean resetRef = new AtomicBoolean(false);
+ Player player = createMockPlayer(titleRef, simpleTitleRef, resetRef);
+
+ AtomicBoolean completed = new AtomicBoolean(false);
+ Title.countdown(player, 0, "§a§lSTART!", "§7Go!", () -> completed.set(true));
+
+ assertTrue(completed.get());
+ TitleCall call = titleRef.get();
+ assertNotNull(call);
+ assertEquals("§a§lSTART!", call.title());
+ assertEquals("§7Go!", call.subtitle());
+ }
+
+ @Test
+ @DisplayName("Expects chained animations with three or more stages to preserve and execute all stages in sequence")
+ void testChainedAnimationPreservesAllStages() {
+ AtomicReference titleRef = new AtomicReference<>();
+ List renderedTitles = new ArrayList<>();
+ Player player = (Player) Proxy.newProxyInstance(
+ Player.class.getClassLoader(),
+ new Class>[]{Player.class},
+ (proxy, method, args) -> {
+ String name = method.getName();
+ if ("sendTitle".equals(name) && args != null && args.length > 0) {
+ renderedTitles.add((String) args[0]);
+ return null;
+ }
+ if ("isOnline".equals(name)) return true;
+ if ("getLocation".equals(name)) return new Location(null, 0, 0, 0);
+ return null;
+ }
+ );
+ TitlePacketSender.setCustomHandler((_, title, _) -> {
+ if (title != null) renderedTitles.add(title);
+ });
+
+ TextAnimation stage1 = TextAnimation.builder().frame("Stage 1").build();
+ TextAnimation stage2 = TextAnimation.builder().frame("Stage 2").build();
+ TextAnimation stage3 = TextAnimation.builder().frame("Stage 3").build();
+
+ org.bukkit.plugin.Plugin mockPlugin = (org.bukkit.plugin.Plugin) Proxy.newProxyInstance(
+ org.bukkit.plugin.Plugin.class.getClassLoader(),
+ new Class>[]{org.bukkit.plugin.Plugin.class},
+ (_, _, _) -> null
+ );
+
+ AtomicBoolean completed = new AtomicBoolean(false);
+ TextAnimation chained = stage1.then(stage2).then(stage3);
+ chained.playTitle(mockPlugin, player, () -> completed.set(true));
+
+ assertTrue(completed.get(), "Chained sequence must reach completion");
+ assertTrue(renderedTitles.contains("Stage 1"), "Must execute Stage 1");
+ assertTrue(renderedTitles.contains("Stage 2"), "Must execute Stage 2 and not discard it");
+ assertTrue(renderedTitles.contains("Stage 3"), "Must execute Stage 3");
+ }
+
+ @Test
+ @DisplayName("Expects rapid text changes not to suppress stay renewal when nearing expiration")
+ void testRapidTextChangesAllowNearExpirationRenewal() throws InterruptedException {
+ AtomicReference titleRef = new AtomicReference<>();
+ AtomicReference simpleTitleRef = new AtomicReference<>();
+ AtomicBoolean resetRef = new AtomicBoolean(false);
+ Player player = createMockPlayer(titleRef, simpleTitleRef, resetRef);
+
+ AtomicReference textOnlyRef = new AtomicReference<>();
+ TitlePacketSender.setCustomHandler((_, title, _) -> textOnlyRef.set(title));
+
+ AtomicReference dynamicText = new AtomicReference<>("Frame A");
+ TitleComposer composer = new TitleComposer();
+ // 4 ticks = 200ms stay. Expiration renewal threshold = max(1000ms, 150ms) = 1000ms.
+ // Let's use stay of 30 ticks = 1500ms stay. Expiration renewal threshold = max(1000ms, (1500*3)/4) = 1125ms.
+ composer.setPersistentTitle(PersistentTitle.of(dynamicText::get, () -> "", TimeableTitle.of(0, 30, 0)));
+
+ // T=0: Initial render (timesChanged = true -> sendFull)
+ composer.render(player);
+ assertNotNull(titleRef.get());
+ assertEquals("Frame A", titleRef.get().title());
+
+ // T+50ms: Text change -> sendTextOnly (does NOT reset lastTimingSentTimestamp)
+ dynamicText.set("Frame B");
+ titleRef.set(null);
+ composer.render(player);
+ assertEquals("Frame B", textOnlyRef.get());
+ assertNull(titleRef.get(), "sendFull should not be called for text change");
+
+ // Wait to cross nearExpiration (1150ms)
+ Thread.sleep(1150L);
+ dynamicText.set("Frame C");
+ composer.render(player);
+
+ // Even though text changed, nearExpiration is triggered -> sendFull with 0 fade-in
+ assertNotNull(titleRef.get(), "sendFull must be called when nearing expiration even if text changed");
+ assertEquals("Frame C", titleRef.get().title());
+ assertEquals(0, titleRef.get().fadeIn());
+ assertEquals(30, titleRef.get().stay());
+ }
+
+ @Test
+ @DisplayName("Expects Collection dispatch helpers to safely ignore null or offline elements")
+ void testCollectionNullAndOfflineFiltering() {
+ AtomicReference titleRef = new AtomicReference<>();
+ AtomicReference simpleTitleRef = new AtomicReference<>();
+ AtomicBoolean resetRef = new AtomicBoolean(false);
+ Player onlinePlayer = createMockPlayer(titleRef, simpleTitleRef, resetRef);
+
+ List mixed = new ArrayList<>();
+ mixed.add(null);
+ mixed.add(onlinePlayer);
+
+ assertDoesNotThrow(() -> Title.send(mixed, "Title", "Sub"));
+ assertNotNull(titleRef.get());
+ assertEquals("Title", titleRef.get().title());
+ }
+}
diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/player/PlayerListener.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/player/PlayerListener.java
index 03248acf..6c076ca9 100644
--- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/player/PlayerListener.java
+++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/player/PlayerListener.java
@@ -1,5 +1,7 @@
package fr.moussax.blightedSMP.engine.player;
+import fr.moussax.bedrock.ui.title.TimeableTitle;
+import fr.moussax.bedrock.ui.title.Title;
import fr.moussax.blightedSMP.engine.entities.BlightedEntity;
import fr.moussax.blightedSMP.engine.entities.listeners.BlightedEntitiesListener;
import fr.moussax.blightedSMP.engine.items.registry.ItemRegistry;
@@ -32,7 +34,7 @@ public void onJoin(PlayerJoinEvent event) {
player.addPotionEffect(new PotionEffect(PotionEffectType.DARKNESS, 140, 0, false, false, false));
player.addPotionEffect(new PotionEffect(PotionEffectType.SLOWNESS, 80, 1, false, false, false));
- player.sendTitle("§f§lThe Blight Awakens", "§7The whispers fade into reality...", 10, 80, 20);
+ Title.send(player, "§f§lThe Blight Awakens", "§7The whispers fade into reality...", TimeableTitle.of(10, 80, 20));
player.sendMessage("\n \n");
player.sendMessage(" §f" + player.getName() + "§7, the §5§lBlight §7has already begun to take root...\n ");
diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/registry/EventsRegistry.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/registry/EventsRegistry.java
index a2d1acfe..cda7661c 100644
--- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/registry/EventsRegistry.java
+++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/registry/EventsRegistry.java
@@ -20,6 +20,7 @@
import fr.moussax.bedrock.ui.menu.system.MenuListener;
import fr.moussax.bedrock.ui.menu.system.MenuSystem;
import fr.moussax.bedrock.ui.sign.SignInput;
+import fr.moussax.bedrock.ui.title.Title;
import org.bukkit.Bukkit;
import org.bukkit.plugin.PluginManager;
@@ -50,6 +51,7 @@ public void initializeListeners() {
menuSystem = new MenuSystem(instance);
SignInput.initialize(instance);
Actionbar.initialize(instance, 20L);
+ Title.initialize(instance, 10L);
playerHudManager = new PlayerHudManager(instance);
Bukkit.getScheduler().runTaskTimer(instance, () -> {
From 25ef8181754e0c187357b4cefd38fc33e848ad68 Mon Sep 17 00:00:00 2001
From: Moussa
Date: Wed, 30 Sep 2026 19:23:54 +0200
Subject: [PATCH 12/23] refactor(animation): deduplicate collection dispatch
and fix disconnect barrier deadlock
- Extract dispatchCollection helper to unify collection playback
across action bars and titles
- Invoke completion barrier callback on player disconnect to
prevent barrier deadlocks
- Avoid redundant temporary packet dispatch for standalone
single-frame titles
- Optimize typewriter regex matching and pre-format shimmer
color sweeps
- Add unit tests verifying collection dispatch and offline
player completion
---
.../bedrock/ui/animation/TextAnimation.java | 115 ++++++++----------
.../ui/animation/TextAnimationTest.java | 47 +++++++
2 files changed, 99 insertions(+), 63 deletions(-)
diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/animation/TextAnimation.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/animation/TextAnimation.java
index ffe56774..1c55d254 100644
--- a/bedrock/src/main/java/fr/moussax/bedrock/ui/animation/TextAnimation.java
+++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/animation/TextAnimation.java
@@ -24,6 +24,7 @@
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
+import java.util.regex.Pattern;
/**
* High-performance, flicker-free text animation engine for Minecraft user interfaces.
@@ -34,6 +35,8 @@
*/
public class TextAnimation {
+ private static final Pattern COLOR_PATTERN = Pattern.compile("§[0-9a-fk-orA-FK-OR]");
+
/**
* An individual frame within a text animation sequence.
*
@@ -176,9 +179,9 @@ public TextAnimation then(@NonNull TextAnimation next) {
/**
* Constructs a typewriter text-reveal animation where characters appear incrementally.
*
- * @param fullText the complete text to reveal
- * @param ticksPerChar tick delay between revealing each character
- * @param clickSound sound played when each letter appears, or null for silent
+ * @param fullText the complete text to reveal
+ * @param ticksPerChar tick delay between revealing each character
+ * @param clickSound sound played when each letter appears, or null for silent
* @return configured typewriter animation
*/
public static TextAnimation typewriter(
@@ -232,7 +235,7 @@ public static TextAnimation typewriter(
}
private static void buildTypewriterFrames(Builder builder, String text, @Nullable String subtitle, @Nullable Sound sound) {
- String stripped = text.replaceAll("§[0-9a-fk-orA-FK-OR]", "");
+ String stripped = COLOR_PATTERN.matcher(text).replaceAll("");
int visibleLength = stripped.length();
if (visibleLength == 0) {
@@ -382,19 +385,22 @@ public static TextAnimation shimmer(
) {
Builder builder = builder().interval(1L).finalTimes(finalTimes);
String prefix = bold ? "§l" : "";
+ String highlight = highlightColor + prefix;
+ String mid = midColor + prefix;
+ String base = baseColor + prefix;
int length = text.length();
for (int cycle = 0; cycle < cycles; cycle++) {
- for (int highlight = -2; highlight < length + 2; highlight++) {
- StringBuilder frameBuilder = new StringBuilder();
+ for (int highlightIndex = -2; highlightIndex < length + 2; highlightIndex++) {
+ StringBuilder frameBuilder = new StringBuilder(length * 4);
for (int index = 0; index < length; index++) {
char character = text.charAt(index);
- if (index == highlight) {
- frameBuilder.append(highlightColor).append(prefix).append(character);
- } else if (Math.abs(index - highlight) == 1) {
- frameBuilder.append(midColor).append(prefix).append(character);
+ if (index == highlightIndex) {
+ frameBuilder.append(highlight).append(character);
+ } else if (Math.abs(index - highlightIndex) == 1) {
+ frameBuilder.append(mid).append(character);
} else {
- frameBuilder.append(baseColor).append(prefix).append(character);
+ frameBuilder.append(base).append(character);
}
}
builder.frame(frameBuilder.toString(), subtitle);
@@ -407,12 +413,12 @@ public static TextAnimation shimmer(
/**
* Constructs a flashing pulse animation alternating between a primary and highlight color.
*
- * @param text message text
- * @param primaryColor standard color code (e.g. "§c")
- * @param flashColor bright pulse color code (e.g. "§f")
- * @param ticksPerPulse tick delay per pulse phase
- * @param pulses number of complete pulses
- * @param sound sound played on each flash, or null for silent
+ * @param text message text
+ * @param primaryColor standard color code (e.g. "§c")
+ * @param flashColor bright pulse color code (e.g. "§f")
+ * @param ticksPerPulse tick delay per pulse phase
+ * @param pulses number of complete pulses
+ * @param sound sound played on each flash, or null for silent
* @return configured pulse animation
*/
public static TextAnimation pulse(
@@ -431,10 +437,6 @@ public static TextAnimation pulse(
return builder.build();
}
- // =========================================================================
- // Actionbar Playback
- // =========================================================================
-
/**
* Plays this text animation on the target player's action bar.
*
@@ -510,7 +512,7 @@ public BukkitTask playActionbar(@NonNull Plugin plugin, @NonNull Player player,
}
if (onComplete != null) onComplete.accept(player);
long stayTicks = Math.max(0L, finalStay.toMillis() / 50L);
- if (Bukkit.getServer() != null && stayTicks > 0) {
+ if (stayTicks > 0) {
return Bukkit.getScheduler().runTaskLater(plugin, () -> finishActionbar(player, extraOnComplete), stayTicks);
} else {
finishActionbar(player, extraOnComplete);
@@ -526,6 +528,9 @@ public void run() {
if (!player.isOnline()) {
cleanupActionbar(player);
cancel();
+ if (extraOnComplete != null) {
+ extraOnComplete.run();
+ }
return;
}
@@ -552,7 +557,7 @@ public void run() {
nextAnimation.playActionbar(plugin, player, extraOnComplete);
} else {
long stayTicks = Math.max(0L, finalStay.toMillis() / 50L);
- if (Bukkit.getServer() != null && stayTicks > 0) {
+ if (stayTicks > 0) {
Bukkit.getScheduler().runTaskLater(plugin, () -> finishActionbar(player, extraOnComplete), stayTicks);
} else {
finishActionbar(player, extraOnComplete);
@@ -596,26 +601,7 @@ public void playActionbar(
) {
Objects.requireNonNull(plugin, "plugin cannot be null");
Objects.requireNonNull(players, "players cannot be null");
-
- List extends Player> targetPlayers = players.stream()
- .filter(p -> p != null && p.isOnline())
- .toList();
-
- if (targetPlayers.isEmpty()) {
- if (extraOnComplete != null) extraOnComplete.run();
- return;
- }
-
- AtomicInteger remaining = new AtomicInteger(targetPlayers.size());
- Runnable barrier = (extraOnComplete != null) ? () -> {
- if (remaining.decrementAndGet() == 0) {
- extraOnComplete.run();
- }
- } : null;
-
- for (Player player : targetPlayers) {
- playActionbar(plugin, player, barrier);
- }
+ dispatchCollection(players, extraOnComplete, (player, barrier) -> playActionbar(plugin, player, barrier));
}
/**
@@ -664,10 +650,6 @@ private static void cleanupActionbar(@NonNull Player player) {
}
}
- // =========================================================================
- // Title Playback
- // =========================================================================
-
/**
* Plays this text animation on the target player as an in-place title sequence.
*
@@ -730,17 +712,15 @@ public BukkitTask playTitle(@NonNull Plugin plugin, @NonNull Player player, @Nul
titleService.setAnimating(player.getUniqueId(), true);
}
- // Initialize display with sufficient stay to cover the entire sequence with zero fade-in
- int totalTicks = (int) (frames.size() * tickInterval);
Frame firstFrame = frames.getFirst();
- TitlePacketSender.sendFull(player, firstFrame.title(), firstFrame.subtitle(), 0, totalTicks + 60, 0);
-
if (firstFrame.sound() != null) {
player.playSound(player.getLocation(), firstFrame.sound(), 1.0f, firstFrame.soundPitch());
}
if (frames.size() == 1) {
if (nextAnimation != null) {
+ int totalTicks = (int) (frames.size() * tickInterval);
+ TitlePacketSender.sendFull(player, firstFrame.title(), firstFrame.subtitle(), 0, totalTicks + 60, 0);
if (onComplete != null) onComplete.accept(player);
return nextAnimation.playTitle(plugin, player, extraOnComplete);
}
@@ -749,7 +729,7 @@ public BukkitTask playTitle(@NonNull Plugin plugin, @NonNull Player player, @Nul
if (onComplete != null) onComplete.accept(player);
long finishDelay = finalTimes.stay() + finalTimes.fadeOut();
- if (Bukkit.getServer() != null && finishDelay > 0) {
+ if (finishDelay > 0) {
return Bukkit.getScheduler().runTaskLater(plugin, () -> finishTitle(player, extraOnComplete), finishDelay);
} else {
finishTitle(player, extraOnComplete);
@@ -757,6 +737,10 @@ public BukkitTask playTitle(@NonNull Plugin plugin, @NonNull Player player, @Nul
}
}
+ // Initialize display with sufficient stay to cover the entire sequence with zero fade-in
+ int totalTicks = (int) (frames.size() * tickInterval);
+ TitlePacketSender.sendFull(player, firstFrame.title(), firstFrame.subtitle(), 0, totalTicks + 60, 0);
+
return new BukkitRunnable() {
private int frameIndex = 1;
@@ -765,6 +749,9 @@ public void run() {
if (!player.isOnline()) {
cleanupTitle(player);
cancel();
+ if (extraOnComplete != null) {
+ extraOnComplete.run();
+ }
return;
}
@@ -802,7 +789,7 @@ public void run() {
}
long finishDelay = finalTimes.stay() + finalTimes.fadeOut();
- if (Bukkit.getServer() != null && finishDelay > 0) {
+ if (finishDelay > 0) {
Bukkit.getScheduler().runTaskLater(plugin, () -> finishTitle(player, extraOnComplete), finishDelay);
} else {
finishTitle(player, extraOnComplete);
@@ -846,7 +833,14 @@ public void playTitle(
) {
Objects.requireNonNull(plugin, "plugin cannot be null");
Objects.requireNonNull(players, "players cannot be null");
+ dispatchCollection(players, extraOnComplete, (player, barrier) -> playTitle(plugin, player, barrier));
+ }
+ private static void dispatchCollection(
+ @NonNull Collection extends Player> players,
+ @Nullable Runnable extraOnComplete,
+ @NonNull BiConsumer dispatcher
+ ) {
List extends Player> targetPlayers = players.stream()
.filter(p -> p != null && p.isOnline())
.toList();
@@ -864,7 +858,7 @@ public void playTitle(
} : null;
for (Player player : targetPlayers) {
- playTitle(plugin, player, barrier);
+ dispatcher.accept(player, barrier);
}
}
@@ -886,10 +880,6 @@ private static void cleanupTitle(@NonNull Player player) {
}
}
- // =========================================================================
- // Generic Frame Consumer Playback
- // =========================================================================
-
/**
* Plays this text animation dispatching each rendered frame to a custom consumer.
*
@@ -972,6 +962,9 @@ public BukkitTask play(
public void run() {
if (!player.isOnline()) {
cancel();
+ if (extraOnComplete != null) {
+ extraOnComplete.run();
+ }
return;
}
@@ -1002,10 +995,6 @@ public void run() {
}.runTaskTimer(plugin, tickInterval, tickInterval);
}
- // =========================================================================
- // Utilities & Resolution
- // =========================================================================
-
private static Plugin resolvePlugin() {
ActionbarService actionbarService = ActionbarService.getInstance();
if (actionbarService != null && actionbarService.getPlugin() != null) {
@@ -1055,7 +1044,7 @@ public static class Builder {
protected Consumer onComplete;
public Builder frame(@NonNull String text) {
- return frame(text, (String) null);
+ return frame(text, null);
}
public Builder frame(@NonNull String text, @Nullable Sound sound, float pitch) {
diff --git a/bedrock/src/test/java/fr/moussax/bedrock/ui/animation/TextAnimationTest.java b/bedrock/src/test/java/fr/moussax/bedrock/ui/animation/TextAnimationTest.java
index 85ece4b2..4d14b4a1 100644
--- a/bedrock/src/test/java/fr/moussax/bedrock/ui/animation/TextAnimationTest.java
+++ b/bedrock/src/test/java/fr/moussax/bedrock/ui/animation/TextAnimationTest.java
@@ -239,4 +239,51 @@ void testCollectionDispatchNullSafety() {
assertDoesNotThrow(() -> anim.playActionbar(createMockPlugin(), players, () -> completed.set(true)));
assertTrue(completed.get());
}
+
+ @Test
+ @DisplayName("Expects collection title dispatch to filter nulls and complete barrier")
+ void testCollectionTitleDispatch() {
+ UUID uuid = UUID.randomUUID();
+ List titlePackets = new ArrayList<>();
+ Player player = createMockPlayer(uuid, titlePackets);
+
+ List players = new ArrayList<>();
+ players.add(null);
+ players.add(player);
+
+ TextAnimation anim = TextAnimation.builder()
+ .frame("Title Test")
+ .finalTimes(TimeableTitle.of(0, 0, 0))
+ .build();
+ AtomicBoolean completed = new AtomicBoolean(false);
+
+ assertDoesNotThrow(() -> anim.playTitle(createMockPlugin(), players, () -> completed.set(true)));
+ assertTrue(completed.get());
+ assertTrue(titlePackets.contains("Title Test"));
+ }
+
+ @Test
+ @DisplayName("Expects offline player to immediately invoke completion callback without deadlocking barrier")
+ void testOfflinePlayerCompletesBarrier() {
+ Player offlinePlayer = createOfflineMockPlayer(UUID.randomUUID());
+ AtomicBoolean completed = new AtomicBoolean(false);
+
+ TextAnimation anim = TextAnimation.builder().frame("Test").build();
+ anim.play(createMockPlugin(), offlinePlayer, (_, _) -> {}, () -> completed.set(true));
+
+ assertTrue(completed.get(), "Offline player must trigger completion callback");
+ }
+
+ private Player createOfflineMockPlayer(UUID uuid) {
+ return (Player) Proxy.newProxyInstance(
+ Player.class.getClassLoader(),
+ new Class>[]{Player.class},
+ (_, method, _) -> {
+ String name = method.getName();
+ if ("getUniqueId".equals(name)) return uuid;
+ if ("isOnline".equals(name)) return false;
+ return null;
+ }
+ );
+ }
}
From 8a1d9e2ba1c05e4c8cd8f34d7b3d3aca2ec9c51f Mon Sep 17 00:00:00 2001
From: Moussa
Date: Wed, 30 Sep 2026 20:55:49 +0200
Subject: [PATCH 13/23] feat(utils): enhance ItemBuilder
---
.../fr/moussax/bedrock/utils/ItemBuilder.java | 427 ++++++++++++++----
1 file changed, 330 insertions(+), 97 deletions(-)
diff --git a/bedrock/src/main/java/fr/moussax/bedrock/utils/ItemBuilder.java b/bedrock/src/main/java/fr/moussax/bedrock/utils/ItemBuilder.java
index 6444c9c7..c652ca7a 100644
--- a/bedrock/src/main/java/fr/moussax/bedrock/utils/ItemBuilder.java
+++ b/bedrock/src/main/java/fr/moussax/bedrock/utils/ItemBuilder.java
@@ -127,21 +127,29 @@ public ItemBuilder(@NonNull ItemStack itemStack) {
}
private static int validateAmount(@NonNull Material material, int amount) {
- if (amount < 1 || amount > material.getMaxStackSize()) {
- throw new IllegalArgumentException("Amount must be between 1 and " + material.getMaxStackSize());
+ if (amount < 1 || amount > 99) {
+ throw new IllegalArgumentException("Amount must be between 1 and 99, but was " + amount);
}
return amount;
}
- private static void applyBase64Texture(SkullMeta meta, String base64Texture) {
- String json = new String(Base64.getDecoder().decode(base64Texture), StandardCharsets.UTF_8);
- JsonObject object = JsonParser.parseString(json)
- .getAsJsonObject().getAsJsonObject("textures")
- .getAsJsonObject("SKIN");
+ private static void applyCustomTexture(SkullMeta meta, String textureInput) {
+ String url;
+ if (textureInput.startsWith("http://") || textureInput.startsWith("https://")) {
+ url = textureInput;
+ } else if (textureInput.matches("^[a-fA-F0-9]{64}$")) {
+ url = "http://textures.minecraft.net/texture/" + textureInput;
+ } else {
+ String json = new String(Base64.getDecoder().decode(textureInput), StandardCharsets.UTF_8);
+ JsonObject object = JsonParser.parseString(json)
+ .getAsJsonObject().getAsJsonObject("textures")
+ .getAsJsonObject("SKIN");
+ url = object.get("url").getAsString();
+ }
- String url = object.get("url").getAsString();
- UUID id = UUID.randomUUID();
- PlayerProfile profile = Bukkit.createPlayerProfile(id, id.toString().substring(0, 16));
+ UUID id = UUID.nameUUIDFromBytes(url.getBytes(StandardCharsets.UTF_8));
+ String profileName = id.toString().replace("-", "").substring(0, 16);
+ PlayerProfile profile = Bukkit.createPlayerProfile(id, profileName);
PlayerTextures textures = profile.getTextures();
try {
@@ -185,39 +193,71 @@ public ItemBuilder setDisplayName(@NonNull String displayName) {
}
/**
- * Adds a line to the item lore.
+ * Adds multiple lore lines to the item.
*
- * @param line the lore line
+ * @param lines the lore lines to add
* @return this builder
*/
- public ItemBuilder addLore(String line) {
+ public ItemBuilder addLore(@Nullable List lines) {
+ if (lines == null || lines.isEmpty()) return this;
List lore = itemMeta.getLore() != null ? new ArrayList<>(itemMeta.getLore()) : new ArrayList<>();
- lore.add(ColorUtils.colorize(line));
+ lore.addAll(ColorUtils.colorize(lines));
itemMeta.setLore(lore);
return this;
}
/**
- * Adds multiple lore lines to the item.
+ * Adds one or more lore lines to the item.
*
* @param lines the lore lines to add
* @return this builder
*/
- public ItemBuilder addLore(List lines) {
- List lore = itemMeta.getLore() != null ? new ArrayList<>(itemMeta.getLore()) : new ArrayList<>();
- lore.addAll(ColorUtils.colorize(lines));
- itemMeta.setLore(lore);
+ public ItemBuilder addLore(String... lines) {
+ return lines != null ? addLore(Arrays.asList(lines)) : this;
+ }
+
+ /**
+ * Sets the item lore, replacing any existing lore.
+ *
+ * @param lines the lore lines
+ * @return this builder
+ */
+ public ItemBuilder setLore(@Nullable List lines) {
+ if (lines == null || lines.isEmpty()) {
+ itemMeta.setLore(null);
+ return this;
+ }
+ itemMeta.setLore(ColorUtils.colorize(lines));
return this;
}
/**
- * Adds multiple lore lines to the item.
+ * Sets the item lore, replacing any existing lore.
*
- * @param lines the lore lines to add
+ * @param lines the lore lines
* @return this builder
*/
- public ItemBuilder addLore(String... lines) {
- return addLore(Arrays.asList(lines));
+ public ItemBuilder setLore(String... lines) {
+ return setLore(lines != null ? Arrays.asList(lines) : Collections.emptyList());
+ }
+
+ /**
+ * Adds an empty line to the item lore.
+ *
+ * @return this builder
+ */
+ public ItemBuilder addEmptyLore() {
+ return addLore("");
+ }
+
+ /**
+ * Clears all lore from the item.
+ *
+ * @return this builder
+ */
+ public ItemBuilder clearLore() {
+ itemMeta.setLore(null);
+ return this;
}
/**
@@ -246,7 +286,7 @@ public ItemBuilder setLore(int index, String line) {
* @throws IllegalArgumentException if the amount exceeds the maximum stack size
*/
public ItemBuilder setAmount(int amount) {
- int effectiveMax = itemMeta.hasMaxStackSize() ? itemMeta.getMaxStackSize() : item.getType().getMaxStackSize();
+ int effectiveMax = itemMeta.hasMaxStackSize() ? itemMeta.getMaxStackSize() : 99;
if (amount < 1 || amount > effectiveMax) {
throw new IllegalArgumentException("Amount must be between 1 and " + effectiveMax);
}
@@ -403,28 +443,18 @@ public ItemBuilder setRarity(ItemRarity rarity) {
}
/**
- * Sets the item's custom model identifier.
+ * Sets the item's custom model identifier, or {@code null} to clear it.
*
- * @param itemModel the model namespace key
+ * @param itemModel the model namespace key, or {@code null}
* @return this builder
*/
- public ItemBuilder setItemModel(@NonNull NamespacedKey itemModel) {
+ public ItemBuilder setItemModel(@Nullable NamespacedKey itemModel) {
itemMeta.setItemModel(itemModel);
return this;
}
/**
- * Clears the item's custom model identifier.
- *
- * @return this builder
- */
- public ItemBuilder clearItemModel() {
- itemMeta.setItemModel(null);
- return this;
- }
-
- /**
- * Sets the item's custom model data.
+ * Sets the item's custom model data, or {@code null} to remove it.
*
* @param data the custom model data value, or {@code null} to remove it
* @return this builder
@@ -435,16 +465,6 @@ public ItemBuilder setCustomModelData(@Nullable Integer data) {
return this;
}
- return setCustomModelData(data.intValue());
- }
-
- /**
- * Sets the item's custom model data.
- *
- * @param data the custom model data value
- * @return this builder
- */
- public ItemBuilder setCustomModelData(int data) {
CustomModelDataComponent component = itemMeta.getCustomModelDataComponent();
component.setFloats(List.of((float) data));
itemMeta.setCustomModelDataComponent(component);
@@ -452,26 +472,16 @@ public ItemBuilder setCustomModelData(int data) {
}
/**
- * Sets the tooltip style of the item.
+ * Sets the tooltip style of the item, or {@code null} to clear it.
*
- * @param tooltipStyle the tooltip style namespace key
+ * @param tooltipStyle the tooltip style namespace key, or {@code null}
* @return this builder
*/
- public ItemBuilder setTooltipStyle(@NonNull NamespacedKey tooltipStyle) {
+ public ItemBuilder setTooltipStyle(@Nullable NamespacedKey tooltipStyle) {
itemMeta.setTooltipStyle(tooltipStyle);
return this;
}
- /**
- * Clears the item's tooltip style.
- *
- * @return this builder
- */
- public ItemBuilder clearTooltipStyle() {
- itemMeta.setTooltipStyle(null);
- return this;
- }
-
/**
* Adds or replaces persistent data on the item.
*
@@ -543,7 +553,12 @@ public ItemBuilder setEnchantmentGlint(boolean glint) {
return this;
}
- public ItemBuilder addEnchantmentGlint() {
+ /**
+ * Displays an enchantment glint on the item without enchantments.
+ *
+ * @return this builder
+ */
+ public ItemBuilder glow() {
return setEnchantmentGlint(true);
}
@@ -558,9 +573,32 @@ public ItemBuilder addItemFlag(ItemFlag... flags) {
return this;
}
+ /**
+ * Removes item flags from the item.
+ *
+ * @param flags the item flags to remove
+ * @return this builder
+ */
+ public ItemBuilder removeItemFlag(ItemFlag... flags) {
+ itemMeta.removeItemFlags(flags);
+ return this;
+ }
+
+ /**
+ * Adds all item flags to hide default vanilla item tooltips.
+ *
+ * @return this builder
+ */
+ public ItemBuilder hideAllFlags() {
+ return addItemFlag(ItemFlag.values());
+ }
+
/**
* Adds or replaces an attribute modifier on the item.
*
+ * If a modifier with the same {@link NamespacedKey} already exists for this attribute,
+ * it will be replaced.
+ *
* @param attribute the attribute affected by the modifier
* @param modifier the attribute modifier
* @return this builder
@@ -569,7 +607,9 @@ public ItemBuilder addAttributeModifier(Attribute attribute, AttributeModifier m
if (itemMeta.getAttributeModifiers() != null) {
Collection existing = itemMeta.getAttributeModifiers().get(attribute);
for (AttributeModifier old : new ArrayList<>(existing)) {
- itemMeta.removeAttributeModifier(attribute, old);
+ if (old.getKey().equals(modifier.getKey())) {
+ itemMeta.removeAttributeModifier(attribute, old);
+ }
}
}
itemMeta.addAttributeModifier(attribute, modifier);
@@ -577,7 +617,7 @@ public ItemBuilder addAttributeModifier(Attribute attribute, AttributeModifier m
}
/**
- * Adds an attribute modifier using the provided values.
+ * Adds an attribute modifier using the provided values and a deterministic namespaced key.
*
* @param attribute the attribute affected by the modifier
* @param amount the modifier amount
@@ -591,18 +631,53 @@ public ItemBuilder addAttributeModifier(
AttributeModifier.Operation operation,
EquipmentSlotGroup slotGroup
) {
- NamespacedKey key = new NamespacedKey(PluginContext.get(), UUID.randomUUID().toString());
+ String keyName = attribute.getKeyOrThrow().getKey() + "_" + slotGroup.toString().toLowerCase(Locale.ROOT);
+ NamespacedKey key = new NamespacedKey(PluginContext.get(), keyName);
AttributeModifier modifier = new AttributeModifier(key, amount, operation, slotGroup);
return addAttributeModifier(attribute, modifier);
}
+ /**
+ * Sets the item's base attack damage attribute modifier in the main hand.
+ *
+ * Note: In Minecraft's Data Component model, entity attack damage is managed via
+ * this attribute modifier rather than {@link #editWeapon(Consumer)}.
+ *
+ * @param damage the attack damage value
+ * @return this builder
+ */
+ public ItemBuilder setAttackDamage(double damage) {
+ return addAttributeModifier(
+ Attribute.ATTACK_DAMAGE,
+ damage,
+ AttributeModifier.Operation.ADD_NUMBER,
+ EquipmentSlotGroup.MAINHAND
+ );
+ }
+
+ /**
+ * Sets the item's attack speed attribute modifier in the main hand.
+ *
+ * @param speed the attack speed modifier value
+ * @return this builder
+ */
+ public ItemBuilder setAttackSpeed(double speed) {
+ return addAttributeModifier(
+ Attribute.ATTACK_SPEED,
+ speed,
+ AttributeModifier.Operation.ADD_NUMBER,
+ EquipmentSlotGroup.MAINHAND
+ );
+ }
+
/**
* Modifies the item's equippable component.
*
* @param consumer the component modifier
* @return this builder
*/
- public ItemBuilder editEquippable(Consumer consumer) {
+ public ItemBuilder editEquippable(@NonNull Consumer consumer) {
+ Objects.requireNonNull(consumer, "consumer cannot be null");
EquippableComponent equippable = itemMeta.getEquippable();
consumer.accept(equippable);
itemMeta.setEquippable(equippable);
@@ -629,7 +704,8 @@ public ItemBuilder preventEquipping() {
* @param consumer the component modifier
* @return this builder
*/
- public ItemBuilder editFood(Consumer consumer) {
+ public ItemBuilder editFood(@NonNull Consumer consumer) {
+ Objects.requireNonNull(consumer, "consumer cannot be null");
FoodComponent food = itemMeta.getFood();
consumer.accept(food);
itemMeta.setFood(food);
@@ -642,7 +718,8 @@ public ItemBuilder editFood(Consumer consumer) {
* @param consumer the component modifier
* @return this builder
*/
- public ItemBuilder editTool(Consumer consumer) {
+ public ItemBuilder editTool(@NonNull Consumer consumer) {
+ Objects.requireNonNull(consumer, "consumer cannot be null");
ToolComponent tool = itemMeta.getTool();
consumer.accept(tool);
itemMeta.setTool(tool);
@@ -652,23 +729,162 @@ public ItemBuilder editTool(Consumer consumer) {
/**
* Modifies the item's weapon component.
*
+ * Important: In Minecraft's Data Component model, {@link WeaponComponent} controls
+ * durability damage sustained by the weapon per attack ({@link WeaponComponent#setItemDamagePerAttack(int)})
+ * and shield disable duration ({@link WeaponComponent#setDisableBlockingForSeconds(float)}).
+ * It does not define the attack damage dealt to entities. To configure entity attack damage,
+ * use {@link #setAttackDamage(double)}.
+ *
* @param consumer the component modifier
* @return this builder
*/
- public ItemBuilder editWeapon(Consumer consumer) {
+ public ItemBuilder editWeapon(@NonNull Consumer consumer) {
+ Objects.requireNonNull(consumer, "consumer cannot be null");
WeaponComponent weapon = itemMeta.getWeapon();
consumer.accept(weapon);
itemMeta.setWeapon(weapon);
return this;
}
+ /**
+ * Modifies the item's piercing weapon component (spears, projectiles).
+ *
+ * @param consumer the component modifier
+ * @return this builder
+ */
+ public ItemBuilder editPiercingWeapon(@NonNull Consumer consumer) {
+ Objects.requireNonNull(consumer, "consumer cannot be null");
+ PiercingWeaponComponent piercing = itemMeta.getPiercingWeapon();
+ consumer.accept(piercing);
+ itemMeta.setPiercingWeapon(piercing);
+ return this;
+ }
+
+ /**
+ * Modifies the item's kinetic weapon component (maces, charge weapons).
+ *
+ * @param consumer the component modifier
+ * @return this builder
+ */
+ public ItemBuilder editKineticWeapon(@NonNull Consumer consumer) {
+ Objects.requireNonNull(consumer, "consumer cannot be null");
+ KineticWeaponComponent kinetic = itemMeta.getKineticWeapon();
+ consumer.accept(kinetic);
+ itemMeta.setKineticWeapon(kinetic);
+ return this;
+ }
+
+ /**
+ * Modifies the item's attack range component (reach distance and margins).
+ *
+ * @param consumer the component modifier
+ * @return this builder
+ */
+ public ItemBuilder editAttackRange(@NonNull Consumer consumer) {
+ Objects.requireNonNull(consumer, "consumer cannot be null");
+ AttackRangeComponent attackRange = itemMeta.getAttackRange();
+ consumer.accept(attackRange);
+ itemMeta.setAttackRange(attackRange);
+ return this;
+ }
+
+ /**
+ * Modifies the item's attack blocking component (shields, defensive items).
+ *
+ * @param consumer the component modifier
+ * @return this builder
+ */
+ public ItemBuilder editBlocksAttacks(@NonNull Consumer consumer) {
+ Objects.requireNonNull(consumer, "consumer cannot be null");
+ BlocksAttacksComponent blocksAttacks = itemMeta.getBlocksAttacks();
+ consumer.accept(blocksAttacks);
+ itemMeta.setBlocksAttacks(blocksAttacks);
+ return this;
+ }
+
+ /**
+ * Modifies the item's swing animation component.
+ *
+ * @param consumer the component modifier
+ * @return this builder
+ */
+ public ItemBuilder editSwingAnimation(@NonNull Consumer consumer) {
+ Objects.requireNonNull(consumer, "consumer cannot be null");
+ SwingAnimationComponent swing = itemMeta.getSwingAnimation();
+ consumer.accept(swing);
+ itemMeta.setSwingAnimation(swing);
+ return this;
+ }
+
+ /**
+ * Modifies the item's use effects component.
+ *
+ * @param consumer the component modifier
+ * @return this builder
+ */
+ public ItemBuilder editUseEffects(@NonNull Consumer consumer) {
+ Objects.requireNonNull(consumer, "consumer cannot be null");
+ UseEffectsComponent useEffects = itemMeta.getUseEffects();
+ consumer.accept(useEffects);
+ itemMeta.setUseEffects(useEffects);
+ return this;
+ }
+
+ /**
+ * Modifies the item's custom model data component (floats, strings, flags, colors).
+ *
+ * @param consumer the component modifier
+ * @return this builder
+ */
+ public ItemBuilder editCustomModelData(@NonNull Consumer consumer) {
+ Objects.requireNonNull(consumer, "consumer cannot be null");
+ CustomModelDataComponent component = itemMeta.getCustomModelDataComponent();
+ consumer.accept(component);
+ itemMeta.setCustomModelDataComponent(component);
+ return this;
+ }
+
+ /**
+ * Sets the minimum attack charge required to strike with this item.
+ *
+ * @param charge minimum charge fraction (between 0.0 and 1.0), or null to clear
+ * @return this builder
+ */
+ public ItemBuilder setMinimumAttackCharge(@Nullable Float charge) {
+ itemMeta.setMinimumAttackCharge(charge);
+ return this;
+ }
+
+ /**
+ * Sets the break sound played when the item runs out of durability.
+ *
+ * @param sound the break sound, or null to clear
+ * @return this builder
+ */
+ public ItemBuilder setBreakSound(@Nullable Sound sound) {
+ itemMeta.setBreakSound(sound);
+ return this;
+ }
+
+ /**
+ * Sets the custom damage type dealt by this item.
+ *
+ * @param damageType the damage type, or null to clear
+ * @return this builder
+ */
+ public ItemBuilder setDamageType(@Nullable DamageType damageType) {
+ itemMeta.setDamageType(damageType);
+ return this;
+ }
+
/**
* Modifies the item's jukebox playable component.
*
* @param consumer the component modifier
* @return this builder
*/
- public ItemBuilder editJukeboxPlayable(Consumer consumer) {
+ public ItemBuilder editJukeboxPlayable(@NonNull Consumer consumer) {
+ Objects.requireNonNull(consumer, "consumer cannot be null");
JukeboxPlayableComponent jukebox = itemMeta.getJukeboxPlayable();
consumer.accept(jukebox);
itemMeta.setJukeboxPlayable(jukebox);
@@ -681,7 +897,8 @@ public ItemBuilder editJukeboxPlayable(Consumer consum
* @param consumer the component modifier
* @return this builder
*/
- public ItemBuilder editUseCooldown(Consumer consumer) {
+ public ItemBuilder editUseCooldown(@NonNull Consumer consumer) {
+ Objects.requireNonNull(consumer, "consumer cannot be null");
UseCooldownComponent cooldown = itemMeta.getUseCooldown();
consumer.accept(cooldown);
itemMeta.setUseCooldown(cooldown);
@@ -694,7 +911,8 @@ public ItemBuilder editUseCooldown(Consumer consumer) {
* @param consumer the component modifier
* @return this builder
*/
- public ItemBuilder editConsumable(Consumer consumer) {
+ public ItemBuilder editConsumable(@NonNull Consumer consumer) {
+ Objects.requireNonNull(consumer, "consumer cannot be null");
ConsumableComponent consumable = itemMeta.getConsumable();
consumer.accept(consumable);
itemMeta.setConsumable(consumable);
@@ -761,6 +979,9 @@ public ItemBuilder addPotionEffect(PotionEffect effect) {
*/
public ItemBuilder setPotionColor(@NonNull String hex) {
this.potionColor = ColorUtils.fromHex(hex);
+ if (this.itemMeta instanceof PotionMeta potionMeta) {
+ potionMeta.setColor(this.potionColor);
+ }
return this;
}
@@ -810,6 +1031,9 @@ public ItemBuilder setSpawnedType(@NonNull EntityType type) {
*/
public ItemBuilder setLeatherColor(@NonNull String hex) {
this.leatherColor = ColorUtils.fromHex(hex);
+ if (isLeatherDyeable(item.getType()) && itemMeta instanceof LeatherArmorMeta leatherArmorMeta) {
+ leatherArmorMeta.setColor(this.leatherColor);
+ }
return this;
}
@@ -824,6 +1048,9 @@ public ItemBuilder setLeatherColor(@NonNull String hex) {
*/
public ItemBuilder setArmorTrim(@NonNull TrimMaterial material, @NonNull TrimPattern pattern) {
this.armorTrim = new ArmorTrim(material, pattern);
+ if (itemMeta instanceof ArmorMeta armorMeta) {
+ armorMeta.setTrim(this.armorTrim);
+ }
return this;
}
@@ -837,6 +1064,9 @@ public ItemBuilder setArmorTrim(@NonNull TrimMaterial material, @NonNull TrimPat
*/
public ItemBuilder addBannerPatterns(List patterns) {
this.bannerPatterns = patterns;
+ if (itemMeta instanceof BannerMeta bannerMeta) {
+ bannerMeta.setPatterns(patterns);
+ }
return this;
}
@@ -849,7 +1079,7 @@ public ItemBuilder addBannerPatterns(List patterns) {
* @return this builder
*/
public ItemBuilder setSkullOwner(@NonNull UUID playerId) {
- item.setType(Material.PLAYER_HEAD);
+ ensureSkullMeta();
this.skullOwnerProfile = Bukkit.createPlayerProfile(playerId);
this.base64Texture = null;
return this;
@@ -864,7 +1094,7 @@ public ItemBuilder setSkullOwner(@NonNull UUID playerId) {
* @return this builder
*/
public ItemBuilder setSkullOwner(@NonNull String playerName) {
- item.setType(Material.PLAYER_HEAD);
+ ensureSkullMeta();
Player onlinePlayer = Bukkit.getPlayerExact(playerName);
this.skullOwnerProfile = onlinePlayer != null ? onlinePlayer.getPlayerProfile() : Bukkit.createPlayerProfile(playerName);
this.base64Texture = null;
@@ -872,15 +1102,15 @@ public ItemBuilder setSkullOwner(@NonNull String playerName) {
}
/**
- * Sets the skull texture using a Base64 texture value.
+ * Sets the skull texture using a Base64 texture value, texture URL, or skin hash.
*
* The item type is changed to {@link Material#PLAYER_HEAD}.
*
- * @param base64Texture the Base64 encoded texture data
+ * @param base64Texture the Base64 encoded texture data, URL, or hash
* @return this builder
*/
public ItemBuilder setCustomSkullTexture(@NonNull String base64Texture) {
- item.setType(Material.PLAYER_HEAD);
+ ensureSkullMeta();
this.base64Texture = base64Texture;
this.skullOwnerProfile = null;
return this;
@@ -894,7 +1124,14 @@ public ItemBuilder setCustomSkullTexture(@NonNull String base64Texture) {
public ItemBuilder enchantedBook() {
if (item.getType() != Material.ENCHANTED_BOOK) {
item.setType(Material.ENCHANTED_BOOK);
- this.itemMeta = this.item.getItemMeta();
+ ItemMeta newMeta = Bukkit.getItemFactory().getItemMeta(Material.ENCHANTED_BOOK);
+ if (newMeta != null) {
+ if (itemMeta.hasDisplayName()) newMeta.setDisplayName(itemMeta.getDisplayName());
+ if (itemMeta.hasItemName()) newMeta.setItemName(itemMeta.getItemName());
+ if (itemMeta.hasLore()) newMeta.setLore(itemMeta.getLore());
+ newMeta.addItemFlags(itemMeta.getItemFlags().toArray(new ItemFlag[0]));
+ this.itemMeta = newMeta;
+ }
}
return this;
}
@@ -971,24 +1208,6 @@ public String getDisplayName() {
return itemMeta.getDisplayName();
}
- /**
- * Returns the configured enchantments.
- *
- * @return unmodifiable enchantment map
- */
- public Map getEnchantments() {
- return Collections.unmodifiableMap(enchantments);
- }
-
- /**
- * Returns the configured banner patterns.
- *
- * @return an unmodifiable list of banner patterns
- */
- public List getBannerPatterns() {
- return bannerPatterns != null ? Collections.unmodifiableList(bannerPatterns) : Collections.emptyList();
- }
-
private void applyEnchantments() {
if (enchantments.isEmpty()) return;
if (itemMeta instanceof EnchantmentStorageMeta storageMeta) {
@@ -1033,7 +1252,21 @@ private void applySkullProperties() {
if (skullOwnerProfile != null) {
skullMeta.setOwnerProfile(skullOwnerProfile);
} else if (base64Texture != null) {
- applyBase64Texture(skullMeta, base64Texture);
+ applyCustomTexture(skullMeta, base64Texture);
+ }
+ }
+ }
+
+ private void ensureSkullMeta() {
+ if (item.getType() != Material.PLAYER_HEAD) {
+ item.setType(Material.PLAYER_HEAD);
+ ItemMeta newMeta = Bukkit.getItemFactory().getItemMeta(Material.PLAYER_HEAD);
+ if (newMeta != null) {
+ if (itemMeta.hasDisplayName()) newMeta.setDisplayName(itemMeta.getDisplayName());
+ if (itemMeta.hasItemName()) newMeta.setItemName(itemMeta.getItemName());
+ if (itemMeta.hasLore()) newMeta.setLore(itemMeta.getLore());
+ newMeta.addItemFlags(itemMeta.getItemFlags().toArray(new ItemFlag[0]));
+ this.itemMeta = newMeta;
}
}
}
From bf129cca19977a459a7c520d4e9c6706db92d71b Mon Sep 17 00:00:00 2001
From: Moussa
Date: Wed, 30 Sep 2026 21:05:46 +0200
Subject: [PATCH 14/23] refactor: update code to use new ItemBuilder methods
---
.../moderator/ModerationTools.java | 2 +-
.../entities/bosses/CorruptedChampion.java | 4 +--
.../blightedSMP/content/items/Bonemerang.java | 2 +-
.../content/items/GlimmeringEye.java | 2 +-
.../content/items/KnightsSword.java | 7 ++----
.../content/items/ThermalFuels.java | 8 +++---
.../content/items/armors/HomodeusArmor.java | 10 ++++----
.../items/blocks/BlightedBlockItems.java | 4 +--
.../items/materials/BlightedMaterials.java | 2 +-
.../content/items/materials/EndMaterials.java | 8 +++---
.../items/materials/FishingMaterials.java | 10 ++++----
.../items/materials/NetherMaterials.java | 24 +++++++++---------
.../items/recipes/forging/menu/ForgeMenu.java | 4 +--
.../items/registry/menu/ItemRegistryMenu.java | 25 ++++++++-----------
14 files changed, 53 insertions(+), 59 deletions(-)
diff --git a/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/ModerationTools.java b/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/ModerationTools.java
index ad89bfb0..6b16b15e 100644
--- a/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/ModerationTools.java
+++ b/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/ModerationTools.java
@@ -15,7 +15,7 @@ public static ItemStack getRandomTeleporter() {
return new ItemBuilder(Material.ENDER_EYE)
.setDisplayName("§dRandom Teleport §7(Right Click)")
.addLore("§7Teleport to a random player.")
- .addEnchantmentGlint()
+ .glow()
.toItemStack();
}
diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/bosses/CorruptedChampion.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/bosses/CorruptedChampion.java
index 66ec294b..a19f5885 100644
--- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/bosses/CorruptedChampion.java
+++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/entities/bosses/CorruptedChampion.java
@@ -66,7 +66,7 @@ public CorruptedChampion() {
.toItemStack()
};
- itemInMainHand = new ItemBuilder(Material.NETHERITE_SWORD).addEnchantmentGlint().toItemStack();
+ itemInMainHand = new ItemBuilder(Material.NETHERITE_SWORD).glow().toItemStack();
}
private static ItemStack createRocketBoots() {
@@ -748,7 +748,7 @@ private void configureSwordGiant(Giant giant, double scale) {
scaleAttribute.setBaseValue(scale);
}
- Objects.requireNonNull(giant.getEquipment()).setItemInMainHand(new ItemBuilder(Material.NETHERITE_SWORD).addEnchantmentGlint().toItemStack());
+ Objects.requireNonNull(giant.getEquipment()).setItemInMainHand(new ItemBuilder(Material.NETHERITE_SWORD).glow().toItemStack());
}
private void setBootsEquipped(ItemStack bootsItem) {
diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/items/Bonemerang.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/items/Bonemerang.java
index d6e9ace1..b4e4070a 100644
--- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/items/Bonemerang.java
+++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/items/Bonemerang.java
@@ -24,7 +24,7 @@ public void register(Consumer registry) {
ItemRarity.EPIC.getName() + " BOW"
);
bonemerang.addAbility(new BonemerangAbility(), false);
- bonemerang.addEnchantmentGlint();
+ bonemerang.glow();
bonemerang.unstackable();
registry.accept(bonemerang);
diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/items/GlimmeringEye.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/items/GlimmeringEye.java
index e5c772ee..4603c63c 100644
--- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/items/GlimmeringEye.java
+++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/items/GlimmeringEye.java
@@ -16,7 +16,7 @@ public void register(Consumer registry) {
BlightedItem glimmeringEye = new BlightedItem("GLIMMERING_EYE", ItemType.UNCATEGORIZED, ItemRarity.RARE, Material.ENDER_EYE);
glimmeringEye.setDisplayName("Glimmering Eye");
- glimmeringEye.addEnchantmentGlint();
+ glimmeringEye.glow();
glimmeringEye.addAbility(new VoidStepAbility());
glimmeringEye.addLore("", ItemRarity.RARE.getName());
glimmeringEye.addRule(ItemRule.PREVENT_PROJECTILE_LAUNCH);
diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/items/KnightsSword.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/items/KnightsSword.java
index 03fe7fc9..256c8ebb 100644
--- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/items/KnightsSword.java
+++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/items/KnightsSword.java
@@ -7,9 +7,6 @@
import fr.moussax.blightedSMP.registry.RegistryModule;
import java.util.function.Consumer;
import org.bukkit.Material;
-import org.bukkit.attribute.Attribute;
-import org.bukkit.attribute.AttributeModifier;
-import org.bukkit.inventory.EquipmentSlotGroup;
public class KnightsSword implements RegistryModule> {
@@ -29,8 +26,8 @@ public void register(Consumer registry) {
);
knightSword.addAbility(new KnightsSlamAbility(), false);
- knightSword.addAttributeModifier(Attribute.ATTACK_DAMAGE, 10, AttributeModifier.Operation.ADD_NUMBER, EquipmentSlotGroup.MAINHAND);
- knightSword.addAttributeModifier(Attribute.ATTACK_SPEED, 1.2, AttributeModifier.Operation.ADD_NUMBER, EquipmentSlotGroup.MAINHAND);
+ knightSword.setAttackDamage(10);
+ knightSword.setAttackSpeed(1.2);
registry.accept(knightSword);
}
diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/items/ThermalFuels.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/items/ThermalFuels.java
index 0aea15ab..63cba04b 100644
--- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/items/ThermalFuels.java
+++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/items/ThermalFuels.java
@@ -22,7 +22,7 @@ public void register(Consumer registry) {
" §7machine.", ""
);
enchantedCoal.addLore(ItemRarity.UNCOMMON.getName());
- enchantedCoal.addEnchantmentGlint();
+ enchantedCoal.glow();
BlightedItem enchantedLavaBucket = new BlightedItem("ENCHANTED_LAVA_BUCKET", ItemType.UNCATEGORIZED, ItemRarity.RARE, Material.LAVA_BUCKET);
enchantedLavaBucket.setDisplayName("Enchanted Lava Bucket");
@@ -34,7 +34,7 @@ public void register(Consumer registry) {
" §7machine.", ""
);
enchantedLavaBucket.addLore(ItemRarity.RARE.getName());
- enchantedLavaBucket.addEnchantmentGlint();
+ enchantedLavaBucket.glow();
enchantedLavaBucket.addRule(ItemRule.PREVENT_BUCKET_INTERACTIONS);
BlightedItem magmaBucket = new BlightedItem("MAGMA_BUCKET", ItemType.UNCATEGORIZED, ItemRarity.EPIC, Material.LAVA_BUCKET);
@@ -47,7 +47,7 @@ public void register(Consumer registry) {
" §7machine.", ""
);
magmaBucket.addLore(ItemRarity.EPIC.getName());
- magmaBucket.addEnchantmentGlint();
+ magmaBucket.glow();
magmaBucket.addRule(ItemRule.PREVENT_BUCKET_INTERACTIONS);
BlightedItem plasmaBucket = new BlightedItem("PLASMA_BUCKET", ItemType.UNCATEGORIZED, ItemRarity.LEGENDARY, Material.LAVA_BUCKET);
@@ -60,7 +60,7 @@ public void register(Consumer registry) {
" §7machine.", ""
);
plasmaBucket.addLore(ItemRarity.LEGENDARY.getName());
- plasmaBucket.addEnchantmentGlint();
+ plasmaBucket.glow();
plasmaBucket.addRule(ItemRule.PREVENT_BUCKET_INTERACTIONS);
registry.accept(enchantedCoal);
diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/items/armors/HomodeusArmor.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/items/armors/HomodeusArmor.java
index 1e756537..857f4bd5 100644
--- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/items/armors/HomodeusArmor.java
+++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/items/armors/HomodeusArmor.java
@@ -6,9 +6,9 @@
import fr.moussax.blightedSMP.engine.items.ItemType;
import fr.moussax.blightedSMP.engine.items.abilities.FullSetBonus;
import fr.moussax.blightedSMP.registry.RegistryModule;
-import java.util.function.Consumer;
import org.bukkit.Material;
-import org.bukkit.inventory.ItemFlag;
+
+import java.util.function.Consumer;
public class HomodeusArmor implements RegistryModule> {
@@ -39,10 +39,10 @@ public void register(Consumer registry) {
}
private void setupHomodeusPiece(BlightedItem piece, FullSetBonus bonus) {
- piece.setUnbreakable(true);
- piece.addEnchantmentGlint();
+ piece.unbreakable();
+ piece.glow();
piece.setLeatherColor("#ffffff");
- piece.addItemFlag(ItemFlag.HIDE_DYE, ItemFlag.HIDE_ATTRIBUTES, ItemFlag.HIDE_ENCHANTS, ItemFlag.HIDE_UNBREAKABLE);
+ piece.hideAllFlags();
piece.setFullSetBonus(bonus);
piece.addLore("", ItemRarity.LEGENDARY.getName());
diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/items/blocks/BlightedBlockItems.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/items/blocks/BlightedBlockItems.java
index 07f4bbf8..9ce6538d 100644
--- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/items/blocks/BlightedBlockItems.java
+++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/items/blocks/BlightedBlockItems.java
@@ -24,7 +24,7 @@ public void register(Consumer registry) {
"",
ItemRarity.UNCOMMON.getName() + " BLOCK"
);
- blightedWorkbench.addEnchantmentGlint();
+ blightedWorkbench.glow();
BlightedItem blightedForge = new BlightedItem("BLIGHTED_FORGE", ItemType.BLOCK, ItemRarity.RARE, Material.BLAST_FURNACE);
blightedForge.setDisplayName("Blighted Forge");
@@ -38,7 +38,7 @@ public void register(Consumer registry) {
"",
ItemRarity.RARE.getName() + " MACHINE"
);
- blightedForge.addEnchantmentGlint();
+ blightedForge.glow();
registry.accept(blightedWorkbench);
registry.accept(blightedForge);
diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/items/materials/BlightedMaterials.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/items/materials/BlightedMaterials.java
index 94edab7c..761c0507 100644
--- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/items/materials/BlightedMaterials.java
+++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/items/materials/BlightedMaterials.java
@@ -151,7 +151,7 @@ private BlightedItem createMaterialItem(
BlightedItem item = new BlightedItem(id, type, rarity, material);
item.setDisplayName(displayName);
item.addLore(rarity.getName());
- item.addEnchantmentGlint();
+ item.glow();
if (preventPlacement) {
item.addRule(ItemRule.PREVENT_PLACEMENT);
diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/items/materials/EndMaterials.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/items/materials/EndMaterials.java
index 003b2ba0..fa9fd2bd 100644
--- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/items/materials/EndMaterials.java
+++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/items/materials/EndMaterials.java
@@ -14,19 +14,19 @@ public void register(Consumer registry) {
BlightedItem enchantedEnderPearl = new BlightedItem("ENCHANTED_ENDER_PEARL", ItemType.MATERIAL, ItemRarity.UNCOMMON, Material.ENDER_PEARL);
enchantedEnderPearl.setDisplayName("Enchanted Ender Pearl");
enchantedEnderPearl.addLore(ItemRarity.UNCOMMON.getName());
- enchantedEnderPearl.addEnchantmentGlint();
+ enchantedEnderPearl.glow();
enchantedEnderPearl.addRule(ItemRule.PREVENT_PROJECTILE_LAUNCH);
BlightedItem enchantedEndstone = new BlightedItem("ENCHANTED_END_STONE", ItemType.MATERIAL, ItemRarity.UNCOMMON, Material.END_STONE);
enchantedEndstone.setDisplayName("Enchanted End Stone");
enchantedEndstone.addLore(ItemRarity.UNCOMMON.getName());
- enchantedEndstone.addEnchantmentGlint();
+ enchantedEndstone.glow();
enchantedEndstone.addRule(ItemRule.PREVENT_PLACEMENT);
BlightedItem enchantedChorusFruit = new BlightedItem("ENCHANTED_CHORUS_FRUIT", ItemType.MATERIAL, ItemRarity.UNCOMMON, Material.CHORUS_FRUIT);
enchantedChorusFruit.setDisplayName("Enchanted Chorus Fruit");
enchantedChorusFruit.addLore(ItemRarity.UNCOMMON.getName());
- enchantedChorusFruit.addEnchantmentGlint();
+ enchantedChorusFruit.glow();
BlightedItem voidResidue = new BlightedItem("VOID_RESIDUE", ItemType.MATERIAL, ItemRarity.UNCOMMON, Material.PURPLE_DYE);
voidResidue.setDisplayName("Voidling Residue");
@@ -37,7 +37,7 @@ public void register(Consumer registry) {
"",
ItemRarity.UNCOMMON.getName()
);
- voidResidue.addEnchantmentGlint();
+ voidResidue.glow();
registry.accept(enchantedEnderPearl);
registry.accept(enchantedEndstone);
diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/items/materials/FishingMaterials.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/items/materials/FishingMaterials.java
index ea293eed..9f58be8d 100644
--- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/items/materials/FishingMaterials.java
+++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/items/materials/FishingMaterials.java
@@ -29,7 +29,7 @@ public void register(Consumer registry) {
ItemRarity.UNCOMMON.getName()
);
blightedAlgae.addRule(ItemRule.PREVENT_PLACEMENT);
- blightedAlgae.addEnchantmentGlint();
+ blightedAlgae.glow();
BlightedItem smokedSalmonPlate = new BlightedItem("SMOKED_SALMON_PLATE", ItemType.UNCATEGORIZED, ItemRarity.UNCOMMON, Material.COOKED_SALMON);
smokedSalmonPlate.setDisplayName("Smoked Salmon Plate");
@@ -105,7 +105,7 @@ public void register(Consumer registry) {
"",
ItemRarity.RARE.getName()
);
- barnacleCluster.addEnchantmentGlint();
+ barnacleCluster.glow();
BlightedItem coralFragment = new BlightedItem("CORAL_FRAGMENT", ItemType.MATERIAL, ItemRarity.RARE, Material.FIRE_CORAL);
coralFragment.setDisplayName("Coral Fragment");
@@ -118,7 +118,7 @@ public void register(Consumer registry) {
ItemRarity.RARE.getName()
);
coralFragment.addRule(ItemRule.PREVENT_PLACEMENT);
- coralFragment.addEnchantmentGlint();
+ coralFragment.glow();
BlightedItem messageInABottle = new BlightedItem("MESSAGE_IN_A_BOTTLE", ItemType.UNCATEGORIZED, ItemRarity.RARE, Material.GLASS_BOTTLE);
messageInABottle.setDisplayName("Message in a Bottle");
@@ -159,7 +159,7 @@ public void register(Consumer registry) {
player.addPotionEffect(new PotionEffect(PotionEffectType.HUNGER, 300, 0));
}
});
- blightedSushi.addEnchantmentGlint();
+ blightedSushi.glow();
BlightedItem abyssalPearl = new BlightedItem("ABYSSAL_PEARL", ItemType.MATERIAL, ItemRarity.EPIC, Material.ENDER_PEARL);
abyssalPearl.setDisplayName("Abyssal Pearl");
@@ -172,7 +172,7 @@ public void register(Consumer registry) {
ItemRarity.EPIC.getName()
);
abyssalPearl.addRule(ItemRule.PREVENT_PROJECTILE_LAUNCH);
- abyssalPearl.addEnchantmentGlint();
+ abyssalPearl.glow();
BlightedItem drownedResearchCodex = new BlightedItem("DROWNED_RESEARCH_CODEX", ItemType.UNCATEGORIZED, ItemRarity.EPIC, Material.KNOWLEDGE_BOOK);
drownedResearchCodex.setDisplayName("Drowned Research Codex");
diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/items/materials/NetherMaterials.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/items/materials/NetherMaterials.java
index c941365b..178380c2 100644
--- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/items/materials/NetherMaterials.java
+++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/content/items/materials/NetherMaterials.java
@@ -16,37 +16,37 @@ public void register(Consumer registry) {
BlightedItem enchantedGhastTear = new BlightedItem("ENCHANTED_GHAST_TEAR", ItemType.MATERIAL, ItemRarity.UNCOMMON, Material.GHAST_TEAR);
enchantedGhastTear.setDisplayName("Enchanted Ghast Tear");
enchantedGhastTear.addLore(ItemRarity.UNCOMMON.getName());
- enchantedGhastTear.addEnchantmentGlint();
+ enchantedGhastTear.glow();
BlightedItem enchantedMagmaCream = new BlightedItem("ENCHANTED_MAGMA_CREAM", ItemType.MATERIAL, ItemRarity.UNCOMMON, Material.MAGMA_CREAM);
enchantedMagmaCream.setDisplayName("Enchanted Magma Cream");
enchantedMagmaCream.addLore(ItemRarity.UNCOMMON.getName());
- enchantedMagmaCream.addEnchantmentGlint();
+ enchantedMagmaCream.glow();
BlightedItem enchantedQuartz = new BlightedItem("ENCHANTED_QUARTZ", ItemType.MATERIAL, ItemRarity.UNCOMMON, Material.QUARTZ);
enchantedQuartz.setDisplayName("Enchanted Quartz");
enchantedQuartz.addLore(ItemRarity.UNCOMMON.getName());
- enchantedQuartz.addEnchantmentGlint();
+ enchantedQuartz.glow();
BlightedItem enchantedBlazePowder = new BlightedItem("ENCHANTED_BLAZE_POWDER", ItemType.MATERIAL, ItemRarity.UNCOMMON, Material.BLAZE_POWDER);
enchantedBlazePowder.setDisplayName("Enchanted Blaze Powder");
enchantedBlazePowder.addLore(ItemRarity.UNCOMMON.getName());
- enchantedBlazePowder.addEnchantmentGlint();
+ enchantedBlazePowder.glow();
BlightedItem enchantedBlazeRod = new BlightedItem("ENCHANTED_BLAZE_ROD", ItemType.MATERIAL, ItemRarity.RARE, Material.BLAZE_ROD);
enchantedBlazeRod.setDisplayName("Enchanted Blaze Rod");
enchantedBlazeRod.addLore(ItemRarity.RARE.getName());
- enchantedBlazeRod.addEnchantmentGlint();
+ enchantedBlazeRod.glow();
BlightedItem enchantedGlowstoneDust = new BlightedItem("ENCHANTED_GLOWSTONE_DUST", ItemType.MATERIAL, ItemRarity.UNCOMMON, Material.GLOWSTONE_DUST);
enchantedGlowstoneDust.setDisplayName("Enchanted Glowstone Dust");
enchantedGlowstoneDust.addLore(ItemRarity.UNCOMMON.getName());
- enchantedGlowstoneDust.addEnchantmentGlint();
+ enchantedGlowstoneDust.glow();
BlightedItem enchantedNetherWart = new BlightedItem("ENCHANTED_NETHER_WART", ItemType.MATERIAL, ItemRarity.UNCOMMON, Material.NETHER_WART);
enchantedNetherWart.setDisplayName("Enchanted Nether Wart");
enchantedNetherWart.addLore(ItemRarity.UNCOMMON.getName());
- enchantedNetherWart.addEnchantmentGlint();
+ enchantedNetherWart.glow();
BlightedItem flames = new BlightedItem("FLAMES", ItemType.MATERIAL, ItemRarity.RARE, Material.BLAZE_POWDER);
flames.setDisplayName("Flames");
@@ -57,7 +57,7 @@ public void register(Consumer registry) {
"",
ItemRarity.RARE.getName()
);
- flames.addEnchantmentGlint();
+ flames.glow();
BlightedItem sulfur = new BlightedItem("SULFUR", ItemType.MATERIAL, ItemRarity.UNCOMMON, Material.GLOWSTONE_DUST);
sulfur.setDisplayName("Sulfur");
@@ -72,12 +72,12 @@ public void register(Consumer registry) {
BlightedItem enchantedSulfur = new BlightedItem("ENCHANTED_SULFUR", ItemType.MATERIAL, ItemRarity.RARE, Material.GLOWSTONE_DUST);
enchantedSulfur.setDisplayName("Enchanted Sulfur");
enchantedSulfur.addLore(ItemRarity.RARE.getName());
- enchantedSulfur.addEnchantmentGlint();
+ enchantedSulfur.glow();
BlightedItem enchantedNetherrack = new BlightedItem("ENCHANTED_NETHERRACK", ItemType.MATERIAL, ItemRarity.UNCOMMON, Material.NETHERRACK);
enchantedNetherrack.setDisplayName("Enchanted Netherrack");
enchantedNetherrack.addLore(ItemRarity.UNCOMMON.getName());
- enchantedNetherrack.addEnchantmentGlint();
+ enchantedNetherrack.glow();
// TODO - Finish items
BlightedItem vengefulEye = new BlightedItem("VENGEFUL_EYE", ItemType.UNCATEGORIZED, ItemRarity.EPIC, Material.PLAYER_HEAD);
@@ -105,13 +105,13 @@ public void register(Consumer registry) {
food.setSaturation(14.4f);
food.setCanAlwaysEat(true);
});
- cremeBrulee.addEnchantmentGlint();
+ cremeBrulee.glow();
BlightedItem suspiciousFungus = new BlightedItem("SUSPICIOUS_FUNGUS", ItemType.UNCATEGORIZED, ItemRarity.RARE, Material.CRIMSON_FUNGUS);
suspiciousFungus.setDisplayName("SuSpIcIoUs Fungus");
suspiciousFungus.addLore(ItemRarity.RARE.getName());
suspiciousFungus.addRule(ItemRule.PREVENT_PLACEMENT);
- suspiciousFungus.addEnchantmentGlint();
+ suspiciousFungus.glow();
registry.accept(enchantedGhastTear);
registry.accept(enchantedMagmaCream);
diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/recipes/forging/menu/ForgeMenu.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/recipes/forging/menu/ForgeMenu.java
index 7ee66a03..a975b919 100644
--- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/recipes/forging/menu/ForgeMenu.java
+++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/recipes/forging/menu/ForgeMenu.java
@@ -260,7 +260,7 @@ private ItemStack createFuelMeter(int currentFuel) {
" §7craft advanced items.",
""
)
- .addEnchantmentGlint().toItemStack();
+ .glow().toItemStack();
}
private ItemStack createFuelGuide() {
@@ -282,7 +282,7 @@ private ItemStack createFuelGuide() {
" §8‣ §cPlasma Bucket §8- §650,000mB",
""
)
- .addEnchantmentGlint()
+ .glow()
.toItemStack();
}
diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/registry/menu/ItemRegistryMenu.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/registry/menu/ItemRegistryMenu.java
index 81dd8932..7984ffd2 100644
--- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/registry/menu/ItemRegistryMenu.java
+++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/registry/menu/ItemRegistryMenu.java
@@ -26,20 +26,17 @@ public final class ItemRegistryMenu {
private static final int[] CATEGORY_SLOTS = PaginatedMenu.INNER_GRID_SLOTS;
private static final int SEARCH_SLOT = 41;
- private static ItemBuilder hideAllItemFlags(ItemBuilder builder) {
- return builder.addItemFlag(
- ItemFlag.HIDE_ATTRIBUTES,
- ItemFlag.HIDE_UNBREAKABLE,
- ItemFlag.HIDE_ENCHANTS,
- ItemFlag.HIDE_DESTROYS,
- ItemFlag.HIDE_PLACED_ON
- );
- }
-
private static ItemStack buildMenuItem(ItemStack base, String name, List lore) {
- ItemBuilder builder = new ItemBuilder(base).setDisplayName(name);
- if (lore != null) lore.forEach(builder::addLore);
- hideAllItemFlags(builder);
+ ItemBuilder builder = new ItemBuilder(base)
+ .setDisplayName(name)
+ .addItemFlag(
+ ItemFlag.HIDE_ATTRIBUTES,
+ ItemFlag.HIDE_UNBREAKABLE,
+ ItemFlag.HIDE_ENCHANTS,
+ ItemFlag.HIDE_DESTROYS,
+ ItemFlag.HIDE_PLACED_ON
+ );
+ if (lore != null) builder.setLore(lore);
return builder.toItemStack();
}
@@ -163,7 +160,7 @@ protected ItemStack getItem(@NonNull Player player, int index) {
stack.setItemMeta(meta);
}
- return hideAllItemFlags(new ItemBuilder(stack)).toItemStack();
+ return stack;
}
@Override
From 79f80a48ed61a72484ffd04e6cb7746400c51909 Mon Sep 17 00:00:00 2001
From: Moussa
Date: Wed, 30 Sep 2026 21:48:40 +0200
Subject: [PATCH 15/23] refactor(utils): simplify ItemBuilder meta handling
with `asMetaFor`
---
.../java/fr/moussax/bedrock/utils/ItemBuilder.java | 12 ++----------
1 file changed, 2 insertions(+), 10 deletions(-)
diff --git a/bedrock/src/main/java/fr/moussax/bedrock/utils/ItemBuilder.java b/bedrock/src/main/java/fr/moussax/bedrock/utils/ItemBuilder.java
index c652ca7a..fb2802b1 100644
--- a/bedrock/src/main/java/fr/moussax/bedrock/utils/ItemBuilder.java
+++ b/bedrock/src/main/java/fr/moussax/bedrock/utils/ItemBuilder.java
@@ -1124,12 +1124,8 @@ public ItemBuilder setCustomSkullTexture(@NonNull String base64Texture) {
public ItemBuilder enchantedBook() {
if (item.getType() != Material.ENCHANTED_BOOK) {
item.setType(Material.ENCHANTED_BOOK);
- ItemMeta newMeta = Bukkit.getItemFactory().getItemMeta(Material.ENCHANTED_BOOK);
+ ItemMeta newMeta = Bukkit.getItemFactory().asMetaFor(itemMeta, Material.ENCHANTED_BOOK);
if (newMeta != null) {
- if (itemMeta.hasDisplayName()) newMeta.setDisplayName(itemMeta.getDisplayName());
- if (itemMeta.hasItemName()) newMeta.setItemName(itemMeta.getItemName());
- if (itemMeta.hasLore()) newMeta.setLore(itemMeta.getLore());
- newMeta.addItemFlags(itemMeta.getItemFlags().toArray(new ItemFlag[0]));
this.itemMeta = newMeta;
}
}
@@ -1260,12 +1256,8 @@ private void applySkullProperties() {
private void ensureSkullMeta() {
if (item.getType() != Material.PLAYER_HEAD) {
item.setType(Material.PLAYER_HEAD);
- ItemMeta newMeta = Bukkit.getItemFactory().getItemMeta(Material.PLAYER_HEAD);
+ ItemMeta newMeta = Bukkit.getItemFactory().asMetaFor(itemMeta, Material.PLAYER_HEAD);
if (newMeta != null) {
- if (itemMeta.hasDisplayName()) newMeta.setDisplayName(itemMeta.getDisplayName());
- if (itemMeta.hasItemName()) newMeta.setItemName(itemMeta.getItemName());
- if (itemMeta.hasLore()) newMeta.setLore(itemMeta.getLore());
- newMeta.addItemFlags(itemMeta.getItemFlags().toArray(new ItemFlag[0]));
this.itemMeta = newMeta;
}
}
From a223c7be19d087d1af663da29ad8d111090d84b1 Mon Sep 17 00:00:00 2001
From: Moussa
Date: Wed, 30 Sep 2026 21:49:26 +0200
Subject: [PATCH 16/23] feat(actionbar): stop service and clear state when
owning plugin unregisters
---
.../bedrock/ui/actionbar/Actionbar.java | 2 ++
.../ui/actionbar/ActionbarService.java | 21 +++++++++++++++++--
2 files changed, 21 insertions(+), 2 deletions(-)
diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/actionbar/Actionbar.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/actionbar/Actionbar.java
index cc26f3d9..d0951217 100644
--- a/bedrock/src/main/java/fr/moussax/bedrock/ui/actionbar/Actionbar.java
+++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/actionbar/Actionbar.java
@@ -412,6 +412,8 @@ public static void unregister(@NonNull Player player, @NonNull String sectionId)
/**
* Unregisters all sections registered by an owning plugin.
*
+ * If the plugin is the service's owning plugin, stops the service.
+ *
* @param plugin owning plugin to clean up
*/
public static void unregisterAll(@NonNull Plugin plugin) {
diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/actionbar/ActionbarService.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/actionbar/ActionbarService.java
index a69381b6..6b9d9c76 100644
--- a/bedrock/src/main/java/fr/moussax/bedrock/ui/actionbar/ActionbarService.java
+++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/actionbar/ActionbarService.java
@@ -8,6 +8,7 @@
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
+import org.bukkit.event.HandlerList;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.plugin.Plugin;
@@ -62,7 +63,9 @@ public ActionbarService(@NonNull Plugin plugin) {
if (instance == null) {
instance = this;
}
- Bukkit.getPluginManager().registerEvents(this, plugin);
+ if (Bukkit.getServer() != null) {
+ Bukkit.getPluginManager().registerEvents(this, plugin);
+ }
}
/**
@@ -121,6 +124,12 @@ public void stop() {
composers.clear();
lastSentTexts.clear();
lastSentTimestamps.clear();
+ HandlerList.unregisterAll(this);
+ synchronized (ActionbarService.class) {
+ if (instance == this) {
+ instance = null;
+ }
+ }
}
/**
@@ -225,15 +234,23 @@ public void unregisterSection(@NonNull Player player, @NonNull String sectionId)
}
/**
- * Unregisters all sections associated with an owning plugin without stopping the service.
+ * Unregisters all sections associated with a plugin.
+ *
+ * If the supplied plugin is the service's owning plugin, stops the service and clears the
+ * singleton instance; otherwise, removes only that plugin's registered sections while keeping
+ * the service running.
*
* @param plugin owning plugin to clean up
*/
public void unregisterAll(@NonNull Plugin plugin) {
+ Objects.requireNonNull(plugin, "plugin cannot be null");
Set sectionIds = pluginSections.remove(plugin);
if (sectionIds != null) {
sectionIds.forEach(this::unregisterSection);
}
+ if (this.plugin.equals(plugin)) {
+ stop();
+ }
}
/**
From f905cc08febc9764603e09754d5886c64119e77b Mon Sep 17 00:00:00 2001
From: Moussa
Date: Thu, 1 Oct 2026 14:02:34 +0200
Subject: [PATCH 17/23] fix(sign): re-register listener on owner unregistration
and modernize block updates
---
.../fr/moussax/bedrock/ui/sign/SignInput.java | 18 +++++++++++++++++-
1 file changed, 17 insertions(+), 1 deletion(-)
diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/sign/SignInput.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/sign/SignInput.java
index 7879533d..fd8fdf7d 100644
--- a/bedrock/src/main/java/fr/moussax/bedrock/ui/sign/SignInput.java
+++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/sign/SignInput.java
@@ -9,8 +9,11 @@
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.Material;
+import org.bukkit.block.Sign;
+import org.bukkit.block.sign.Side;
import org.bukkit.craftbukkit.entity.CraftPlayer;
import org.bukkit.entity.Player;
+import org.bukkit.event.HandlerList;
import org.bukkit.plugin.Plugin;
import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;
@@ -32,6 +35,7 @@ public final class SignInput {
private static final String DEFAULT_POINTER = "^^^^^^^^^^^^^^^";
private static final Set ACTIVE_PLUGINS = ConcurrentHashMap.newKeySet();
private static volatile SignInputListener listenerInstance;
+ private static Plugin listenerOwner;
private final String[] lines;
private final int inputLine;
@@ -68,6 +72,7 @@ public static synchronized void initialize(@NonNull Plugin plugin) {
if (listenerInstance == null) {
listenerInstance = new SignInputListener();
Bukkit.getPluginManager().registerEvents(listenerInstance, plugin);
+ listenerOwner = plugin;
for (Player player : Bukkit.getOnlinePlayers()) {
listenerInstance.inject(player);
}
@@ -86,6 +91,12 @@ public static synchronized void cleanup(@NonNull Plugin plugin) {
if (ACTIVE_PLUGINS.isEmpty() && listenerInstance != null) {
listenerInstance.cleanup();
listenerInstance = null;
+ listenerOwner = null;
+ } else if (plugin.equals(listenerOwner)) {
+ Plugin replacementOwner = ACTIVE_PLUGINS.iterator().next();
+ HandlerList.unregisterAll(listenerInstance);
+ Bukkit.getPluginManager().registerEvents(listenerInstance, replacementOwner);
+ listenerOwner = replacementOwner;
}
}
@@ -197,7 +208,12 @@ public void open(@NonNull Player player) {
for (int i = 0; i < Math.min(4, lines.length); i++) {
safeLines[i] = lines[i] != null ? lines[i] : "";
}
- player.sendSignChange(location, safeLines);
+ Sign signState = (Sign) Material.PALE_OAK_SIGN.createBlockData().createBlockState();
+ Side side = frontSide ? Side.FRONT : Side.BACK;
+ for (int i = 0; i < safeLines.length; i++) {
+ signState.getSide(side).setLine(i, safeLines[i]);
+ }
+ player.sendBlockUpdate(location, signState);
BlockPos blockPosition = new BlockPos(location.getBlockX(), location.getBlockY(), location.getBlockZ());
ServerPlayer nmsPlayer = ((CraftPlayer) player).getHandle();
From 568fbbe18010f0fddb59d520c86b557c52ed62c4 Mon Sep 17 00:00:00 2001
From: Moussa
Date: Thu, 1 Oct 2026 14:02:49 +0200
Subject: [PATCH 18/23] fix(sign): re-register listener on owner unregistration
and modernize block updates
---
.../java/fr/moussax/bedrock/ui/menu/Menu.java | 19 +++++++++++++------
1 file changed, 13 insertions(+), 6 deletions(-)
diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/menu/Menu.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/menu/Menu.java
index 18d804dc..0083b4f0 100644
--- a/bedrock/src/main/java/fr/moussax/bedrock/ui/menu/Menu.java
+++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/menu/Menu.java
@@ -148,8 +148,9 @@ public Map getSlots() {
/**
* Places an item in a slot with an interaction-specific action.
*
- * If the slot already exists, its displayed item and action are updated
- * without removing its other registered actions.
+ * If the slot is already interactive, its displayed item and action are updated
+ * without removing its other registered actions. Static slots are replaced so the
+ * new action receives the default click sound.
*
* @param slot inventory slot index
* @param item item displayed in the slot
@@ -159,7 +160,7 @@ public Map getSlots() {
*/
public MenuSlot setItem(int slot, @NonNull ItemStack item, @NonNull MenuItemInteraction interaction, @NonNull MenuAction action) {
MenuSlot existing = slots.get(slot);
- if (existing != null) {
+ if (existing != null && existing.hasAction()) {
existing.item = item;
existing.addAction(interaction, action);
return existing;
@@ -211,10 +212,16 @@ public MenuSlot setItem(int slot, @NonNull ItemStack item, @NonNull Map