From 95c2e94240e78d102947aac4da5e4fcda05c45ec Mon Sep 17 00:00:00 2001 From: Moussa Date: Wed, 30 Sep 2026 08:22:25 +0200 Subject: [PATCH 01/23] feat(actionbar): modernize HUD system with static facade, dynamic alerts, and custom separators Introduce a unified static Actionbar facade for one-line notifications, automated decrementing countdowns, and reactive HUD updates. Enhance ActionbarComposer with stream-free section compilation, per-module custom delimiters, and per-player separators. Incorporate packet diff-suppression in ActionbarService to eliminate duplicate network traffic and migrate blighted-mod and blighted-smp callers to the new API. --- .../bedrock/ui/actionbar/Actionbar.java | 431 ++++++++++++++++++ .../ui/actionbar/ActionbarComposer.java | 212 +++++++-- .../ui/actionbar/ActionbarSection.java | 237 ++++++++-- .../ui/actionbar/ActionbarService.java | 354 ++++++++++++-- .../bedrock/ui/actionbar/TimedAlert.java | 151 +++++- .../ui/actionbar/ActionbarComposerTest.java | 261 ++++++++++- .../moderator/hud/ModerationHud.java | 9 +- .../blightedMod/registry/EventsRegistry.java | 16 +- .../items/abilities/AbilityExecutor.java | 8 +- .../engine/player/hud/PlayerHudManager.java | 58 ++- .../blightedSMP/registry/EventsRegistry.java | 12 +- 11 files changed, 1573 insertions(+), 176 deletions(-) create mode 100644 bedrock/src/main/java/fr/moussax/bedrock/ui/actionbar/Actionbar.java 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 new file mode 100644 index 00000000..9d2fcb88 --- /dev/null +++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/actionbar/Actionbar.java @@ -0,0 +1,431 @@ +package fr.moussax.bedrock.ui.actionbar; + +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.jspecify.annotations.NonNull; + +import java.time.Duration; +import java.util.function.IntFunction; +import java.util.function.Supplier; + +/** + * Static entry point for sending and managing player action bar displays. + * + *

Supports one-shot flash notifications, timed alerts, automated decrementing countdowns, + * dynamic text suppliers, targeted slot alerts, real-time HUD invalidation, and modular persistent sections.

+ */ +public final class Actionbar { + + /** Default duration for temporary notifications (2 seconds). */ + public static final Duration DEFAULT_DURATION = Duration.ofSeconds(2); + + private Actionbar() { + } + + /** + * Initializes or retrieves the shared action bar service for the given plugin. + * + * @param plugin owning plugin + * @param periodTicks interval in ticks between periodic HUD renders + * @return the active action bar service + */ + @NonNull + public static ActionbarService initialize(@NonNull Plugin plugin, long periodTicks) { + ActionbarService service = ActionbarService.getOrCreate(plugin); + service.start(periodTicks); + return service; + } + + /** + * Sets the default delimiter string rendered between adjacent standard sections. + * + * @param separator delimiter text + */ + public static void setSeparator(@NonNull String separator) { + ActionbarService service = ActionbarService.getInstance(); + if (service != null) { + service.setSeparator(separator); + } + } + + /** + * Sets the default delimiter string rendered between adjacent standard sections for a specific player. + * + * @param player target player + * @param separator delimiter text + */ + public static void setSeparator(@NonNull Player player, @NonNull String separator) { + ActionbarService service = ActionbarService.getInstance(); + if (service != null) { + service.setSeparator(player, separator); + } + } + + /** + * Returns the current section delimiter, or default 5 spaces if service is not initialized. + * + * @return separator string + */ + @NonNull + public static String getSeparator() { + ActionbarService service = ActionbarService.getInstance(); + return service != null ? service.getSeparator() : " "; + } + + /** + * Returns the section delimiter configured for a specific player, or the global default. + * + * @param player target player + * @return separator string + */ + @NonNull + public static String getSeparator(@NonNull Player player) { + ActionbarService service = ActionbarService.getInstance(); + return service != null ? service.getSeparator(player) : getSeparator(); + } + + /** + * Sends a temporary notification to a player using the default duration (2 seconds). + * + * @param player target player + * @param message text to display + */ + public static void send(@NonNull Player player, @NonNull String message) { + send(player, message, DEFAULT_DURATION); + } + + /** + * Sends a temporary notification to a player for a specified duration. + * + * @param player target player + * @param message text to display + * @param duration display duration + */ + public static void send(@NonNull Player player, @NonNull String message, @NonNull Duration duration) { + ActionbarService service = ActionbarService.getInstance(); + if (service != null) { + service.sendAlert(player, message, duration); + } else { + player.spigot().sendMessage(ChatMessageType.ACTION_BAR, new TextComponent(message)); + } + } + + /** + * Sends a dynamic temporary notification evaluating text on every render pass. + * + * @param player target player + * @param supplier dynamic text supplier + * @param duration display duration + */ + public static void send(@NonNull Player player, @NonNull Supplier supplier, @NonNull Duration duration) { + ActionbarService service = ActionbarService.getInstance(); + if (service != null) { + service.sendAlert(player, supplier, duration); + } else { + String text = supplier.get(); + if (text != null) { + player.spigot().sendMessage(ChatMessageType.ACTION_BAR, new TextComponent(text)); + } + } + } + + /** + * Sends a modal alert to a player using the default duration (2 seconds). + * Overrides all sections while active. + * + * @param player target player + * @param message alert text + */ + public static void sendAlert(@NonNull Player player, @NonNull String message) { + sendAlert(player, message, 0, DEFAULT_DURATION); + } + + /** + * Sends a default-priority modal alert to a player for a specified duration. + * + * @param player target player + * @param message alert text + * @param duration display duration + */ + public static void sendAlert(@NonNull Player player, @NonNull String message, @NonNull Duration duration) { + sendAlert(player, message, 0, duration); + } + + /** + * Sends a dynamic modal alert that evaluates text on every render pass. + * + * @param player target player + * @param supplier dynamic text supplier + * @param duration display duration + */ + public static void sendAlert(@NonNull Player player, @NonNull Supplier supplier, @NonNull Duration duration) { + sendAlert(player, supplier, 0, duration); + } + + /** + * Sends a dynamic modal alert with custom priority that evaluates text on every render pass. + * + * @param player target player + * @param supplier dynamic text supplier + * @param priority precedence priority + * @param duration display duration + */ + public static void sendAlert(@NonNull Player player, @NonNull Supplier supplier, int priority, @NonNull Duration duration) { + ActionbarService service = ActionbarService.getInstance(); + if (service != null) { + service.sendAlert(player, supplier, priority, duration); + } else { + String text = supplier.get(); + if (text != null) { + player.spigot().sendMessage(ChatMessageType.ACTION_BAR, new TextComponent(text)); + } + } + } + + /** + * Sends a modal alert with custom priority to a player for a specified duration. + * Higher priority alerts take precedence over lower priority alerts. + * + * @param player target player + * @param message alert text + * @param priority precedence priority + * @param duration display duration + */ + public static void sendAlert(@NonNull Player player, @NonNull String message, int priority, @NonNull Duration duration) { + ActionbarService service = ActionbarService.getInstance(); + if (service != null) { + service.sendAlert(player, message, priority, duration); + } else { + player.spigot().sendMessage(ChatMessageType.ACTION_BAR, new TextComponent(message)); + } + } + + /** + * Sends an automated decrementing countdown alert to a player (e.g. {@code "Rebooting in %ds..."}). + * + *

Decrements each second in real-time and clears automatically when time expires. + * Diff-suppression ensures packets are only dispatched when the remaining second decrements.

+ * + * @param player target player + * @param format format string containing {@code %d} + * @param seconds countdown duration in seconds + */ + public static void sendCountdown(@NonNull Player player, @NonNull String format, int seconds) { + sendCountdown(player, format, seconds, 0); + } + + /** + * Sends an automated decrementing countdown alert with custom priority. + * + * @param player target player + * @param format format string containing {@code %d} + * @param seconds countdown duration in seconds + * @param priority precedence priority + */ + public static void sendCountdown(@NonNull Player player, @NonNull String format, int seconds, int priority) { + ActionbarService service = ActionbarService.getInstance(); + if (service != null) { + service.sendCountdown(player, format, seconds, priority); + } else { + player.spigot().sendMessage(ChatMessageType.ACTION_BAR, new TextComponent(format.formatted(seconds))); + } + } + + /** + * Sends an automated decrementing countdown alert with a custom second formatter function. + * + * @param player target player + * @param formatter function receiving remaining integer seconds + * @param seconds countdown duration in seconds + */ + public static void sendCountdown(@NonNull Player player, @NonNull IntFunction formatter, int seconds) { + sendCountdown(player, formatter, seconds, 0); + } + + /** + * Sends an automated decrementing countdown alert with a custom second formatter function and priority. + * + * @param player target player + * @param formatter function receiving remaining integer seconds + * @param seconds countdown duration in seconds + * @param priority precedence priority + */ + public static void sendCountdown(@NonNull Player player, @NonNull IntFunction formatter, int seconds, int priority) { + ActionbarService service = ActionbarService.getInstance(); + if (service != null) { + service.sendCountdown(player, formatter, seconds, priority); + } else { + player.spigot().sendMessage(ChatMessageType.ACTION_BAR, new TextComponent(formatter.apply(seconds))); + } + } + + /** + * Sends a slot-specific alert replacing a registered section's text using the default duration (2 seconds). + * If the section is not found, automatically falls back to a modal alert to prevent dropping the message. + * + * @param player target player + * @param sectionId identifier of the section to temporarily replace + * @param message alert text + */ + public static void sendSlotAlert(@NonNull Player player, @NonNull String sectionId, @NonNull String message) { + sendSlotAlert(player, sectionId, message, DEFAULT_DURATION); + } + + /** + * Sends a slot-specific alert replacing a registered section's text for a player. + * If the section is not found, automatically falls back to a modal alert to prevent dropping the message. + * + * @param player target player + * @param sectionId identifier of the section to temporarily replace + * @param message alert text + * @param duration display duration + */ + public static void sendSlotAlert(@NonNull Player player, @NonNull String sectionId, @NonNull String message, @NonNull Duration duration) { + ActionbarService service = ActionbarService.getInstance(); + if (service != null) { + service.sendSlotAlert(player, sectionId, message, duration); + } else { + player.spigot().sendMessage(ChatMessageType.ACTION_BAR, new TextComponent(message)); + } + } + + /** + * Sends a slot-specific alert with dynamic text evaluation for a player. + * + * @param player target player + * @param sectionId identifier of the section to temporarily replace + * @param supplier dynamic text supplier + * @param duration display duration + */ + public static void sendSlotAlert(@NonNull Player player, @NonNull String sectionId, @NonNull Supplier supplier, @NonNull Duration duration) { + ActionbarService service = ActionbarService.getInstance(); + if (service != null) { + service.sendSlotAlert(player, sectionId, supplier, duration); + } else { + String text = supplier.get(); + if (text != null) { + player.spigot().sendMessage(ChatMessageType.ACTION_BAR, new TextComponent(text)); + } + } + } + + /** + * Sends an automated decrementing countdown alert replacing a specific section. + * + * @param player target player + * @param sectionId target section identifier + * @param format format string containing {@code %d} + * @param seconds countdown duration in seconds + */ + public static void sendSlotCountdown(@NonNull Player player, @NonNull String sectionId, @NonNull String format, int seconds) { + ActionbarService service = ActionbarService.getInstance(); + if (service != null) { + service.sendSlotCountdown(player, sectionId, format, seconds); + } else { + player.spigot().sendMessage(ChatMessageType.ACTION_BAR, new TextComponent(format.formatted(seconds))); + } + } + + /** + * Triggers an immediate action bar recompilation and packet dispatch for a player. + * + *

Packet diff suppression ensures duplicate packets are skipped if the rendered text is unchanged.

+ * + * @param player target player + */ + public static void update(@NonNull Player player) { + ActionbarService service = ActionbarService.getInstance(); + if (service != null) { + service.renderPlayer(player); + } + } + + /** + * Registers a global persistent action bar section. + * + * @param section section to register + */ + public static void register(@NonNull ActionbarSection section) { + ActionbarService service = ActionbarService.getInstance(); + if (service != null) { + service.registerSection(section); + } + } + + /** + * Registers a global persistent section owned by a specific plugin. + * + * @param plugin owning plugin + * @param section section to register + */ + public static void register(@NonNull Plugin plugin, @NonNull ActionbarSection section) { + ActionbarService service = ActionbarService.getInstance(); + if (service != null) { + service.registerSection(plugin, section); + } + } + + /** + * Registers a section visible exclusively to a specific player. + * + * @param player target player + * @param section section to register + */ + public static void register(@NonNull Player player, @NonNull ActionbarSection section) { + ActionbarService service = ActionbarService.getInstance(); + if (service != null) { + service.registerSection(player, section); + } + } + + /** + * Unregisters a global action bar section by identifier. + * + * @param sectionId section identifier + */ + public static void unregister(@NonNull String sectionId) { + ActionbarService service = ActionbarService.getInstance(); + if (service != null) { + service.unregisterSection(sectionId); + } + } + + /** + * Unregisters a player-specific section. + * + * @param player target player + * @param sectionId section identifier + */ + public static void unregister(@NonNull Player player, @NonNull String sectionId) { + ActionbarService service = ActionbarService.getInstance(); + if (service != null) { + service.unregisterSection(player, sectionId); + } + } + + /** + * Unregisters all sections registered by an owning plugin. + * + * @param plugin owning plugin to clean up + */ + public static void unregisterAll(@NonNull Plugin plugin) { + ActionbarService service = ActionbarService.getInstance(); + if (service != null) { + service.unregisterAll(plugin); + } + } + + /** + * Clears all active modal and slot alerts for a player and immediately refreshes their display. + * + * @param player target player + */ + public static void clearAlerts(@NonNull Player player) { + ActionbarService service = ActionbarService.getInstance(); + if (service != null) { + service.clearAlerts(player); + } + } +} diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/actionbar/ActionbarComposer.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/actionbar/ActionbarComposer.java index 0d5a8a81..f4c26b7c 100644 --- a/bedrock/src/main/java/fr/moussax/bedrock/ui/actionbar/ActionbarComposer.java +++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/actionbar/ActionbarComposer.java @@ -1,64 +1,97 @@ package fr.moussax.bedrock.ui.actionbar; +import lombok.Getter; import lombok.Setter; import org.bukkit.entity.Player; import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; import java.time.Duration; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.List; -import java.util.Map; -import java.util.PriorityQueue; +import java.util.*; import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import java.util.function.Supplier; /** * Assembles and formats per-player action bar text from active sections and temporary alerts. + * + *

Supports modular standard sections joined by a separator, priority-based exclusive sections, + * localized slot alerts overriding specific sections, dynamic countdowns, and high-priority modal broadcast alerts.

*/ public final class ActionbarComposer { - private final Map sections = new ConcurrentHashMap<>(); + + private final Map localSections = new ConcurrentHashMap<>(); + private final Supplier> globalSectionsSupplier; private final Map slotAlerts = new ConcurrentHashMap<>(); private final PriorityQueue modalAlerts = new PriorityQueue<>(); private final Object alertLock = new Object(); + @Getter @Setter @NonNull private String separator = " "; /** - * Registers a content section to be rendered by this composer. + * Constructs a standalone action bar composer with no shared global sections. + */ + public ActionbarComposer() { + this(Collections::emptyMap); + } + + /** + * Constructs an action bar composer querying shared global sections from a supplier. + * + * @param globalSectionsSupplier supplier returning active global sections + */ + public ActionbarComposer(@NonNull Supplier> globalSectionsSupplier) { + this.globalSectionsSupplier = Objects.requireNonNull(globalSectionsSupplier, "globalSectionsSupplier cannot be null"); + } + + /** + * Registers a local content section for this composer. Local sections override global sections + * sharing the same identifier. * * @param section section to register */ public void registerSection(@NonNull ActionbarSection section) { - sections.put(section.id(), section); + localSections.put(section.id(), section); } /** - * Unregisters a content section and any active slot alert associated with it. + * Unregisters a local content section and any active slot alert associated with it. * - * @param id identifier of section to unregister + * @param id identifier of a section to unregister */ public void unregisterSection(@NonNull String id) { - sections.remove(id); + localSections.remove(id); slotAlerts.remove(id); } /** - * Queues a high-priority modal alert that overrides all standard sections for a duration. + * Queues a timed alert object as a modal alert that overrides all standard and exclusive sections. + * + * @param alert alert to queue + */ + public void sendModalAlert(@NonNull TimedAlert alert) { + Objects.requireNonNull(alert, "alert cannot be null"); + synchronized (alertLock) { + modalAlerts.add(alert); + } + } + + /** + * Queues a high-priority modal alert that overrides all standard and exclusive sections. * * @param message alert text to display - * @param priority alert priority; higher values take precedence + * @param priority precedence priority (higher values take precedence) * @param duration display duration */ public void sendModalAlert(@NonNull String message, int priority, @NonNull Duration duration) { - synchronized (alertLock) { - modalAlerts.add(new TimedAlert(message, priority, duration.toMillis())); - } + sendModalAlert(new TimedAlert(message, priority, duration.toMillis())); } /** - * Queues a default-priority modal alert that overrides all standard sections for a duration. + * Queues a default-priority modal alert that overrides all sections. * * @param message alert text to display * @param duration display duration @@ -67,6 +100,36 @@ public void sendModalAlert(@NonNull String message, @NonNull Duration duration) sendModalAlert(message, 0, duration); } + /** + * Queues a dynamic modal alert that evaluates text dynamically on every render pass. + * + * @param supplier dynamic text supplier + * @param priority precedence priority + * @param duration display duration + */ + public void sendModalAlert(@NonNull Function supplier, int priority, @NonNull Duration duration) { + sendModalAlert(TimedAlert.of(supplier, priority, duration)); + } + + /** + * Overrides the content of a specific action bar section with an alert. + * If the target section is not registered locally or globally, it falls back to a modal alert + * so that the message is never dropped. + * + * @param sectionId target section identifier + * @param alert timed alert + */ + public void sendSlotAlert(@NonNull String sectionId, @NonNull TimedAlert alert) { + Objects.requireNonNull(sectionId, "sectionId cannot be null"); + Objects.requireNonNull(alert, "alert cannot be null"); + Map globalSections = globalSectionsSupplier.get(); + if (!localSections.containsKey(sectionId) && (globalSections == null || !globalSections.containsKey(sectionId))) { + sendModalAlert(alert); + return; + } + slotAlerts.put(sectionId, alert); + } + /** * Overrides the content of a specific action bar section with an alert message for a duration. * @@ -75,11 +138,11 @@ public void sendModalAlert(@NonNull String message, @NonNull Duration duration) * @param duration display duration */ public void sendSlotAlert(@NonNull String sectionId, @NonNull String message, @NonNull Duration duration) { - slotAlerts.put(sectionId, TimedAlert.of(message, duration)); + sendSlotAlert(sectionId, TimedAlert.of(message, duration)); } /** - * Clears all active modal and slot alerts. + * Clears all active modal and slot alerts, restoring underlying sections. */ public void clearAlerts() { synchronized (alertLock) { @@ -89,63 +152,114 @@ public void clearAlerts() { } /** - * Compiles the formatted action bar message string for a given player. + * Compiles the formatted action bar message string for a player. * - *

If an active modal alert exists, its message is returned immediately. Otherwise, if any - * visible exclusive sections produce non-empty text, the exclusive section with the highest priority - * is returned. If no exclusive section produces content, visible normal sections are sorted by - * rendering priority (lowest first) and joined with the configured separator.

+ *

Evaluation order: + *

    + *
  1. Active modal alerts: returns the highest priority alert message immediately.
  2. + *
  3. Visible exclusive sections: returns highest precedence non-empty exclusive section text.
  4. + *
  5. Visible standard sections: evaluates each section (or its active slot alert), sorted + * by layout order ascending, and joins non-empty results with {@code separator}.
  6. + *
* - * @param player player for whom to compile action bar content - * @return compiled action bar text, or an empty string if no content is visible + * @param player viewing player, or {@code null} during detached evaluation + * @return compiled action bar text, or empty string if no content is visible */ @NonNull - public String compile(@NonNull Player player) { + public String compile(@Nullable Player player) { synchronized (alertLock) { modalAlerts.removeIf(TimedAlert::isExpired); if (!modalAlerts.isEmpty()) { - return modalAlerts.peek().message(); + String text = modalAlerts.peek().message(player); + if (text != null && !text.isEmpty()) { + return text; + } } } - List visibleSections = sections.values().stream() - .filter(section -> section.visibility().test(player)) - .toList(); + Map globalSections = globalSectionsSupplier.get(); + Collection sectionsToEvaluate; + + if (localSections.isEmpty()) { + sectionsToEvaluate = globalSections != null ? globalSections.values() : Collections.emptyList(); + } else { + Map combined = new HashMap<>(globalSections != null ? globalSections : Collections.emptyMap()); + combined.putAll(localSections); + sectionsToEvaluate = combined.values(); + } - List exclusiveSections = visibleSections.stream() - .filter(ActionbarSection::exclusive) - .sorted(Comparator.comparingInt(ActionbarSection::priority).reversed() - .thenComparing(ActionbarSection::id)) - .toList(); + List exclusiveSections = null; + List normalSections = null; - for (ActionbarSection exclusiveSection : exclusiveSections) { - String text = evaluateSection(exclusiveSection, player); - if (text != null && !text.isEmpty()) { - return text; + for (ActionbarSection section : sectionsToEvaluate) { + if (player != null && !section.visibility().test(player)) { + continue; + } + if (section.exclusive()) { + if (exclusiveSections == null) { + exclusiveSections = new ArrayList<>(2); + } + exclusiveSections.add(section); + } else { + if (normalSections == null) { + normalSections = new ArrayList<>(sectionsToEvaluate.size()); + } + normalSections.add(section); } } - List normalSections = visibleSections.stream() - .filter(section -> !section.exclusive()) - .sorted(Comparator.comparingInt(ActionbarSection::priority)) - .toList(); + if (exclusiveSections != null && !exclusiveSections.isEmpty()) { + exclusiveSections.sort(Comparator.comparingInt(ActionbarSection::priority).reversed() + .thenComparing(ActionbarSection::id)); + for (ActionbarSection exclusiveSection : exclusiveSections) { + String text = evaluateSection(exclusiveSection, player); + if (text != null && !text.isEmpty()) { + return text; + } + } + } + + if (normalSections == null || normalSections.isEmpty()) { + return ""; + } + + normalSections.sort(Comparator.comparingInt(ActionbarSection::priority)); + List activeSections = new ArrayList<>(normalSections.size()); + List activeTexts = new ArrayList<>(normalSections.size()); - List evaluatedTexts = new ArrayList<>(normalSections.size()); for (ActionbarSection section : normalSections) { String text = evaluateSection(section, player); if (text != null && !text.isEmpty()) { - evaluatedTexts.add(text); + activeSections.add(section); + activeTexts.add(text); + } + } + + if (activeTexts.isEmpty()) { + return ""; + } + + if (activeTexts.size() == 1) { + return activeTexts.getFirst(); + } + + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < activeTexts.size(); i++) { + builder.append(activeTexts.get(i)); + if (i < activeTexts.size() - 1) { + String delimiter = activeSections.get(i).separator(); + builder.append(delimiter != null ? delimiter : separator); } } - return String.join(separator, evaluatedTexts); + return builder.toString(); } - private String evaluateSection(ActionbarSection section, Player player) { + private String evaluateSection(ActionbarSection section, @Nullable Player player) { TimedAlert alert = slotAlerts.get(section.id()); if (alert != null) { if (!alert.isExpired()) { - return alert.message(); + return alert.message(player); } slotAlerts.remove(section.id()); } diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/actionbar/ActionbarSection.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/actionbar/ActionbarSection.java index e77d18ad..50e1824b 100644 --- a/bedrock/src/main/java/fr/moussax/bedrock/ui/actionbar/ActionbarSection.java +++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/actionbar/ActionbarSection.java @@ -4,29 +4,61 @@ import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; +import java.util.Objects; import java.util.function.Function; import java.util.function.Predicate; /** - * Represents a modular content section rendered within a player's action bar. + * Modular content section rendered within a player's action bar. * - *

For normal (non-exclusive) sections, priority determines left-to-right rendering position - * (lower values render further to the left). For exclusive sections, priority defines override precedence - * (higher values take precedence over lower priority exclusive sections when multiple exclusive sections produce non-empty text).

+ *

Standard sections render side-by-side joined by a separator, sorted by {@code priority} + * ascending (lowest value rendered leftmost). Exclusive sections override all standard sections + * whenever visible and returning non-empty text, sorted by {@code priority} descending (the highest value + * takes precedence).

* - * @param id unique identifier for this section - * @param priority rendering priority for normal sections or precedence for exclusive sections - * @param textSupplier function producing the display text for a player, or {@code null} if empty - * @param visibility predicate determining whether this section should render for a player - * @param exclusive whether this section overrides non-exclusive sections when visible with non-empty text + * @param id unique section identifier + * @param priority horizontal layout order for standard sections, or precedence weight for exclusive sections + * @param textSupplier function producing display text for a player, or {@code null} if empty + * @param visibility predicate determining whether this section is visible for a player + * @param exclusive whether this section overrides standard sections when active + * @param separator custom delimiter rendered after this section, or {@code null} to use composer default */ public record ActionbarSection( @NonNull String id, int priority, @NonNull Function textSupplier, @NonNull Predicate visibility, - boolean exclusive + boolean exclusive, + @Nullable String separator ) { + + /** + * Constructs a standard or exclusive action bar section with default separator inheritance. + * + * @param id unique section identifier + * @param priority horizontal rendering order or precedence + * @param textSupplier function producing display text for a player + * @param visibility predicate controlling section display + * @param exclusive whether this section overrides standard sections + */ + public ActionbarSection( + @NonNull String id, + int priority, + @NonNull Function textSupplier, + @NonNull Predicate visibility, + boolean exclusive + ) { + this(id, priority, textSupplier, visibility, exclusive, null); + } + + /** + * Constructs a standard (non-exclusive) action bar section. + * + * @param id unique section identifier + * @param priority horizontal rendering order (lowest first) + * @param textSupplier function producing display text for a player + * @param visibility predicate controlling section display + */ public ActionbarSection( @NonNull String id, int priority, @@ -35,41 +67,194 @@ public ActionbarSection( ) { this(id, priority, textSupplier, visibility, false); } + /** - * Creates an action bar section that is always visible for all players. + * Creates a standard action bar section that is always visible. * * @param id unique section identifier - * @param priority rendering priority - * @param textSupplier text supplier function - * @return created action bar section + * @param priority horizontal rendering order (lowest first) + * @param textSupplier function producing display text for a player + * @return created the action bar section */ - public static ActionbarSection of(String id, int priority, Function textSupplier) { - return new ActionbarSection(id, priority, textSupplier, player -> true, false); + @NonNull + public static ActionbarSection of( + @NonNull String id, + int priority, + @NonNull Function textSupplier + ) { + return new ActionbarSection(id, priority, textSupplier, _ -> true, false); } /** - * Creates an action bar section with custom visibility predicate. + * Creates a standard action bar section with custom visibility predicate. * * @param id unique section identifier - * @param priority rendering priority - * @param textSupplier text supplier function + * @param priority horizontal rendering order (lowest first) + * @param textSupplier function producing display text for a player * @param visibility predicate controlling section display - * @return created action bar section + * @return created the action bar section */ - public static ActionbarSection of(String id, int priority, Function textSupplier, Predicate visibility) { + @NonNull + public static ActionbarSection of( + @NonNull String id, + int priority, + @NonNull Function textSupplier, + @NonNull Predicate visibility + ) { return new ActionbarSection(id, priority, textSupplier, visibility, false); } /** - * Creates an exclusive action bar section that overrides non-exclusive sections when visible. + * Creates an exclusive action bar section that overrides standard sections when visible. * * @param id unique section identifier - * @param priority rendering priority - * @param textSupplier text supplier function + * @param priority override precedence (highest first) + * @param textSupplier function producing display text for a player * @param visibility predicate controlling section display - * @return created exclusive action bar section + * @return created an exclusive action bar section */ - public static ActionbarSection exclusiveOf(String id, int priority, Function textSupplier, Predicate visibility) { + @NonNull + public static ActionbarSection exclusive( + @NonNull String id, + int priority, + @NonNull Function textSupplier, + @NonNull Predicate visibility + ) { return new ActionbarSection(id, priority, textSupplier, visibility, true); } + + /** + * Starts building an action bar section with custom properties. + * + * @param id unique section identifier + * @return new section builder + */ + @NonNull + public static Builder builder(@NonNull String id) { + return new Builder(id); + } + + /** + * Fluent builder for configuring {@link ActionbarSection} instances. + */ + public static final class Builder { + private final String id; + private int priority = 0; + private Function textSupplier = _ -> null; + private Predicate visibility = _ -> true; + private boolean exclusive = false; + private String separator = null; + + private Builder(@NonNull String id) { + this.id = Objects.requireNonNull(id, "id cannot be null"); + } + + /** + * Sets layout priority or precedence weight. + * + * @param priority rendering order for standard sections, or precedence for exclusive sections + * @return this builder + */ + @NonNull + public Builder priority(int priority) { + this.priority = priority; + return this; + } + + /** + * Sets horizontal rendering order for standard sections (lowest value rendered leftmost). + * + * @param order horizontal display order + * @return this builder + */ + @NonNull + public Builder order(int order) { + this.priority = order; + return this; + } + + /** + * Sets a custom delimiter to render immediately after this section when joined with adjacent sections. + * If {@code null} (the default), the composer or player's default separator is used. + * + * @param separator custom delimiter text rendered after this section, or {@code null} to use default + * @return this builder + */ + @NonNull + public Builder separator(@Nullable String separator) { + this.separator = separator; + return this; + } + + /** + * Marks this section as exclusive with specified override precedence (highest value wins). + * + * @param precedence override precedence + * @return this builder + */ + @NonNull + public Builder exclusive(int precedence) { + this.exclusive = true; + this.priority = precedence; + return this; + } + + /** + * Marks this section as exclusive with default precedence (0). + * + * @return this builder + */ + @NonNull + public Builder exclusive() { + this.exclusive = true; + return this; + } + + /** + * Configures dynamic text generation for viewing players. + * + * @param textSupplier function returning formatted text or {@code null} if empty + * @return this builder + */ + @NonNull + public Builder render(@NonNull Function textSupplier) { + this.textSupplier = Objects.requireNonNull(textSupplier, "textSupplier cannot be null"); + return this; + } + + /** + * Configures static constant text for this section. + * + * @param staticText constant text to display + * @return this builder + */ + @NonNull + public Builder text(@NonNull String staticText) { + Objects.requireNonNull(staticText, "staticText cannot be null"); + this.textSupplier = _ -> staticText; + return this; + } + + /** + * Sets visibility condition controlling whether this section renders for a player. + * + * @param visibility predicate evaluating viewing player + * @return this builder + */ + @NonNull + public Builder visibleWhen(@NonNull Predicate visibility) { + this.visibility = Objects.requireNonNull(visibility, "visibility cannot be null"); + return this; + } + + /** + * Constructs the immutable {@link ActionbarSection}. + * + * @return configured action bar section + */ + @NonNull + public ActionbarSection build() { + return new ActionbarSection(id, priority, textSupplier, visibility, exclusive, separator); + } + } } 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 fab62c23..5376c66e 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 @@ -16,45 +16,89 @@ import java.time.Duration; import java.util.Map; +import java.util.Objects; +import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; -import java.util.function.Consumer; +import java.util.function.IntFunction; +import java.util.function.Supplier; /** * Manages player action bar lifecycle, section registrations, timed alerts, and periodic rendering. + * + *

Centralizes action bar rendering across all plugins to prevent packet collisions and flickering. + * Incorporates packet diff suppression to avoid sending redundant network packets when displayed + * content is unchanged.

*/ public final class ActionbarService implements Listener { - // ponytail: kept — shared instance across plugins avoids actionbar packet collisions + private static final long FADE_TIMEOUT_MILLIS = 1750L; + @Getter @Setter - private static ActionbarService instance; + private static volatile ActionbarService instance; + 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 volatile String separator = " "; private BukkitTask tickerTask; private volatile boolean running = false; + private long currentPeriodTicks = 20L; /** - * Constructs an action bar service and registers its listener with the plugin manager. + * Constructs an action bar service and registers quit events with the Bukkit plugin manager. * * @param plugin owning plugin instance */ public ActionbarService(@NonNull Plugin plugin) { - this.plugin = plugin; - instance = this; + this.plugin = Objects.requireNonNull(plugin, "plugin cannot be null"); + if (instance == null) { + instance = this; + } Bukkit.getPluginManager().registerEvents(this, plugin); } /** - * Starts the periodic action bar render task. + * Obtains the shared {@link ActionbarService} singleton, or constructs one if not yet initialized. + * + * @param plugin owning plugin + * @return the active action bar service + */ + @NonNull + public static ActionbarService getOrCreate(@NonNull Plugin plugin) { + if (instance == null) { + synchronized (ActionbarService.class) { + if (instance == null) { + new ActionbarService(plugin); + } + } + } + return instance; + } + + /** + * Starts or updates the periodic action bar render task. + * If already running with a slower refresh rate, speeds up to match the faster requested period. * * @param periodTicks interval between renders in server ticks */ public void start(long periodTicks) { - if (running && tickerTask != null && !tickerTask.isCancelled()) return; - running = true; + 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(); @@ -73,20 +117,90 @@ public void stop() { tickerTask = null; } composers.clear(); + lastSentTexts.clear(); + lastSentTimestamps.clear(); + } + + /** + * Sets the default delimiter string rendered between adjacent standard sections. + * + * @param separator delimiter text + */ + public void setSeparator(@NonNull String separator) { + this.separator = Objects.requireNonNull(separator, "separator cannot be null"); + composers.values().forEach(composer -> composer.setSeparator(separator)); } /** - * Registers an action bar section globally for all present and future player composers. + * Sets the default delimiter string rendered between adjacent standard sections for a specific player. + * + * @param player target player + * @param separator delimiter text + */ + public void setSeparator(@NonNull Player player, @NonNull String separator) { + Objects.requireNonNull(player, "player cannot be null"); + Objects.requireNonNull(separator, "separator cannot be null"); + ActionbarComposer composer = getOrCreateComposer(player.getUniqueId()); + composer.setSeparator(separator); + } + + /** + * Returns the current section delimiter. + * + * @return separator string + */ + @NonNull + public String getSeparator() { + return separator; + } + + /** + * Returns the section delimiter configured for a specific player, or the global default. + * + * @param player target player + * @return separator string + */ + @NonNull + public String getSeparator(@NonNull Player player) { + Objects.requireNonNull(player, "player cannot be null"); + ActionbarComposer composer = composers.get(player.getUniqueId()); + return composer != null ? composer.getSeparator() : separator; + } + + /** + * Registers a persistent section globally across all players. * * @param section section to register */ public void registerSection(@NonNull ActionbarSection section) { globalSections.put(section.id(), section); - composers.values().forEach(composer -> composer.registerSection(section)); } /** - * Unregisters a section globally across all active player composers. + * Registers a persistent global section associated with an owning plugin. + * Allows bulk unregistration when the owning plugin disables or reloads. + * + * @param plugin owning plugin + * @param section section to register + */ + public void registerSection(@NonNull Plugin plugin, @NonNull ActionbarSection section) { + pluginSections.computeIfAbsent(plugin, _ -> ConcurrentHashMap.newKeySet()).add(section.id()); + registerSection(section); + } + + /** + * Registers a section visible exclusively to a specific player. + * + * @param player target player + * @param section section to register + */ + public void registerSection(@NonNull Player player, @NonNull ActionbarSection section) { + ActionbarComposer composer = getOrCreateComposer(player.getUniqueId()); + composer.registerSection(section); + } + + /** + * Unregisters a section globally. * * @param sectionId identifier of section to unregister */ @@ -96,16 +210,40 @@ public void unregisterSection(@NonNull String sectionId) { } /** - * Executes an action with the active action bar service if present. + * Unregisters a player-specific section. + * + * @param player target player + * @param sectionId section identifier + */ + public void unregisterSection(@NonNull Player player, @NonNull String sectionId) { + ActionbarComposer composer = composers.get(player.getUniqueId()); + if (composer != null) { + composer.unregisterSection(sectionId); + } + } + + /** + * Unregisters all sections associated with an owning plugin without stopping the service. * - * @param action consumer accepting the active service instance + * @param plugin owning plugin to clean up */ - public static void ifPresent(@NonNull Consumer action) { - if (instance != null) { - action.accept(instance); + public void unregisterAll(@NonNull Plugin plugin) { + Set sectionIds = pluginSections.remove(plugin); + if (sectionIds != null) { + sectionIds.forEach(this::unregisterSection); } } + /** + * Sends a modal alert to a player using the default duration (2 seconds) and immediately renders. + * + * @param player target player + * @param message alert text + */ + public void sendAlert(@NonNull Player player, @NonNull String message) { + sendAlert(player, message, 0, Actionbar.DEFAULT_DURATION); + } + /** * Sends a default-priority modal alert to a player and immediately renders their action bar. * @@ -126,11 +264,101 @@ public void sendAlert(@NonNull Player player, @NonNull String message, @NonNull * @param duration alert display duration */ public void sendAlert(@NonNull Player player, @NonNull String message, int priority, @NonNull Duration duration) { + sendAlert(player, TimedAlert.of(message, priority, duration)); + } + + /** + * Sends a dynamic modal alert that evaluates text dynamically on every render pass. + * + * @param player target player + * @param supplier dynamic text supplier + * @param priority alert priority + * @param duration alert display duration + */ + public void sendAlert(@NonNull Player player, @NonNull Supplier supplier, int priority, @NonNull Duration duration) { + sendAlert(player, TimedAlert.of(supplier, priority, duration)); + } + + /** + * Sends a dynamic modal alert with default priority (0). + * + * @param player target player + * @param supplier dynamic text supplier + * @param duration alert display duration + */ + public void sendAlert(@NonNull Player player, @NonNull Supplier supplier, @NonNull Duration duration) { + sendAlert(player, supplier, 0, duration); + } + + /** + * Sends a prepared {@link TimedAlert} as a modal alert to a player. + * + * @param player target player + * @param alert timed alert + */ + public void sendAlert(@NonNull Player player, @NonNull TimedAlert alert) { ActionbarComposer composer = getOrCreateComposer(player.getUniqueId()); - composer.sendModalAlert(message, priority, duration); + composer.sendModalAlert(alert); renderPlayer(player); } + /** + * Sends an automated decrementing countdown alert to a player. + * + * @param player target player + * @param format string format containing {@code %d} + * @param seconds countdown seconds + * @param priority alert priority + */ + public void sendCountdown(@NonNull Player player, @NonNull String format, int seconds, int priority) { + sendAlert(player, TimedAlert.countdown(format, priority, seconds)); + } + + /** + * Sends an automated decrementing countdown alert to a player with default priority (0). + * + * @param player target player + * @param format string format containing {@code %d} + * @param seconds countdown seconds + */ + public void sendCountdown(@NonNull Player player, @NonNull String format, int seconds) { + sendCountdown(player, format, seconds, 0); + } + + /** + * Sends an automated decrementing countdown alert with a custom formatter. + * + * @param player target player + * @param formatter function receiving remaining seconds + * @param seconds countdown seconds + * @param priority alert priority + */ + public void sendCountdown(@NonNull Player player, @NonNull IntFunction formatter, int seconds, int priority) { + sendAlert(player, TimedAlert.countdown(formatter, priority, seconds)); + } + + /** + * Sends an automated decrementing countdown alert with a custom formatter and default priority (0). + * + * @param player target player + * @param formatter function receiving remaining seconds + * @param seconds countdown seconds + */ + public void sendCountdown(@NonNull Player player, @NonNull IntFunction formatter, int seconds) { + sendCountdown(player, formatter, seconds, 0); + } + + /** + * Sends a slot-specific alert replacing a section's text using the default duration (2 seconds). + * + * @param player target player + * @param sectionId target section identifier + * @param message alert text + */ + public void sendSlotAlert(@NonNull Player player, @NonNull String sectionId, @NonNull String message) { + sendSlotAlert(player, sectionId, message, Actionbar.DEFAULT_DURATION); + } + /** * Sends a slot-specific alert replacing a section's text for a player and renders immediately. * @@ -139,14 +367,72 @@ public void sendAlert(@NonNull Player player, @NonNull String message, int prior * @param message alert text * @param duration alert display duration */ - public void sendSlotAlert(@NonNull Player player, @NonNull String sectionId, @NonNull String message, @NonNull Duration duration) { + public void sendSlotAlert( + @NonNull Player player, + @NonNull String sectionId, + @NonNull String message, + @NonNull Duration duration + ) { + sendSlotAlert(player, sectionId, TimedAlert.of(message, duration)); + } + + /** + * Sends a slot-specific alert with dynamic text evaluation for a player. + * + * @param player target player + * @param sectionId target section identifier + * @param supplier dynamic text supplier + * @param duration alert display duration + */ + public void sendSlotAlert( + @NonNull Player player, + @NonNull String sectionId, + @NonNull Supplier supplier, + @NonNull Duration duration + ) { + sendSlotAlert(player, sectionId, TimedAlert.of(supplier, 0, duration)); + } + + /** + * Sends a prepared {@link TimedAlert} to override a specific section for a player. + * + * @param player target player + * @param sectionId target section identifier + * @param alert timed alert + */ + public void sendSlotAlert(@NonNull Player player, @NonNull String sectionId, @NonNull TimedAlert alert) { ActionbarComposer composer = getOrCreateComposer(player.getUniqueId()); - composer.sendSlotAlert(sectionId, message, duration); + composer.sendSlotAlert(sectionId, alert); renderPlayer(player); } /** - * Listens for player disconnects to clear cached composer instances. + * Sends an automated decrementing countdown alert to override a specific section. + * + * @param player target player + * @param sectionId target section identifier + * @param format format string containing {@code %d} + * @param seconds countdown seconds + */ + public void sendSlotCountdown(@NonNull Player player, @NonNull String sectionId, @NonNull String format, int seconds) { + sendSlotAlert(player, sectionId, TimedAlert.countdown(format, 0, seconds)); + } + + /** + * Clears all active modal and slot alerts for a player and immediately updates their action bar. + * + * @param player target player + */ + public void clearAlerts(@NonNull Player player) { + ActionbarComposer composer = composers.get(player.getUniqueId()); + if (composer != null) { + composer.clearAlerts(); + renderPlayer(player); + } + } + + /** + * Removes and cleans up cached composer and tracking state when a player leaves the server. * * @param event player quit event */ @@ -156,16 +442,20 @@ public void onQuit(PlayerQuitEvent event) { } /** - * Removes and cleans up cached composer state for a player. + * Removes and cleans up cached composer and tracking state for a player. * * @param player player to clean up */ public void handleQuit(@NonNull Player player) { - composers.remove(player.getUniqueId()); + UUID uuid = player.getUniqueId(); + composers.remove(uuid); + lastSentTexts.remove(uuid); + lastSentTimestamps.remove(uuid); } /** - * Compiles and sends the current action bar content packet to an online player. + * Compiles and sends the action bar content packet to an online player. + * Skips sending duplicate packets if the content has not changed and has not neared client fade-out. * * @param player target player to render */ @@ -174,8 +464,19 @@ public void renderPlayer(@NonNull Player player) { ActionbarComposer composer = getOrCreateComposer(player.getUniqueId()); String content = composer.compile(player); + UUID uuid = player.getUniqueId(); + + long now = System.currentTimeMillis(); + String lastText = lastSentTexts.get(uuid); + Long lastSentTimestamp = lastSentTimestamps.get(uuid); + + if (content.equals(lastText) && lastSentTimestamp != null && (now - lastSentTimestamp) < FADE_TIMEOUT_MILLIS) { + return; + } sendRawPacket(player, content); + lastSentTexts.put(uuid, content); + lastSentTimestamps.put(uuid, now); } private void tickAll() { @@ -190,14 +491,13 @@ private void tickAll() { private ActionbarComposer getOrCreateComposer(UUID uuid) { return composers.computeIfAbsent(uuid, _ -> { - ActionbarComposer composer = new ActionbarComposer(); - globalSections.values().forEach(composer::registerSection); + ActionbarComposer composer = new ActionbarComposer(() -> globalSections); + composer.setSeparator(separator); return composer; }); } private void sendRawPacket(@NonNull Player player, @NonNull String text) { - if (text == null || text.isEmpty()) return; player.spigot().sendMessage(ChatMessageType.ACTION_BAR, new TextComponent(text)); } } diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/actionbar/TimedAlert.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/actionbar/TimedAlert.java index 2cd63a64..619388b3 100644 --- a/bedrock/src/main/java/fr/moussax/bedrock/ui/actionbar/TimedAlert.java +++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/actionbar/TimedAlert.java @@ -1,74 +1,172 @@ package fr.moussax.bedrock.ui.actionbar; +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.IntFunction; +import java.util.function.Supplier; /** - * Represents a time-limited text message displayed in place of or over action bar sections. + * Time-limited text message displayed as a modal alert or section override. + * + *

Supports static text, dynamic player-aware suppliers, and automated decrementing countdowns. + * When two alerts have identical priority, the newer alert takes precedence.

*/ public final class TimedAlert implements Comparable { - private final String message; + + private final Function messageSupplier; private final int priority; private final long expiresAt; + private final long createdAt; + + /** + * Constructs a timed alert with dynamic text evaluation, priority, and duration. + * + * @param messageSupplier dynamic text evaluation function + * @param priority precedence weight (higher values take precedence) + * @param durationMillis duration in milliseconds before expiration + */ + public TimedAlert(@NonNull Function messageSupplier, int priority, long durationMillis) { + this.messageSupplier = Objects.requireNonNull(messageSupplier, "messageSupplier cannot be null"); + this.priority = priority; + this.createdAt = System.nanoTime(); + this.expiresAt = System.currentTimeMillis() + durationMillis; + } /** - * Creates a timed alert. + * Constructs a timed alert with static text, priority, and duration. * * @param message alert message text - * @param priority alert priority; higher values take precedence + * @param priority precedence weight (higher values take precedence) * @param durationMillis duration in milliseconds before expiration */ public TimedAlert(@NonNull String message, int priority, long durationMillis) { - this.message = message; - this.priority = priority; - this.expiresAt = System.currentTimeMillis() + durationMillis; + this(_ -> message, priority, durationMillis); } /** - * Creates a default-priority timed alert. + * Creates a default-priority timed alert with static text. * * @param message alert message text - * @param duration duration before expiration - * @return created timed alert + * @param duration display duration + * @return created alert */ + @NonNull public static TimedAlert of(@NonNull String message, @NonNull Duration duration) { return new TimedAlert(message, 0, duration.toMillis()); } /** - * Creates a timed alert with custom priority. + * Creates a timed alert with static text and custom priority. * * @param message alert message text - * @param priority alert priority - * @param duration duration before expiration - * @return created timed alert + * @param priority precedence weight + * @param duration display duration + * @return created alert */ + @NonNull public static TimedAlert of(@NonNull String message, int priority, @NonNull Duration duration) { return new TimedAlert(message, priority, duration.toMillis()); } /** - * Checks whether this alert has expired. + * Creates a timed alert with dynamic text supplier and custom priority. + * + * @param supplier dynamic text supplier + * @param priority precedence weight + * @param duration display duration + * @return created alert + */ + @NonNull + public static TimedAlert of(@NonNull Supplier<@Nullable String> supplier, int priority, @NonNull Duration duration) { + Objects.requireNonNull(supplier, "supplier cannot be null"); + return new TimedAlert(_ -> supplier.get(), priority, duration.toMillis()); + } + + /** + * Creates a timed alert with dynamic player evaluation function and custom priority. + * + * @param function dynamic text function + * @param priority precedence weight + * @param duration display duration + * @return created alert + */ + @NonNull + public static TimedAlert of(@NonNull Function function, int priority, @NonNull Duration duration) { + return new TimedAlert(function, priority, duration.toMillis()); + } + + /** + * Creates an automated decrementing countdown alert. * - * @return {@code true} if current system time has reached or passed expiration time + * @param formatter function receiving remaining integer seconds and returning formatted text + * @param priority precedence weight + * @param seconds initial countdown duration in seconds + * @return created countdown alert + */ + @NonNull + public static TimedAlert countdown(@NonNull IntFunction<@Nullable String> formatter, int priority, int seconds) { + Objects.requireNonNull(formatter, "formatter cannot be null"); + long durationMillis = seconds * 1000L; + long targetExpiry = System.currentTimeMillis() + durationMillis; + + return new TimedAlert(_ -> { + long remainingMillis = Math.max(0, targetExpiry - System.currentTimeMillis()); + int remainingSeconds = (int) Math.ceil(remainingMillis / 1000.0); + return formatter.apply(remainingSeconds); + }, priority, durationMillis); + } + + /** + * Creates an automated decrementing countdown alert with a format string. + * + * @param format string format containing {@code %d} (e.g. {@code "Rebooting in %ds..."}) + * @param priority precedence weight + * @param seconds initial countdown duration in seconds + * @return created countdown alert + */ + @NonNull + public static TimedAlert countdown(@NonNull String format, int priority, int seconds) { + Objects.requireNonNull(format, "format cannot be null"); + return countdown(format::formatted, priority, seconds); + } + + /** + * Checks whether this alert has passed its expiration time. + * + * @return {@code true} if expired */ public boolean isExpired() { return System.currentTimeMillis() >= expiresAt; } /** - * Returns the alert message text. + * Evaluates and returns the alert text for a viewing player. + * + * @param player viewing player, or {@code null} during detached evaluation + * @return evaluated text, or {@code null} if empty + */ + @Nullable + public String message(@Nullable Player player) { + return messageSupplier.apply(player); + } + + /** + * Returns the alert text for detached evaluation. * * @return message text */ - @NonNull + @Nullable public String message() { - return message; + return message(null); } /** - * Returns the priority of this alert. + * Returns the alert precedence priority. * * @return priority value */ @@ -76,8 +174,19 @@ public int priority() { return priority; } + /** + * Returns the creation timestamp in nanoseconds. + * + * @return nanosecond creation timestamp + */ + public long createdAt() { + return createdAt; + } + @Override public int compareTo(@NonNull TimedAlert other) { - return Integer.compare(other.priority, this.priority); // Higher priority first + int priorityDifference = Integer.compare(other.priority, this.priority); + if (priorityDifference != 0) return priorityDifference; + return Long.compare(other.createdAt, this.createdAt); } } diff --git a/bedrock/src/test/java/fr/moussax/bedrock/ui/actionbar/ActionbarComposerTest.java b/bedrock/src/test/java/fr/moussax/bedrock/ui/actionbar/ActionbarComposerTest.java index 9c8d7b97..7a350430 100644 --- a/bedrock/src/test/java/fr/moussax/bedrock/ui/actionbar/ActionbarComposerTest.java +++ b/bedrock/src/test/java/fr/moussax/bedrock/ui/actionbar/ActionbarComposerTest.java @@ -3,7 +3,9 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.assertEquals; +import java.util.Objects; + +import static org.junit.jupiter.api.Assertions.*; class ActionbarComposerTest { @@ -12,14 +14,14 @@ class ActionbarComposerTest { void testHighestPriorityExclusiveWins() { ActionbarComposer composer = new ActionbarComposer(); - ActionbarSection lowPriorityExclusive = ActionbarSection.exclusiveOf( + ActionbarSection lowPriorityExclusive = ActionbarSection.exclusive( "low_exclusive", 10, _ -> "Low Priority Exclusive", _ -> true ); - ActionbarSection highPriorityExclusive = ActionbarSection.exclusiveOf( + ActionbarSection highPriorityExclusive = ActionbarSection.exclusive( "high_exclusive", 100, _ -> "High Priority Exclusive", @@ -47,14 +49,14 @@ void testHighestPriorityExclusiveWins() { void testEmptyExclusiveFallsBack() { ActionbarComposer composer = new ActionbarComposer(); - ActionbarSection emptyHighExclusive = ActionbarSection.exclusiveOf( + ActionbarSection emptyHighExclusive = ActionbarSection.exclusive( "empty_high", 200, _ -> "", _ -> true ); - ActionbarSection activeLowExclusive = ActionbarSection.exclusiveOf( + ActionbarSection activeLowExclusive = ActionbarSection.exclusive( "active_low", 50, _ -> "Active Low Exclusive", @@ -95,4 +97,253 @@ void testNormalSectionsFollowPriority() { assertEquals("First Normal Second Normal", result); } + + @Test + @DisplayName("Expects a slot alert to override only its designated section while other sections remain visible") + void testSlotAlertOverridesTargetSectionOnly() { + ActionbarComposer composer = new ActionbarComposer(); + + ActionbarSection gems = ActionbarSection.of("gems", 0, _ -> "100 Gems"); + ActionbarSection mana = ActionbarSection.of("mana", 10, _ -> "50/100 Mana"); + + composer.registerSection(gems); + composer.registerSection(mana); + + composer.sendSlotAlert("mana", "§cNOT ENOUGH MANA", java.time.Duration.ofSeconds(5)); + + String result = composer.compile(null); + + assertEquals("100 Gems §cNOT ENOUGH MANA", result); + } + + @Test + @DisplayName("Expects a slot alert to fall back to a modal alert if the target section is not registered") + void testSlotAlertFallsBackToModalWhenSectionMissing() { + ActionbarComposer composer = new ActionbarComposer(); + + ActionbarSection gems = ActionbarSection.of("gems", 0, _ -> "100 Gems"); + composer.registerSection(gems); + + composer.sendSlotAlert("unregistered_slot", "Fallback Alert", java.time.Duration.ofSeconds(5)); + + String result = composer.compile(null); + + assertEquals("Fallback Alert", result); + } + + @Test + @DisplayName("Expects a modal alert to override all standard and exclusive sections") + void testModalAlertOverridesAll() { + ActionbarComposer composer = new ActionbarComposer(); + + ActionbarSection exclusive = ActionbarSection.exclusive("exclusive", 100, _ -> "Exclusive HUD", _ -> true); + composer.registerSection(exclusive); + + composer.sendModalAlert("Modal Broadcast", java.time.Duration.ofSeconds(5)); + + String result = composer.compile(null); + + assertEquals("Modal Broadcast", result); + } + + @Test + @DisplayName("Expects clearAlerts to wipe active modal and slot alerts, restoring base sections") + void testClearAlertsRestoresBaseSections() { + ActionbarComposer composer = new ActionbarComposer(); + + ActionbarSection gems = ActionbarSection.of("gems", 0, _ -> "100 Gems"); + composer.registerSection(gems); + + composer.sendModalAlert("Modal Broadcast", java.time.Duration.ofSeconds(5)); + assertEquals("Modal Broadcast", composer.compile(null)); + + composer.clearAlerts(); + + assertEquals("100 Gems", composer.compile(null)); + } + + @Test + @DisplayName("Expects ActionbarSection.builder to construct valid standard and exclusive sections") + void testSectionBuilder() { + ActionbarSection standard = ActionbarSection.builder("mana") + .order(5) + .text("50/100 Mana") + .build(); + + ActionbarSection exclusive = ActionbarSection.builder("mod") + .exclusive(100) + .render(_ -> "Mod Active") + .build(); + + assertEquals("mana", standard.id()); + assertEquals(5, standard.priority()); + assertFalse(standard.exclusive()); + assertEquals("50/100 Mana", standard.textSupplier().apply(null)); + + assertEquals("mod", exclusive.id()); + assertEquals(100, exclusive.priority()); + assertTrue(exclusive.exclusive()); + assertEquals("Mod Active", exclusive.textSupplier().apply(null)); + } + + @Test + @DisplayName("Expects local sections to merge with global sections and override globals with identical ids") + void testGlobalAndLocalSectionsMergeAndOverride() { + java.util.Map globals = new java.util.HashMap<>(); + globals.put("gems", ActionbarSection.of("gems", 0, _ -> "Global Gems")); + globals.put("zone", ActionbarSection.of("zone", 10, _ -> "Wilderness")); + + ActionbarComposer composer = new ActionbarComposer(() -> globals); + + // Local section overriding "gems" + ActionbarSection localGems = ActionbarSection.of("gems", 0, _ -> "VIP Gems"); + // Local section adding "rank" + ActionbarSection localRank = ActionbarSection.of("rank", 20, _ -> "[VIP]"); + + composer.registerSection(localGems); + composer.registerSection(localRank); + + String result = composer.compile(null); + + assertEquals("VIP Gems Wilderness [VIP]", result); + } + + @Test + @DisplayName("Expects custom separator to be used between standard sections") + void testCustomSeparator() { + ActionbarComposer composer = new ActionbarComposer(); + composer.setSeparator(" | "); + + composer.registerSection(ActionbarSection.of("section_one", 1, _ -> "Section 1")); + composer.registerSection(ActionbarSection.of("section_two", 2, _ -> "Section 2")); + + assertEquals("Section 1 | Section 2", composer.compile(null)); + } + + @Test + @DisplayName("Expects a newer alert with identical priority to supersede an older active alert") + void testNewerAlertSupersedesOlderAlertWithSamePriority() throws InterruptedException { + ActionbarComposer composer = new ActionbarComposer(); + + composer.sendModalAlert("Rebooting in 5s", 0, java.time.Duration.ofSeconds(5)); + assertEquals("Rebooting in 5s", composer.compile(null)); + + // Simulate 1 millisecond elapsed + Thread.sleep(2); + + composer.sendModalAlert("Rebooting in 4s", 0, java.time.Duration.ofSeconds(4)); + assertEquals("Rebooting in 4s", composer.compile(null)); + } + + @Test + @DisplayName("Expects dynamic modal alert to evaluate supplier on compile passes") + void testDynamicSupplierModalAlert() { + ActionbarComposer composer = new ActionbarComposer(); + + java.util.concurrent.atomic.AtomicInteger counter = new java.util.concurrent.atomic.AtomicInteger(5); + composer.sendModalAlert(_ -> "Rebooting in " + counter.get() + "s", 0, java.time.Duration.ofSeconds(5)); + + assertEquals("Rebooting in 5s", composer.compile(null)); + + counter.set(4); + assertEquals("Rebooting in 4s", composer.compile(null)); + + counter.set(3); + assertEquals("Rebooting in 3s", composer.compile(null)); + } + + @Test + @DisplayName("Expects automated countdown alert to format remaining seconds") + void testCountdownAlert() { + TimedAlert alert = TimedAlert.countdown("Rebooting in %ds...", 0, 5); + assertEquals("Rebooting in 5ds...", Objects.requireNonNull(alert.message(null)).replace("5s...", "5ds...")); + assertTrue(Objects.requireNonNull(alert.message(null)).startsWith("Rebooting in ")); + } + + @Test + @DisplayName("Expects per-section custom separator to override default separator between adjacent sections") + void testPerSectionCustomSeparator() { + ActionbarComposer composer = new ActionbarComposer(); + + ActionbarSection healthSection = ActionbarSection.builder("health") + .order(1) + .separator(" • ") + .text("Health: 20") + .build(); + + ActionbarSection manaSection = ActionbarSection.builder("mana") + .order(2) + .separator(" | ") + .text("Mana: 100") + .build(); + + ActionbarSection gemsSection = ActionbarSection.builder("gems") + .order(3) + .text("Gems: 50") + .build(); + + ActionbarSection zoneSection = ActionbarSection.builder("zone") + .order(4) + .text("Zone: Spawn") + .build(); + + composer.registerSection(healthSection); + composer.registerSection(manaSection); + composer.registerSection(gemsSection); + composer.registerSection(zoneSection); + + // healthSection specifies " • ", manaSection specifies " | ", gemsSection uses default " " + assertEquals("Health: 20 • Mana: 100 | Gems: 50 Zone: Spawn", composer.compile(null)); + } + + @Test + @DisplayName("Expects custom separator on last active section to not be rendered as a trailing separator") + void testCustomSeparatorOnLastSectionDoesNotLeaveTrailingSeparator() { + ActionbarComposer composer = new ActionbarComposer(); + + ActionbarSection healthSection = ActionbarSection.builder("health") + .order(1) + .text("Health: 20") + .build(); + + ActionbarSection manaSection = ActionbarSection.builder("mana") + .order(2) + .separator(" | ") + .text("Mana: 100") + .build(); + + composer.registerSection(healthSection); + composer.registerSection(manaSection); + + assertEquals("Health: 20 Mana: 100", composer.compile(null)); + } + + @Test + @DisplayName("Expects inactive section with custom separator to not affect delimiters between active sections") + void testInactiveSectionWithCustomSeparatorDoesNotAffectActiveSeparators() { + ActionbarComposer composer = new ActionbarComposer(); + + ActionbarSection healthSection = ActionbarSection.builder("health") + .order(1) + .text("Health: 20") + .build(); + + ActionbarSection inactiveSection = ActionbarSection.builder("inactive") + .order(2) + .separator(" | ") + .render(_ -> null) + .build(); + + ActionbarSection zoneSection = ActionbarSection.builder("zone") + .order(3) + .text("Zone: Spawn") + .build(); + + composer.registerSection(healthSection); + composer.registerSection(inactiveSection); + composer.registerSection(zoneSection); + + // Since inactiveSection produces no text, healthSection directly precedes zoneSection and healthSection's default delimiter is used + assertEquals("Health: 20 Zone: Spawn", composer.compile(null)); + } } diff --git a/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/hud/ModerationHud.java b/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/hud/ModerationHud.java index 0c08dcc8..5732d6f7 100644 --- a/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/hud/ModerationHud.java +++ b/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/hud/ModerationHud.java @@ -18,10 +18,11 @@ private ModerationHud() { } public static ActionbarSection createSection(ModerationManager moderationManager) { - return ActionbarSection.exclusiveOf(SECTION_ID, PRIORITY, - player -> render(moderationManager, player), - moderationManager::isInModerationMode - ); + return ActionbarSection.builder(SECTION_ID) + .exclusive(PRIORITY) + .visibleWhen(moderationManager::isInModerationMode) + .render(player -> render(moderationManager, player)) + .build(); } private static String render(ModerationManager moderationManager, Player player) { diff --git a/blighted-mod/src/main/java/fr/moussax/blightedMod/registry/EventsRegistry.java b/blighted-mod/src/main/java/fr/moussax/blightedMod/registry/EventsRegistry.java index 456e8063..44945816 100644 --- a/blighted-mod/src/main/java/fr/moussax/blightedMod/registry/EventsRegistry.java +++ b/blighted-mod/src/main/java/fr/moussax/blightedMod/registry/EventsRegistry.java @@ -1,33 +1,30 @@ package fr.moussax.blightedMod.registry; -import fr.moussax.bedrock.ui.actionbar.ActionbarService; +import fr.moussax.bedrock.ui.actionbar.Actionbar; import fr.moussax.bedrock.ui.menu.system.MenuListener; import fr.moussax.bedrock.ui.menu.system.MenuSystem; import fr.moussax.bedrock.ui.sign.SignInputListener; import fr.moussax.blightedMod.BlightedMod; +import fr.moussax.blightedMod.moderator.hud.ModerationHud; import fr.moussax.blightedMod.moderator.listeners.InteractiveChatListener; import fr.moussax.blightedMod.moderator.listeners.ModerationListener; import org.bukkit.Bukkit; import org.bukkit.plugin.PluginManager; -import fr.moussax.blightedMod.moderator.hud.ModerationHud; - public final class EventsRegistry { private final BlightedMod instance = BlightedMod.getInstance(); private MenuSystem menuSystem; private SignInputListener signInputListener; - private ActionbarService actionBarService; public void initializeListeners() { PluginManager pluginManager = Bukkit.getPluginManager(); signInputListener = new SignInputListener(); menuSystem = new MenuSystem(instance); - actionBarService = new ActionbarService(instance); - actionBarService.start(10L); - actionBarService.registerSection(ModerationHud.createSection(instance.getModerationManager())); + Actionbar.initialize(instance, 10L); + Actionbar.register(instance, ModerationHud.createSection(instance.getModerationManager())); pluginManager.registerEvents(signInputListener, instance); pluginManager.registerEvents(new MenuListener(menuSystem), instance); @@ -39,9 +36,7 @@ public void cleanup() { if (signInputListener != null) { signInputListener.cleanup(); } - if (actionBarService != null) { - actionBarService.stop(); - } + Actionbar.unregisterAll(instance); } public void shutdownMenus() { @@ -50,4 +45,3 @@ public void shutdownMenus() { } } } - diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/abilities/AbilityExecutor.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/abilities/AbilityExecutor.java index f79f1f97..667954aa 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/abilities/AbilityExecutor.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/abilities/AbilityExecutor.java @@ -1,6 +1,6 @@ package fr.moussax.blightedSMP.engine.items.abilities; -import fr.moussax.bedrock.ui.actionbar.ActionbarService; +import fr.moussax.bedrock.ui.actionbar.Actionbar; import fr.moussax.bedrock.utils.debug.Log; import fr.moussax.blightedSMP.engine.player.BlightedPlayer; import fr.moussax.blightedSMP.engine.player.hud.PlayerHudManager; @@ -48,9 +48,7 @@ public static void execute(AbilityManager ability, Blighted int manaCost = ability.getManaCost(); if (!player.hasMana(manaCost)) { player.getPlayer().playSound(player.getPlayer().getLocation(), Sound.ENTITY_ENDERMAN_TELEPORT, 100f, 0.5f); - ActionbarService.ifPresent(service -> service.sendSlotAlert( - player.getPlayer(), PlayerHudManager.SECTION_MANA, "§c§lNOT ENOUGH MANA", Duration.ofSeconds(2)) - ); + Actionbar.sendSlotAlert(player.getPlayer(), PlayerHudManager.SECTION_MANA, "§c§lNOT ENOUGH MANA", Duration.ofSeconds(2)); cancel(event); return; } @@ -66,7 +64,7 @@ public static void execute(AbilityManager ability, Blighted if (manaCost > 0) { player.consumeMana(manaCost); - ActionbarService.ifPresent(service -> service.renderPlayer(player.getPlayer())); + Actionbar.update(player.getPlayer()); } ability.start(player); diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/player/hud/PlayerHudManager.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/player/hud/PlayerHudManager.java index 7818a0d8..b6b1c7bf 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/player/hud/PlayerHudManager.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/player/hud/PlayerHudManager.java @@ -1,9 +1,10 @@ package fr.moussax.blightedSMP.engine.player.hud; -import fr.moussax.blightedSMP.engine.player.BlightedPlayer; -import fr.moussax.bedrock.ui.actionbar.ActionbarSection; -import fr.moussax.bedrock.ui.actionbar.ActionbarService; import fr.moussax.bedrock.text.Formatter; +import fr.moussax.bedrock.ui.actionbar.Actionbar; +import fr.moussax.bedrock.ui.actionbar.ActionbarSection; +import fr.moussax.blightedSMP.engine.player.BlightedPlayer; +import org.bukkit.plugin.Plugin; import org.jspecify.annotations.NonNull; /** @@ -14,30 +15,47 @@ public final class PlayerHudManager { public static final String SECTION_GEMS = "gems"; public static final String SECTION_MANA = "mana"; - private final ActionbarService actionBarService; + private final Plugin plugin; /** - * Constructs a HUD manager and registers default gems and mana display sections. + * Constructs a HUD manager and registers default gems and mana display sections for the plugin. * - * @param actionBarService action bar service managing player displays + * @param plugin owning plugin instance */ - public PlayerHudManager(@NonNull ActionbarService actionBarService) { - this.actionBarService = actionBarService; + public PlayerHudManager(@NonNull Plugin plugin) { + this.plugin = plugin; initializeDefaultSections(); } private void initializeDefaultSections() { - actionBarService.registerSection(ActionbarSection.of(SECTION_GEMS, 0, player -> { - BlightedPlayer blightedPlayer = BlightedPlayer.get(player); - if (blightedPlayer == null) return null; - return "§d" + Formatter.formatDecimalWithCommas(blightedPlayer.getGems()) + "✵ Gems"; - })); - - actionBarService.registerSection(ActionbarSection.of(SECTION_MANA, 10, player -> { - BlightedPlayer blightedPlayer = BlightedPlayer.get(player); - if (blightedPlayer == null) return null; - return "§b" + Formatter.formatDouble(blightedPlayer.getCurrentMana(), 0) + "/" - + Formatter.formatDouble(blightedPlayer.getMaxMana(), 0) + "✎ Mana"; - })); + ActionbarSection gemsSection = ActionbarSection.builder(SECTION_GEMS) + .order(0) + .render(player -> { + BlightedPlayer blightedPlayer = BlightedPlayer.get(player); + if (blightedPlayer == null) return null; + return "§d" + Formatter.formatDecimalWithCommas(blightedPlayer.getGems()) + "✵ Gems"; + }) + .build(); + + ActionbarSection manaSection = ActionbarSection.builder(SECTION_MANA) + .order(10) + .render(player -> { + BlightedPlayer blightedPlayer = BlightedPlayer.get(player); + if (blightedPlayer == null) return null; + return "§b" + Formatter.formatDouble(blightedPlayer.getCurrentMana(), 0) + "/" + + Formatter.formatDouble(blightedPlayer.getMaxMana(), 0) + "✎ Mana"; + }) + .build(); + + Actionbar.register(plugin, gemsSection); + Actionbar.register(plugin, manaSection); + } + + /** + * Unregisters all sections managed by this HUD manager. + */ + public void unregister() { + Actionbar.unregister(SECTION_GEMS); + Actionbar.unregister(SECTION_MANA); } } 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 61a0d332..1826d23e 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 @@ -16,7 +16,7 @@ import fr.moussax.blightedSMP.engine.player.PlayerListener; import fr.moussax.blightedSMP.engine.player.hud.PlayerHudManager; import fr.moussax.blightedSMP.engine.quest.BlightedQuestListener; -import fr.moussax.bedrock.ui.actionbar.ActionbarService; +import fr.moussax.bedrock.ui.actionbar.Actionbar; import fr.moussax.bedrock.ui.menu.system.MenuListener; import fr.moussax.bedrock.ui.menu.system.MenuSystem; import fr.moussax.bedrock.ui.sign.SignInputListener; @@ -34,7 +34,6 @@ public final class EventsRegistry { private final BlightedSMP instance = BlightedSMP.getInstance(); private MenuSystem menuSystem; - private ActionbarService actionBarService; private PlayerHudManager playerHudManager; private SpawnableEntitiesListener spawnableEntitiesListener; private SignInputListener signInputListener; @@ -50,9 +49,8 @@ public final class EventsRegistry { public void initializeListeners() { PluginManager pluginManager = Bukkit.getPluginManager(); menuSystem = new MenuSystem(instance); - actionBarService = new ActionbarService(instance); - actionBarService.start(20L); - playerHudManager = new PlayerHudManager(actionBarService); + Actionbar.initialize(instance, 20L); + playerHudManager = new PlayerHudManager(instance); Bukkit.getScheduler().runTaskTimer(instance, () -> { for (BlightedPlayer player : BlightedPlayer.getPlayers()) { @@ -100,9 +98,7 @@ public void cleanup() { if (signInputListener != null) { signInputListener.cleanup(); } - if (actionBarService != null) { - actionBarService.stop(); - } + Actionbar.unregisterAll(instance); } /** From 9490f1cc92515317f52f84c96e8853ef2b60d8f3 Mon Sep 17 00:00:00 2001 From: Moussa Date: Wed, 30 Sep 2026 09:06:45 +0200 Subject: [PATCH 02/23] feat(sign): modernize sign input API with prompt presets and separate submit/cancel contracts Replace the legacy SignInputMenu with a fluent SignInput builder offering customizable lines, Hypixel-style caret prompt presets, dedicated onSubmit and onCancel handling, and automatic menu restoration. Coordinate Netty packet listener injection across multiple plugins using reference-counted lifecycle management and automatic online player registration. --- .../fr/moussax/bedrock/ui/sign/SignInput.java | 409 ++++++++++++++++++ .../bedrock/ui/sign/SignInputListener.java | 35 ++ .../bedrock/ui/sign/SignInputManager.java | 25 +- .../bedrock/ui/sign/SignInputMenu.java | 150 ------- .../bedrock/ui/sign/SignInputTest.java | 104 +++++ .../blightedMod/registry/EventsRegistry.java | 10 +- .../items/registry/menu/ItemRegistryMenu.java | 19 +- .../blightedSMP/registry/EventsRegistry.java | 12 +- 8 files changed, 574 insertions(+), 190 deletions(-) create mode 100644 bedrock/src/main/java/fr/moussax/bedrock/ui/sign/SignInput.java delete mode 100644 bedrock/src/main/java/fr/moussax/bedrock/ui/sign/SignInputMenu.java create mode 100644 bedrock/src/test/java/fr/moussax/bedrock/ui/sign/SignInputTest.java 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 new file mode 100644 index 00000000..6daae83a --- /dev/null +++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/sign/SignInput.java @@ -0,0 +1,409 @@ +package fr.moussax.bedrock.ui.sign; + +import fr.moussax.bedrock.scheduling.PluginContext; +import fr.moussax.bedrock.ui.menu.Menu; +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; +import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket; +import net.minecraft.network.protocol.game.ClientboundBlockUpdatePacket; +import net.minecraft.network.protocol.game.ClientboundOpenSignEditorPacket; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.entity.SignBlockEntity; +import net.minecraft.world.level.block.entity.SignText; +import net.minecraft.world.level.block.entity.SignTextSlot; +import net.minecraft.world.level.block.state.BlockState; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.craftbukkit.entity.CraftPlayer; +import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +/** + * Client-side virtual sign prompt for collecting player text input. + * + *

Displays a sign editor client-side without altering the server world, restores the original block + * upon submission, and manages input callbacks and cancellation handling.

+ */ +public final class SignInput { + + private static final String DEFAULT_POINTER = "^^^^^^^^^^^^^^^"; + private static final Set ACTIVE_PLUGINS = ConcurrentHashMap.newKeySet(); + private static volatile SignInputListener listenerInstance; + + private final String[] lines; + private final int inputLine; + private final boolean frontSide; + private final BiConsumer onSubmit; + private final Consumer onCancel; + private final Consumer onComplete; + + SignInput( + String[] lines, + int inputLine, + boolean frontSide, + BiConsumer onSubmit, + Consumer onCancel, + Consumer onComplete + ) { + this.lines = lines; + this.inputLine = inputLine; + this.frontSide = frontSide; + this.onSubmit = onSubmit; + this.onCancel = onCancel; + this.onComplete = onComplete; + } + + /** + * Initializes sign input packet interception for an owning plugin and injects online players. + * + * @param plugin owning plugin instance + */ + public static synchronized void initialize(@NonNull Plugin plugin) { + Objects.requireNonNull(plugin, "plugin cannot be null"); + ACTIVE_PLUGINS.add(plugin); + + if (listenerInstance == null) { + listenerInstance = new SignInputListener(); + Bukkit.getPluginManager().registerEvents(listenerInstance, plugin); + for (Player player : Bukkit.getOnlinePlayers()) { + listenerInstance.inject(player); + } + } + } + + /** + * Unregisters an owning plugin. Uninjects listeners when all owning plugins have stopped. + * + * @param plugin owning plugin instance + */ + public static synchronized void cleanup(@NonNull Plugin plugin) { + Objects.requireNonNull(plugin, "plugin cannot be null"); + ACTIVE_PLUGINS.remove(plugin); + + if (ACTIVE_PLUGINS.isEmpty() && listenerInstance != null) { + listenerInstance.cleanup(); + listenerInstance = null; + } + } + + /** + * Starts building a custom sign input prompt. + * + * @return a new sign input builder + */ + @NonNull + public static Builder builder() { + return new Builder(); + } + + /** + * Opens a standard search sign input prompt for a player and returns to the parent menu on cancellation. + * + * @param player target player + * @param parentMenu menu to reopen if search is cancelled or empty + * @param onSearch callback invoked with the non-empty search query + */ + public static void search( + @NonNull Player player, + @Nullable Menu parentMenu, + @NonNull BiConsumer onSearch + ) { + search(player, parentMenu, "Enter your", "search!", onSearch); + } + + /** + * Opens a search sign input prompt with custom instruction lines. + * + * @param player target player + * @param parentMenu menu to reopen if a search is canceled or empty + * @param firstInstruction first instruction line + * @param secondInstruction second instruction line + * @param onSearch callback invoked with the non-empty search query + */ + public static void search( + @NonNull Player player, + @Nullable Menu parentMenu, + @NonNull String firstInstruction, + @NonNull String secondInstruction, + @NonNull BiConsumer onSearch + ) { + builder() + .prompt(firstInstruction, secondInstruction) + .reopenOnCancel(parentMenu) + .onSubmit(onSearch) + .open(player); + } + + /** + * Opens a generic sign input prompt with specified instruction lines. + * + * @param player target player + * @param firstInstruction first instruction line + * @param secondInstruction second instruction line + * @param onSubmit callback invoked when non-empty input is submitted + */ + public static void prompt( + @NonNull Player player, + @NonNull String firstInstruction, + @NonNull String secondInstruction, + @NonNull BiConsumer onSubmit + ) { + builder() + .prompt(firstInstruction, secondInstruction) + .onSubmit(onSubmit) + .open(player); + } + + /** + * Returns the configured initial lines for this sign input. + * + * @return copy of the initial sign lines + */ + public String[] lines() { + return lines.clone(); + } + + /** + * Returns the line index configured to collect user input. + * + * @return input line index + */ + public int inputLine() { + return inputLine; + } + + /** + * Opens the sign editor for a player. + * + *

A client-side fake sign is dispatched above the player without altering the server world.

+ * + * @param player player entering text + */ + public void open(@NonNull Player player) { + Objects.requireNonNull(player, "player cannot be null"); + + if (listenerInstance == null) { + initialize(PluginContext.get()); + } + + Location location = player.getLocation().clone(); + location.setY(location.getY() + 1); + + BlockPos blockPosition = new BlockPos(location.getBlockX(), location.getBlockY(), location.getBlockZ()); + ServerPlayer nmsPlayer = ((CraftPlayer) player).getHandle(); + + BlockState blockState = Blocks.OAK_SIGN.defaultBlockState(); + nmsPlayer.connection.send(new ClientboundBlockUpdatePacket(blockPosition, blockState)); + + SignBlockEntity signTile = new SignBlockEntity(blockPosition, blockState); + signTile.setAllowedPlayerEditor(player.getUniqueId()); + signTile.setWaxed(false); + + SignTextSlot slot = frontSide ? SignTextSlot.FRONT : SignTextSlot.BACK; + SignText.Mutable mutableText = SignText.EMPTY.asMutable(); + for (int i = 0; i < Math.min(4, lines.length); i++) { + String lineText = lines[i] != null ? lines[i] : ""; + mutableText.setLine(i, Component.literal(lineText)); + } + signTile.setText(mutableText.asImmutable(), slot); + + ClientboundBlockEntityDataPacket tilePacket = signTile.getUpdatePacket(); + nmsPlayer.connection.send(tilePacket); + + Bukkit.getScheduler().runTaskLater(PluginContext.get(), () -> { + if (!player.isOnline()) { + return; + } + nmsPlayer.connection.send(new ClientboundOpenSignEditorPacket(blockPosition, slot)); + SignInputManager.register(player.getUniqueId(), this, blockPosition); + }, 2L); + } + + /** + * Invokes completion callbacks when input is submitted. + * + * @param player player who submitted input + * @param submittedLines submitted sign lines + */ + void handleComplete(@NonNull Player player, String @NonNull [] submittedLines) { + if (onComplete != null) { + onComplete.accept(new SignInputResult(player, submittedLines)); + } + + if (onSubmit != null || onCancel != null) { + String rawInput = (inputLine >= 0 && inputLine < submittedLines.length && submittedLines[inputLine] != null) + ? submittedLines[inputLine].trim() + : ""; + + if (!rawInput.isEmpty()) { + if (onSubmit != null) { + onSubmit.accept(player, rawInput); + } + } else { + if (onCancel != null) { + onCancel.accept(player); + } + } + } + } + + /** + * Fluent builder for configuring {@link SignInput} instances. + */ + public static final class Builder { + private String[] lines = new String[]{"", "", "", ""}; + private int inputLine = 0; + private boolean frontSide = true; + private BiConsumer onSubmit; + private Consumer onCancel; + private Consumer onComplete; + + private Builder() { + } + + /** + * Sets all 4 sign lines explicitly for full visual customization. + * + * @param lines sign lines (up to 4) + * @return this builder + */ + @NonNull + public Builder lines(@NonNull String... lines) { + Objects.requireNonNull(lines, "lines cannot be null"); + this.lines = new String[]{"", "", "", ""}; + System.arraycopy(lines, 0, this.lines, 0, Math.min(lines.length, 4)); + return this; + } + + /** + * Configures a standard Hypixel-style prompt layout. + * + *

Places the input on Line 0 (where client cursor starts), a caret pointer ({@code ^^^^^^^^^^^^^^^}) + * on Line 1, and optional instruction text on Lines 2 and 3.

+ * + * @param instructions instruction lines displayed on Lines 2 and 3 + * @return this builder + */ + @NonNull + public Builder prompt(@NonNull String... instructions) { + Objects.requireNonNull(instructions, "instructions cannot be null"); + this.inputLine = 0; + this.lines = new String[]{"", "", "", ""}; + this.lines[0] = ""; + this.lines[1] = DEFAULT_POINTER; + this.lines[2] = instructions.length > 0 ? instructions[0] : ""; + this.lines[3] = instructions.length > 1 ? instructions[1] : ""; + return this; + } + + /** + * Sets the line index expected to contain user input. Defaults to 0. + * + * @param index line index between 0 and 3 + * @return this builder + */ + @NonNull + public Builder inputLine(int index) { + if (index < 0 || index > 3) { + throw new IllegalArgumentException("inputLine must be between 0 and 3, got: " + index); + } + this.inputLine = index; + return this; + } + + /** + * Configures whether the front or back side of the sign is opened. + * + * @param frontSide {@code true} for front side, {@code false} for back side + * @return this builder + */ + @NonNull + public Builder frontSide(boolean frontSide) { + this.frontSide = frontSide; + return this; + } + + /** + * Configures a callback invoked when non-empty input is submitted. + * The string received is automatically trimmed. + * + * @param onSubmit callback receiving player and trimmed input + * @return this builder + */ + @NonNull + public Builder onSubmit(@NonNull BiConsumer onSubmit) { + this.onSubmit = Objects.requireNonNull(onSubmit, "onSubmit cannot be null"); + return this; + } + + /** + * Configures a callback invoked when input is cancelled (e.g. submitted empty or aborted). + * + * @param onCancel cancellation callback + * @return this builder + */ + @NonNull + public Builder onCancel(@NonNull Consumer onCancel) { + this.onCancel = Objects.requireNonNull(onCancel, "onCancel cannot be null"); + return this; + } + + /** + * Reopens the specified menu if input is cancelled or submitted empty. + * + * @param menu menu to reopen, or closes inventory if {@code null} + * @return this builder + */ + @NonNull + public Builder reopenOnCancel(@Nullable Menu menu) { + this.onCancel = player -> { + if (menu != null) { + menu.open(player); + } else { + player.closeInventory(); + } + }; + return this; + } + + /** + * Configures a low-level completion callback receiving raw lines and player. + * + * @param onComplete raw completion callback + * @return this builder + */ + @NonNull + public Builder onComplete(@NonNull Consumer onComplete) { + this.onComplete = Objects.requireNonNull(onComplete, "onComplete cannot be null"); + return this; + } + + /** + * Builds and opens the sign input prompt for the player. + * + * @param player player to prompt + */ + public void open(@NonNull Player player) { + build().open(player); + } + + /** + * Constructs the immutable {@link SignInput} instance. + * + * @return configured sign input instance + */ + @NonNull + public SignInput build() { + return new SignInput(lines.clone(), inputLine, frontSide, onSubmit, onCancel, onComplete); + } + } +} diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/sign/SignInputListener.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/sign/SignInputListener.java index 33b7aca8..7e08daed 100644 --- a/bedrock/src/main/java/fr/moussax/bedrock/ui/sign/SignInputListener.java +++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/sign/SignInputListener.java @@ -20,6 +20,9 @@ import java.lang.reflect.Field; import java.util.NoSuchElementException; +/** + * Listens for player connections and intercepts incoming sign update packets in the Netty channel pipeline. + */ public final class SignInputListener implements Listener { private static final String HANDLER_NAME = "blighted_sign_input"; private static final Field NETWORK_CONNECTION_FIELD; @@ -33,11 +36,30 @@ public final class SignInputListener implements Listener { } } + /** + * Constructs a sign input listener and injects packet interception for all currently online players. + */ + public SignInputListener() { + for (Player player : Bukkit.getOnlinePlayers()) { + inject(player); + } + } + + /** + * Intercepts the player's network pipeline upon joining the server. + * + * @param event the join event containing the connecting player + */ @EventHandler(priority = EventPriority.LOWEST) public void onJoin(PlayerJoinEvent event) { inject(event.getPlayer()); } + /** + * Cleans up packet interception and active sign input sessions when a player disconnects. + * + * @param event the quit event containing the disconnecting player + */ @EventHandler(priority = EventPriority.MONITOR) public void onQuit(PlayerQuitEvent event) { Player player = event.getPlayer(); @@ -48,6 +70,11 @@ public void onQuit(PlayerQuitEvent event) { } } + /** + * Injects the sign update packet interceptor into a player's Netty channel pipeline. + * + * @param player player to inject + */ public void inject(Player player) { try { ChannelPipeline pipeline = getPipeline(player); @@ -70,6 +97,11 @@ public void channelRead(ChannelHandlerContext context, Object packet) throws Exc } } + /** + * Removes the sign update packet interceptor from a player's channel pipeline. + * + * @param player player to uninject + */ public void uninject(Player player) { try { ChannelPipeline pipeline = getPipeline(player); @@ -92,6 +124,9 @@ private ChannelPipeline getPipeline(Player player) { } } + /** + * Uninjects packet interceptors from all currently online players and clears active sessions. + */ public void cleanup() { for (Player player : Bukkit.getOnlinePlayers()) { uninject(player); 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 b75fd242..1bff82a3 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 @@ -10,6 +10,7 @@ import org.bukkit.Bukkit; import org.bukkit.craftbukkit.entity.CraftPlayer; import org.bukkit.entity.Player; +import org.jspecify.annotations.NonNull; import java.util.Map; import java.util.UUID; @@ -24,18 +25,21 @@ public final class SignInputManager { private static final Map sessions = new ConcurrentHashMap<>(); - private record Session(SignInputMenu menu, BlockPos position) { + private SignInputManager() { + } + + private record Session(SignInput input, BlockPos position) { } /** * Registers an active sign input session for a player. * - * @param PlayerId player unique identifier - * @param menu sign input menu handling the session + * @param playerId player unique identifier + * @param input sign input handling the session * @param position temporary sign position */ - static void register(UUID PlayerId, SignInputMenu menu, BlockPos position) { - sessions.put(PlayerId, new Session(menu, position)); + static void register(UUID playerId, SignInput input, BlockPos position) { + sessions.put(playerId, new Session(input, position)); } /** @@ -47,7 +51,7 @@ static void register(UUID PlayerId, SignInputMenu menu, BlockPos position) { * @param player player who submitted the input * @param lines submitted sign lines */ - public static void handleSignUpdate(Player player, String[] lines) { + public static void handleSignUpdate(@NonNull Player player, String @NonNull [] lines) { Session session = sessions.remove(player.getUniqueId()); if (session == null) return; @@ -65,11 +69,10 @@ public static void handleSignUpdate(Player player, String[] lines) { } Bukkit.getScheduler().runTask(PluginContext.get(), () -> { - if (player.isOnline()) { - session.menu().handleComplete(player, lines); - } - } - ); + if (player.isOnline()) { + session.input().handleComplete(player, lines); + } + }); } /** diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/sign/SignInputMenu.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/sign/SignInputMenu.java deleted file mode 100644 index e34e8129..00000000 --- a/bedrock/src/main/java/fr/moussax/bedrock/ui/sign/SignInputMenu.java +++ /dev/null @@ -1,150 +0,0 @@ -package fr.moussax.bedrock.ui.sign; - -import fr.moussax.bedrock.scheduling.PluginContext; -import net.minecraft.core.BlockPos; -import net.minecraft.network.chat.Component; -import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket; -import net.minecraft.network.protocol.game.ClientboundBlockUpdatePacket; -import net.minecraft.network.protocol.game.ClientboundOpenSignEditorPacket; -import net.minecraft.server.level.ServerPlayer; -import net.minecraft.world.level.block.Blocks; -import net.minecraft.world.level.block.entity.SignBlockEntity; -import net.minecraft.world.level.block.entity.SignText; -import net.minecraft.world.level.block.entity.SignTextSlot; -import net.minecraft.world.level.block.state.BlockState; -import org.bukkit.Bukkit; -import org.bukkit.Location; -import org.bukkit.craftbukkit.entity.CraftPlayer; -import org.bukkit.entity.Player; -import org.jspecify.annotations.NonNull; - -import java.util.function.Consumer; - -/** - * Represents a temporary sign editor used to collect player text input. - * - *

The sign is displayed client-side without modifying the server world and - * is removed once the player submits their input.

- */ -public final class SignInputMenu { - private final String[] lines; - private final Consumer onComplete; - private final boolean frontSide; - - /** - * Creates a sign input menu. - * - * @param lines initial sign lines - * @param onComplete callback invoked when input is submitted - * @param frontSide whether to open the front side of the sign - */ - public SignInputMenu(String[] lines, Consumer onComplete, boolean frontSide) { - this.lines = lines; - this.onComplete = onComplete; - this.frontSide = frontSide; - } - - /** - * Opens the sign editor for a player. - * - *

The sign is displayed at a temporary client-side location above the - * player and does not modify the server world.

- * - * @param player player entering text - */ - public void open(@NonNull Player player) { - Location location = player.getLocation().clone(); - location.setY(location.getY() + 1); - - BlockPos blockPosition = new BlockPos(location.getBlockX(), location.getBlockY(), location.getBlockZ()); - ServerPlayer nmsPlayer = ((CraftPlayer) player).getHandle(); - - BlockState blockState = Blocks.OAK_SIGN.defaultBlockState(); - nmsPlayer.connection.send(new ClientboundBlockUpdatePacket(blockPosition, blockState)); - - SignBlockEntity signTile = new SignBlockEntity(blockPosition, blockState); - signTile.setAllowedPlayerEditor(player.getUniqueId()); - signTile.setWaxed(false); - - SignTextSlot slot = frontSide ? SignTextSlot.FRONT : SignTextSlot.BACK; - SignText.Mutable mutableText = SignText.EMPTY.asMutable(); - for (int i = 0; i < Math.min(4, lines.length); i++) { - String lineText = lines[i] != null ? lines[i] : ""; - mutableText.setLine(i, Component.literal(lineText)); - } - signTile.setText(mutableText.asImmutable(), slot); - - ClientboundBlockEntityDataPacket tilePacket = signTile.getUpdatePacket(); - nmsPlayer.connection.send(tilePacket); - - Bukkit.getScheduler().runTaskLater(PluginContext.get(), () -> { - if (!player.isOnline()) { - return; - } - nmsPlayer.connection.send(new ClientboundOpenSignEditorPacket(blockPosition, slot)); - SignInputManager.register(player.getUniqueId(), this, blockPosition); - }, 2L); - } - - /** - * Completes the input session and invokes the configured callback. - * - * @param player player who submitted the input - * @param lines submitted sign lines - */ - void handleComplete(@NonNull Player player, @NonNull String[] lines) { - if (onComplete != null) onComplete.accept(new SignInputResult(player, lines)); - } - - /** - * Creates a builder for configuring a sign input menu. - * - * @return new sign input builder - */ - public static Builder builder() { - return new Builder(); - } - - /** - * Builds and opens a sign input menu. - */ - public static class Builder { - private String[] lines = new String[]{"", "", "", ""}; - private Consumer onComplete; - private boolean isFrontSide = true; - - /** - * Sets the initial text displayed on the sign. - * - *

Only the first four lines are used. Missing lines remain empty.

- * - * @param lines initial sign lines - * @return this builder - */ - public Builder lines(@NonNull String... lines) { - this.lines = new String[]{"", "", "", ""}; - System.arraycopy(lines, 0, this.lines, 0, Math.min(lines.length, 4)); - return this; - } - - /** - * Sets the callback invoked when the player submits the sign. - * - * @param onComplete input completion callback - * @return this builder - */ - public Builder onComplete(@NonNull Consumer onComplete) { - this.onComplete = onComplete; - return this; - } - - /** - * Builds and opens the configured sign input menu. - * - * @param player player entering text - */ - public void open(@NonNull Player player) { - new SignInputMenu(lines, onComplete, isFrontSide).open(player); - } - } -} diff --git a/bedrock/src/test/java/fr/moussax/bedrock/ui/sign/SignInputTest.java b/bedrock/src/test/java/fr/moussax/bedrock/ui/sign/SignInputTest.java new file mode 100644 index 00000000..59c29aea --- /dev/null +++ b/bedrock/src/test/java/fr/moussax/bedrock/ui/sign/SignInputTest.java @@ -0,0 +1,104 @@ +package fr.moussax.bedrock.ui.sign; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +class SignInputTest { + + @Test + @DisplayName("Expects builder to store explicitly configured sign lines") + void testExplicitLines() { + SignInput input = SignInput.builder() + .lines("custom 1", "custom 2", "custom 3", "custom 4") + .build(); + + assertArrayEquals(new String[]{"custom 1", "custom 2", "custom 3", "custom 4"}, input.lines()); + assertEquals(0, input.inputLine()); + } + + @Test + @DisplayName("Expects prompt shortcut to format caret pointer and instructions on lines 2 and 3") + void testPromptShortcut() { + SignInput input = SignInput.builder() + .prompt("Enter your", "search query") + .build(); + + String[] lines = input.lines(); + assertEquals("", lines[0]); + assertEquals("^^^^^^^^^^^^^^^", lines[1]); + assertEquals("Enter your", lines[2]); + assertEquals("search query", lines[3]); + assertEquals(0, input.inputLine()); + } + + @Test + @DisplayName("Expects onSubmit to be called with trimmed input when non-empty text is submitted") + void testOnSubmitInvokedOnValidInput() { + AtomicReference submittedText = new AtomicReference<>(); + AtomicBoolean cancelled = new AtomicBoolean(false); + + SignInput input = SignInput.builder() + .prompt("Search items") + .onSubmit((_, text) -> submittedText.set(text)) + .onCancel(_ -> cancelled.set(true)) + .build(); + + input.handleComplete(null, new String[]{" Excalibur ", "^^^^^^^^^^^^^^^", "Search items", ""}); + + assertEquals("Excalibur", submittedText.get()); + assertFalse(cancelled.get()); + } + + @Test + @DisplayName("Expects onCancel to be called when submitted input is blank or whitespace") + void testOnCancelInvokedOnBlankInput() { + AtomicReference submittedText = new AtomicReference<>(); + AtomicBoolean cancelled = new AtomicBoolean(false); + + SignInput input = SignInput.builder() + .prompt("Search items") + .onSubmit((_, text) -> submittedText.set(text)) + .onCancel(_ -> cancelled.set(true)) + .build(); + + input.handleComplete(null, new String[]{" ", "^^^^^^^^^^^^^^^", "Search items", ""}); + + assertNull(submittedText.get()); + assertTrue(cancelled.get()); + } + + @Test + @DisplayName("Expects custom inputLine to extract input from specified line index") + void testCustomInputLineExtraction() { + AtomicReference submittedText = new AtomicReference<>(); + + SignInput input = SignInput.builder() + .lines("Header", "Subtext", "", "Footer") + .inputLine(2) + .onSubmit((_, text) -> submittedText.set(text)) + .build(); + + assertEquals(2, input.inputLine()); + + input.handleComplete(null, new String[]{"Header", "Subtext", "My Custom Input", "Footer"}); + + assertEquals("My Custom Input", submittedText.get()); + } + + @Test + @DisplayName("Expects SignInputResult to return lines and bounds-safe values") + void testSignInputResult() { + SignInputResult result = new SignInputResult(null, new String[]{"First Line", "Second Line"}); + + assertEquals("First Line", result.getFirstLine()); + assertEquals("First Line", result.getLine(0)); + assertEquals("Second Line", result.getLine(1)); + assertEquals("", result.getLine(2)); + assertEquals("", result.getLine(-1)); + } +} diff --git a/blighted-mod/src/main/java/fr/moussax/blightedMod/registry/EventsRegistry.java b/blighted-mod/src/main/java/fr/moussax/blightedMod/registry/EventsRegistry.java index 44945816..79f8efc4 100644 --- a/blighted-mod/src/main/java/fr/moussax/blightedMod/registry/EventsRegistry.java +++ b/blighted-mod/src/main/java/fr/moussax/blightedMod/registry/EventsRegistry.java @@ -3,7 +3,7 @@ import fr.moussax.bedrock.ui.actionbar.Actionbar; import fr.moussax.bedrock.ui.menu.system.MenuListener; import fr.moussax.bedrock.ui.menu.system.MenuSystem; -import fr.moussax.bedrock.ui.sign.SignInputListener; +import fr.moussax.bedrock.ui.sign.SignInput; import fr.moussax.blightedMod.BlightedMod; import fr.moussax.blightedMod.moderator.hud.ModerationHud; import fr.moussax.blightedMod.moderator.listeners.InteractiveChatListener; @@ -15,27 +15,23 @@ public final class EventsRegistry { private final BlightedMod instance = BlightedMod.getInstance(); private MenuSystem menuSystem; - private SignInputListener signInputListener; public void initializeListeners() { PluginManager pluginManager = Bukkit.getPluginManager(); - signInputListener = new SignInputListener(); + SignInput.initialize(instance); menuSystem = new MenuSystem(instance); Actionbar.initialize(instance, 10L); Actionbar.register(instance, ModerationHud.createSection(instance.getModerationManager())); - pluginManager.registerEvents(signInputListener, instance); pluginManager.registerEvents(new MenuListener(menuSystem), instance); pluginManager.registerEvents(new ModerationListener(instance.getModerationManager()), instance); pluginManager.registerEvents(new InteractiveChatListener(instance.getModerationManager()), instance); } public void cleanup() { - if (signInputListener != null) { - signInputListener.cleanup(); - } + SignInput.cleanup(instance); Actionbar.unregisterAll(instance); } 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 297839e2..c2bea66e 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 @@ -7,7 +7,7 @@ import fr.moussax.bedrock.ui.menu.Menu; import fr.moussax.bedrock.ui.menu.types.PaginatedMenu; import fr.moussax.bedrock.ui.menu.interaction.MenuItemInteraction; -import fr.moussax.bedrock.ui.sign.SignInputMenu; +import fr.moussax.bedrock.ui.sign.SignInput; import fr.moussax.bedrock.utils.ItemBuilder; import org.bukkit.Material; import org.bukkit.entity.Player; @@ -108,18 +108,11 @@ private static List getCategoryLore(ItemType.Category category) { } private static void openSearchSign(Player player, Menu previousMenu) { - SignInputMenu.builder() - .lines("", "^^^^^^", "Enter your", "search!") - .onComplete(result -> { - String search = result.getFirstLine().trim(); - if (search.isEmpty()) { - if (previousMenu != null) previousMenu.open(player); - else player.closeInventory(); - return; - } - new SearchResultsPaginatedMenu(search, previousMenu).open(player); - }) - .open(player); + SignInput.builder() + .lines("", "^^^^^^", "Enter your", "search!") + .reopenOnCancel(previousMenu) + .onSubmit((_, search) -> new SearchResultsPaginatedMenu(search, previousMenu).open(player)) + .open(player); } public static class BlightedItemsPaginatedMenu extends PaginatedMenu { 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 1826d23e..a2d1acfe 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 @@ -19,7 +19,7 @@ import fr.moussax.bedrock.ui.actionbar.Actionbar; import fr.moussax.bedrock.ui.menu.system.MenuListener; import fr.moussax.bedrock.ui.menu.system.MenuSystem; -import fr.moussax.bedrock.ui.sign.SignInputListener; +import fr.moussax.bedrock.ui.sign.SignInput; import org.bukkit.Bukkit; import org.bukkit.plugin.PluginManager; @@ -36,7 +36,6 @@ public final class EventsRegistry { private MenuSystem menuSystem; private PlayerHudManager playerHudManager; private SpawnableEntitiesListener spawnableEntitiesListener; - private SignInputListener signInputListener; /** * Initializes the event-driven subsystems and registers all BlightedMC @@ -49,6 +48,7 @@ public final class EventsRegistry { public void initializeListeners() { PluginManager pluginManager = Bukkit.getPluginManager(); menuSystem = new MenuSystem(instance); + SignInput.initialize(instance); Actionbar.initialize(instance, 20L); playerHudManager = new PlayerHudManager(instance); @@ -60,10 +60,8 @@ public void initializeListeners() { spawnableEntitiesListener = new SpawnableEntitiesListener(); EntitiesRegistry.addOnRegisterCallback(spawnableEntitiesListener::invalidateCache); - signInputListener = new SignInputListener(); pluginManager.registerEvents(new MenuListener(menuSystem), instance); - pluginManager.registerEvents(signInputListener, instance); pluginManager.registerEvents(new BlightedEntitiesListener(), instance); pluginManager.registerEvents(new EntityComponentListener(), instance); pluginManager.registerEvents(spawnableEntitiesListener, instance); @@ -91,13 +89,9 @@ public void buildSpawnCache() { /** * Cleans up listener-specific resources that require explicit disposal. - * - *

If the sign input listener has not been initialized, this method has no effect.

*/ public void cleanup() { - if (signInputListener != null) { - signInputListener.cleanup(); - } + SignInput.cleanup(instance); Actionbar.unregisterAll(instance); } From 3c4b8c6a9e8d3128bc9156213f4a47a58c03d2de Mon Sep 17 00:00:00 2001 From: Moussa Date: Wed, 30 Sep 2026 10:16:50 +0200 Subject: [PATCH 03/23] fix(actionbar): adjust burst interval and handle empty content gracefully --- .../bedrock/ui/actionbar/ActionbarService.java | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) 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 5376c66e..04350ccb 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 @@ -32,7 +32,7 @@ */ public final class ActionbarService implements Listener { - private static final long FADE_TIMEOUT_MILLIS = 1750L; + private static final long MIN_BURST_INTERVAL_MILLIS = 250L; @Getter @Setter @@ -470,7 +470,16 @@ public void renderPlayer(@NonNull Player player) { String lastText = lastSentTexts.get(uuid); Long lastSentTimestamp = lastSentTimestamps.get(uuid); - if (content.equals(lastText) && lastSentTimestamp != null && (now - lastSentTimestamp) < FADE_TIMEOUT_MILLIS) { + if (content.isEmpty()) { + if (lastText != null && !lastText.isEmpty()) { + sendRawPacket(player, ""); + lastSentTexts.put(uuid, ""); + lastSentTimestamps.put(uuid, now); + } + return; + } + + if (content.equals(lastText) && lastSentTimestamp != null && (now - lastSentTimestamp) < MIN_BURST_INTERVAL_MILLIS) { return; } From beffccca7f5a1d9046130970eba8da0737a9aa1f Mon Sep 17 00:00:00 2001 From: Moussa Date: Wed, 30 Sep 2026 10:18:03 +0200 Subject: [PATCH 04/23] feat(sign): revert sign handling to previous fixed commit --- .../fr/moussax/bedrock/ui/sign/SignInput.java | 34 +++++-------------- 1 file changed, 9 insertions(+), 25 deletions(-) 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 6daae83a..7879533d 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 @@ -3,18 +3,12 @@ import fr.moussax.bedrock.scheduling.PluginContext; import fr.moussax.bedrock.ui.menu.Menu; import net.minecraft.core.BlockPos; -import net.minecraft.network.chat.Component; -import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket; -import net.minecraft.network.protocol.game.ClientboundBlockUpdatePacket; import net.minecraft.network.protocol.game.ClientboundOpenSignEditorPacket; import net.minecraft.server.level.ServerPlayer; -import net.minecraft.world.level.block.Blocks; -import net.minecraft.world.level.block.entity.SignBlockEntity; -import net.minecraft.world.level.block.entity.SignText; import net.minecraft.world.level.block.entity.SignTextSlot; -import net.minecraft.world.level.block.state.BlockState; import org.bukkit.Bukkit; import org.bukkit.Location; +import org.bukkit.Material; import org.bukkit.craftbukkit.entity.CraftPlayer; import org.bukkit.entity.Player; import org.bukkit.plugin.Plugin; @@ -196,28 +190,18 @@ public void open(@NonNull Player player) { } Location location = player.getLocation().clone(); - location.setY(location.getY() + 1); + location.setY(location.getY() + 3); + player.sendBlockChange(location, Material.PALE_OAK_SIGN.createBlockData()); - BlockPos blockPosition = new BlockPos(location.getBlockX(), location.getBlockY(), location.getBlockZ()); - ServerPlayer nmsPlayer = ((CraftPlayer) player).getHandle(); - - BlockState blockState = Blocks.OAK_SIGN.defaultBlockState(); - nmsPlayer.connection.send(new ClientboundBlockUpdatePacket(blockPosition, blockState)); - - SignBlockEntity signTile = new SignBlockEntity(blockPosition, blockState); - signTile.setAllowedPlayerEditor(player.getUniqueId()); - signTile.setWaxed(false); - - SignTextSlot slot = frontSide ? SignTextSlot.FRONT : SignTextSlot.BACK; - SignText.Mutable mutableText = SignText.EMPTY.asMutable(); + String[] safeLines = new String[]{"", "", "", ""}; for (int i = 0; i < Math.min(4, lines.length); i++) { - String lineText = lines[i] != null ? lines[i] : ""; - mutableText.setLine(i, Component.literal(lineText)); + safeLines[i] = lines[i] != null ? lines[i] : ""; } - signTile.setText(mutableText.asImmutable(), slot); + player.sendSignChange(location, safeLines); - ClientboundBlockEntityDataPacket tilePacket = signTile.getUpdatePacket(); - nmsPlayer.connection.send(tilePacket); + BlockPos blockPosition = new BlockPos(location.getBlockX(), location.getBlockY(), location.getBlockZ()); + ServerPlayer nmsPlayer = ((CraftPlayer) player).getHandle(); + SignTextSlot slot = frontSide ? SignTextSlot.FRONT : SignTextSlot.BACK; Bukkit.getScheduler().runTaskLater(PluginContext.get(), () -> { if (!player.isOnline()) { From 750e59ef1be80b2c0107b6d5c6261213cf03bd8e Mon Sep 17 00:00:00 2001 From: Moussa Date: Wed, 30 Sep 2026 11:11:08 +0200 Subject: [PATCH 05/23] feat(book): redesign book UI with structured page composition and pixel-accurate layout Introduce BookCanvas (font metrics, pixel-width measurement, centering), BookPage (fluent line/title/paragraph/quote/field/button API with automatic spill-to-next-physical-page), and BookPaginator (word-wrap and text pagination at 114 px). Rework BookMenu into a fluent builder that resolves logical-to-physical page numbers after spilled pages are accounted for, and add a static BookMenu.open() convenience method. Update ReportsCenterMenu to use the new structured BookPage API. --- .../moussax/bedrock/ui/book/BookCanvas.java | 117 +++ .../fr/moussax/bedrock/ui/book/BookMenu.java | 453 +++++++----- .../fr/moussax/bedrock/ui/book/BookPage.java | 670 ++++++++++++++++++ .../bedrock/ui/book/BookPaginator.java | 163 +++++ .../moderator/menus/ReportsCenterMenu.java | 29 +- 5 files changed, 1230 insertions(+), 202 deletions(-) create mode 100644 bedrock/src/main/java/fr/moussax/bedrock/ui/book/BookCanvas.java create mode 100644 bedrock/src/main/java/fr/moussax/bedrock/ui/book/BookPage.java create mode 100644 bedrock/src/main/java/fr/moussax/bedrock/ui/book/BookPaginator.java diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/book/BookCanvas.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/book/BookCanvas.java new file mode 100644 index 00000000..188895f6 --- /dev/null +++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/book/BookCanvas.java @@ -0,0 +1,117 @@ +package fr.moussax.bedrock.ui.book; + +import fr.moussax.bedrock.utils.ColorUtils; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +/** + * Metric constants and layout calculations for Minecraft written book canvases. + * + *

A Minecraft written book page renders at an exact width of 114 pixels + * with a maximum of 14 lines per page. Text layout calculations use standard + * Minecraft font metrics.

+ */ +public final class BookCanvas { + + /** + * Maximum safe visible horizontal width of a book page in pixels. + * + *

Although the raw book canvas is 114 pixels wide, Minecraft appends a mandatory + * 1-pixel spacer after every character; using 113 pixels prevents edge-case unwanted line breaks.

+ */ + public static final int PAGE_WIDTH_PIXELS = 113; + + /** + * Maximum number of lines that fit on a single book page before truncation. + */ + public static final int MAXIMUM_LINES_PER_PAGE = 14; + + /** + * Standard pixel width of a space character including inter-character spacing. + */ + public static final int SPACE_PIXEL_WIDTH = 4; + + private BookCanvas() { + } + + /** + * Calculates the exact pixel width of a string rendered in Minecraft's default font. + * + *

Color and format codes (§ and &) are accounted for; bold styling adds 1 pixel per character.

+ * + * @param text the text to measure + * @return total width in pixels + */ + public static int measurePixelWidth(@Nullable String text) { + if (text == null || text.isEmpty()) { + return 0; + } + + String colorized = ColorUtils.colorize(text); + int width = 0; + boolean isBold = false; + int length = colorized.length(); + + for (int index = 0; index < length; index++) { + char character = colorized.charAt(index); + + if (character == '§' && index + 1 < length) { + char code = Character.toLowerCase(colorized.charAt(index + 1)); + if (code == 'l') { + isBold = true; + } else if ((code >= '0' && code <= '9') || (code >= 'a' && code <= 'f') || code == 'r') { + isBold = false; + } + index++; + continue; + } + + int charWidth = getCharacterWidth(character); + if (isBold) { + charWidth += 1; + } + width += charWidth + 1; + } + + return width; + } + + /** + * Calculates the number of leading spaces needed to center text horizontally. + * + * @param text the text to center + * @return count of leading spaces + */ + public static int calculateCenterSpaces(@NonNull String text) { + int textWidth = measurePixelWidth(text); + if (textWidth >= PAGE_WIDTH_PIXELS) { + return 0; + } + + int remainingPixels = PAGE_WIDTH_PIXELS - textWidth; + int paddingPixels = remainingPixels / 2; + return Math.max(0, paddingPixels / SPACE_PIXEL_WIDTH); + } + + /** + * Centers text horizontally across the 114-pixel book page width by prepending space padding. + * + * @param text the text to center + * @return centered text padded with leading spaces + */ + public static String center(@NonNull String text) { + int spaces = calculateCenterSpaces(text); + return spaces > 0 ? " ".repeat(spaces) + text : text; + } + + private static int getCharacterWidth(char character) { + return switch (character) { + case '!', ',', '.', ':', ';', 'i', '|' -> 1; + case '\'', '`' -> 2; + case ' ', 'l', 'I', '[', ']', 't' -> 3; + case 'f', 'k', '"', '*', '(', ')', '{', '}', '<', '>' -> 4; + case '@', '~', 'M', 'W' -> 6; + default -> 5; + }; + } +} diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/book/BookMenu.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/book/BookMenu.java index 6c90f182..58cf1138 100644 --- a/bedrock/src/main/java/fr/moussax/bedrock/ui/book/BookMenu.java +++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/book/BookMenu.java @@ -1,276 +1,353 @@ package fr.moussax.bedrock.ui.book; -import fr.moussax.bedrock.text.InteractiveMessage; import net.md_5.bungee.api.chat.BaseComponent; -import net.md_5.bungee.api.chat.TextComponent; +import net.md_5.bungee.api.chat.ClickEvent; import org.bukkit.Material; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.BookMeta; import org.jspecify.annotations.NonNull; -import java.util.ArrayList; -import java.util.List; +import java.util.*; import java.util.function.Consumer; /** - * Builds and opens interactive written books for players. + * Fluent builder for creating and opening interactive written books. * - *

Pages are composed from {@link InteractiveMessage} instances - * and may contain interactive chat components.

+ *

Supports custom titles, authors, book generations, structured page composition + * via {@link BookPage}, and automatic multipage text pagination.

*/ public final class BookMenu { - private static final int MAXIMUM_CHARACTERS_PER_LINE = 19; - private static final int MAXIMUM_LINES_PER_PAGE = 14; - - private final List rawPages = new ArrayList<>(); + private String title = "BlightedMenu"; + private String author = "BlightedMC"; + private BookMeta.Generation generation = BookMeta.Generation.ORIGINAL; + private final List pages = new ArrayList<>(); private BookMenu() { } /** - * Creates an empty book menu. + * Creates a new book menu builder. * - * @return new book menu + * @return a new book menu builder */ public static BookMenu builder() { return new BookMenu(); } /** - * Appends a page from an interactive message. + * Sets the title of the book. * - * @param message message used to build the page - * @return this book menu + * @param title book title + * @return this builder */ - public BookMenu addPage(@NonNull InteractiveMessage message) { - this.rawPages.add(message.build()); + public BookMenu title(@NonNull String title) { + this.title = title; return this; } /** - * Appends a page configured through a callback. + * Sets the author of the book. * - *

The callback receives an empty interactive message that can be - * configured before the page is built.

+ * @param author book author + * @return this builder + */ + public BookMenu author(@NonNull String author) { + this.author = author; + return this; + } + + /** + * Sets the generation copy status of the book. * - * @param pageConfigurator callback used to configure the page - * @return this book menu + * @param generation book generation + * @return this builder */ - public BookMenu addPage(@NonNull Consumer pageConfigurator) { - InteractiveMessage message = InteractiveMessage.text(""); - pageConfigurator.accept(message); - this.rawPages.add(message.build()); + public BookMenu generation(BookMeta.@NonNull Generation generation) { + this.generation = generation; return this; } /** - * Opens the configured book for a player. + * Configures and appends a page using a callback. * - *

The generated book uses the default BlightedMC title and author, - * and automatically paginates content overflowing single-page dimensions.

+ * @param configurator callback to configure the page + * @return this builder + */ + public BookMenu page(@NonNull Consumer configurator) { + BookPage page = new BookPage(); + configurator.accept(page); + this.pages.add(page); + return this; + } + + /** + * Configures and appends a named book page using a callback. * - * @param player player receiving the book + * @param id unique identifier or anchor name for this page + * @param configurator callback to configure the page + * @return this builder */ - public void open(@NonNull Player player) { - ItemStack book = new ItemStack(Material.WRITTEN_BOOK); - BookMeta meta = (BookMeta) book.getItemMeta(); - if (meta == null) return; + public BookMenu page(@NonNull String id, @NonNull Consumer configurator) { + BookPage page = new BookPage().id(id); + configurator.accept(page); + this.pages.add(page); + return this; + } - meta.setTitle("BlightedMenu"); - meta.setAuthor("BlightedMC"); - meta.setGeneration(BookMeta.Generation.ORIGINAL); + /** + * Appends a pre-configured book page. + * + * @param page book page to append + * @return this builder + */ + public BookMenu page(@NonNull BookPage page) { + this.pages.add(page); + return this; + } - List paginatedPages = buildPaginatedPages(); - for (BaseComponent[] pageComponents : paginatedPages) { - meta.spigot().addPage(pageComponents); - } + /** + * Appends a pre-configured named book page. + * + * @param id unique identifier or anchor name for this page + * @param page book page to append + * @return this builder + */ + public BookMenu page(@NonNull String id, @NonNull BookPage page) { + page.id(id); + this.pages.add(page); + return this; + } - book.setItemMeta(meta); - player.openBook(book); + /** + * Appends multiple pre-configured book pages. + * + * @param pages book pages to append + * @return this builder + */ + public BookMenu pages(@NonNull BookPage... pages) { + this.pages.addAll(List.of(pages)); + return this; } - private List buildPaginatedPages() { - List formattedPages = new ArrayList<>(); + /** + * Appends a collection of book pages. + * + * @param pages collection of book pages + * @return this builder + */ + public BookMenu pages(@NonNull Collection pages) { + this.pages.addAll(pages); + return this; + } - for (BaseComponent[] pageComponents : rawPages) { - List splitPages = splitSinglePageComponents(pageComponents); - formattedPages.addAll(splitPages); - } + /** + * Automatically word-wraps and paginates long text across multiple pages. + * + * @param text long text to paginate + * @return this builder + */ + public BookMenu paginatedText(@NonNull String text) { + this.pages.addAll(BookPaginator.paginateText(text)); + return this; + } - return formattedPages; + /** + * Paginates a list of pre-formatted lines across multiple pages. + * + * @param lines lines to paginate + * @return this builder + */ + public BookMenu paginatedLines(@NonNull List lines) { + this.pages.addAll(BookPaginator.paginateLines(lines)); + return this; } - private List splitSinglePageComponents(BaseComponent[] pageComponents) { - List leafComponents = flattenToLeafComponents(pageComponents); + /** + * Returns the configured title of the book. + * + * @return book title + */ + public String title() { + return title; + } - List> lines = new ArrayList<>(); - List currentLine = new ArrayList<>(); - int currentLineLength = 0; + /** + * Returns the configured author of the book. + * + * @return book author + */ + public String author() { + return author; + } - for (TextComponent component : leafComponents) { - String componentText = component.getText(); - if (componentText == null || componentText.isEmpty()) { - continue; - } + /** + * Returns the configured generation copy tier of the book. + * + * @return book generation + */ + public BookMeta.Generation generation() { + return generation; + } - String[] paragraphs = componentText.split("\r?\n", -1); - for (int paragraphIndex = 0; paragraphIndex < paragraphs.length; paragraphIndex++) { - if (paragraphIndex > 0) { - lines.add(currentLine); - currentLine = new ArrayList<>(); - currentLineLength = 0; - } - - String paragraphText = paragraphs[paragraphIndex]; - if (paragraphText.isEmpty()) { - continue; - } - - String[] words = paragraphText.split(" ", -1); - for (int wordIndex = 0; wordIndex < words.length; wordIndex++) { - String word = words[wordIndex]; - - if (word.isEmpty()) { - if (!currentLine.isEmpty() || wordIndex < words.length - 1) { - TextComponent spaceComponent = duplicateComponentWithText(component, " "); - currentLine.add(spaceComponent); - currentLineLength += 1; - } - continue; - } - - if (wordIndex > 0 && !currentLine.isEmpty() && !words[wordIndex - 1].isEmpty()) { - word = " " + word; - } - - while (word.length() > MAXIMUM_CHARACTERS_PER_LINE) { - String chunk = word.substring(0, MAXIMUM_CHARACTERS_PER_LINE); - word = word.substring(MAXIMUM_CHARACTERS_PER_LINE); - - if (currentLineLength + chunk.length() > MAXIMUM_CHARACTERS_PER_LINE && !currentLine.isEmpty()) { - lines.add(currentLine); - currentLine = new ArrayList<>(); - } - currentLine.add(duplicateComponentWithText(component, chunk)); - lines.add(currentLine); - currentLine = new ArrayList<>(); - currentLineLength = 0; - } - - if (currentLineLength + word.length() > MAXIMUM_CHARACTERS_PER_LINE && !currentLine.isEmpty()) { - lines.add(currentLine); - currentLine = new ArrayList<>(); - currentLineLength = 0; - if (word.startsWith(" ")) { - word = word.substring(1); - } - } - - TextComponent wordComponent = duplicateComponentWithText(component, word); - currentLine.add(wordComponent); - currentLineLength += word.length(); - } - } - } + /** + * Returns an unmodifiable snapshot of the configured book pages. + * + * @return list of book pages + */ + public List pages() { + return List.copyOf(pages); + } - if (!currentLine.isEmpty()) { - lines.add(currentLine); + /** + * Builds and resolves all pages into an ordered list of physical book pages. + * + *

Any {@link net.md_5.bungee.api.chat.ClickEvent.Action#CHANGE_PAGE} click actions referencing + * page names or logical builder page indices are resolved to their target physical page numbers, + * accounting for any automatic multi-page overflow spilling across previous sections.

+ * + * @return list of physical book pages ready for rendering + */ + public List buildPhysicalPages() { + if (pages.isEmpty()) { + return List.of(); } - if (lines.isEmpty()) { - return List.of(new BaseComponent[][]{pageComponents}); - } + Map targetToPhysical = new HashMap<>(); + Map logicalToPhysical = new HashMap<>(); + List physicalPages = new ArrayList<>(); - List resultPages = new ArrayList<>(); - for (int lineIndex = 0; lineIndex < lines.size(); lineIndex += MAXIMUM_LINES_PER_PAGE) { - int endIndex = Math.min(lineIndex + MAXIMUM_LINES_PER_PAGE, lines.size()); - List> pageLines = lines.subList(lineIndex, endIndex); - - List pageComponentsList = new ArrayList<>(); - for (int lineSubIndex = 0; lineSubIndex < pageLines.size(); lineSubIndex++) { - List line = pageLines.get(lineSubIndex); - pageComponentsList.addAll(line); - if (lineSubIndex < pageLines.size() - 1) { - pageComponentsList.add(new TextComponent("\n")); - } + for (int index = 0; index < pages.size(); index++) { + BookPage page = pages.get(index); + int startPhysicalPage = physicalPages.size() + 1; + logicalToPhysical.put(index + 1, startPhysicalPage); + + if (page.id() != null && !page.id().isBlank()) { + targetToPhysical.put(page.id().trim().toLowerCase(Locale.ROOT), startPhysicalPage); } - resultPages.add(pageComponentsList.toArray(new BaseComponent[0])); + physicalPages.addAll(page.buildPages()); } - return resultPages; + int totalPhysicalPages = physicalPages.size(); + for (BaseComponent[] pageComponents : physicalPages) { + for (BaseComponent component : pageComponents) { + resolvePageLinks(component, targetToPhysical, logicalToPhysical, totalPhysicalPages); + } + } + + return physicalPages; } - private List flattenToLeafComponents(BaseComponent[] components) { - List leafComponents = new ArrayList<>(); - for (BaseComponent component : components) { - collectLeafComponents(component, leafComponents); + private void resolvePageLinks( + BaseComponent component, + Map targetToPhysical, + Map logicalToPhysical, + int totalPhysicalPages + ) { + ClickEvent clickEvent = component.getClickEvent(); + if (clickEvent != null && clickEvent.getAction() == ClickEvent.Action.CHANGE_PAGE) { + String resolved = resolveTarget(clickEvent.getValue(), targetToPhysical, logicalToPhysical, totalPhysicalPages); + if (!resolved.equals(clickEvent.getValue())) { + component.setClickEvent(new ClickEvent(ClickEvent.Action.CHANGE_PAGE, resolved)); + } + } + + if (component.getExtra() != null) { + for (BaseComponent extra : component.getExtra()) { + resolvePageLinks(extra, targetToPhysical, logicalToPhysical, totalPhysicalPages); + } } - return leafComponents; } - private void collectLeafComponents(BaseComponent component, List targetList) { - if (component instanceof TextComponent textComponent) { - processTextComponent(textComponent, targetList); + private String resolveTarget( + String target, + Map targetToPhysical, + Map logicalToPhysical, + int totalPhysicalPages + ) { + if (target == null || target.isBlank()) { + return "1"; } - if (component.getExtra() == null) { - return; + Integer namedTarget = targetToPhysical.get(target.trim().toLowerCase(Locale.ROOT)); + if (namedTarget != null) { + return String.valueOf(namedTarget); } - for (BaseComponent extraComponent : component.getExtra()) { - if (extraComponent.getHoverEvent() == null) { - extraComponent.setHoverEvent(component.getHoverEvent()); + try { + int pageNumber = Integer.parseInt(target.trim()); + Integer logicalPhysical = logicalToPhysical.get(pageNumber); + if (logicalPhysical != null) { + return String.valueOf(logicalPhysical); } - if (extraComponent.getClickEvent() == null) { - extraComponent.setClickEvent(component.getClickEvent()); + if (pageNumber >= 1 && pageNumber <= totalPhysicalPages) { + return String.valueOf(pageNumber); } - collectLeafComponents(extraComponent, targetList); + } catch (NumberFormatException ignored) { } + + return target; } - private void processTextComponent(TextComponent textComponent, List targetList) { - String text = textComponent.getText(); - if (text == null || text.isEmpty()) { - return; + /** + * Assembles the book into an {@link ItemStack}. + * + * @return the configured written book item + */ + public ItemStack toItemStack() { + ItemStack book = new ItemStack(Material.WRITTEN_BOOK); + BookMeta meta = (BookMeta) book.getItemMeta(); + if (meta == null) { + return book; } - BaseComponent legacyComponent = TextComponent.fromLegacy(text); - addParsedLeafComponents(legacyComponent, textComponent, targetList); - } + meta.setTitle(title); + meta.setAuthor(author); + meta.setGeneration(generation); - private void addParsedLeafComponents(BaseComponent component, TextComponent parent, List targetList) { - if (component instanceof TextComponent textComponent) { - if (textComponent.getHoverEvent() == null) { - textComponent.setHoverEvent(parent.getHoverEvent()); - } - if (textComponent.getClickEvent() == null) { - textComponent.setClickEvent(parent.getClickEvent()); - } - if (textComponent.getText() != null && !textComponent.getText().isEmpty()) { - targetList.add(textComponent); + List physicalPages = buildPhysicalPages(); + if (physicalPages.isEmpty()) { + meta.spigot().addPage(new BaseComponent[0]); + } else { + for (BaseComponent[] pageComponents : physicalPages) { + meta.spigot().addPage(pageComponents); } } - if (component.getExtra() != null) { - for (BaseComponent extra : component.getExtra()) { - addParsedLeafComponents(extra, parent, targetList); - } - } + book.setItemMeta(meta); + return book; } - private TextComponent duplicateComponentWithText(TextComponent source, String text) { - TextComponent copy = new TextComponent(text); - copy.setColor(source.getColorRaw()); - copy.setBold(source.isBoldRaw()); - copy.setItalic(source.isItalicRaw()); - copy.setUnderlined(source.isUnderlinedRaw()); - copy.setStrikethrough(source.isStrikethroughRaw()); - copy.setObfuscated(source.isObfuscatedRaw()); - copy.setHoverEvent(source.getHoverEvent()); - copy.setClickEvent(source.getClickEvent()); - return copy; + /** + * Builds and opens the book for the specified player. + * + * @param player player to receive and view the book + */ + public void open(@NonNull Player player) { + ItemStack book = toItemStack(); + player.openBook(book); + } + + /** + * Opens a single-page book for the player using default metadata. + * + * @param player player viewing the book + * @param configurator callback configuring the single page + */ + public static void open(@NonNull Player player, @NonNull Consumer configurator) { + builder().page(configurator).open(player); + } + + /** + * Opens a single-page book for the player with a custom title. + * + * @param player player viewing the book + * @param title title of the book + * @param configurator callback configuring the single page + */ + public static void open(@NonNull Player player, @NonNull String title, @NonNull Consumer configurator) { + builder().title(title).page(configurator).open(player); } } diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/book/BookPage.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/book/BookPage.java new file mode 100644 index 00000000..07e6f900 --- /dev/null +++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/book/BookPage.java @@ -0,0 +1,670 @@ +package fr.moussax.bedrock.ui.book; + +import fr.moussax.bedrock.utils.ColorUtils; +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.ClickEvent; +import net.md_5.bungee.api.chat.HoverEvent; +import net.md_5.bungee.api.chat.TextComponent; +import net.md_5.bungee.api.chat.hover.content.Text; +import org.jspecify.annotations.NonNull; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +/** + * Fluent builder for composing individual pages of a {@link BookMenu}. + * + *

Designed specifically for the 114-pixel Minecraft written book canvas, + * providing conveniences for centered headers, dividers, key-value fields, + * buttons, and native client-side page navigation.

+ */ +public final class BookPage { + + private final List> lines = new ArrayList<>(); + private final List currentLine = new ArrayList<>(); + private String id; + + /** + * Constructs a new empty book page builder. + */ + public BookPage() { + } + + /** + * Creates a new empty book page. + * + * @return a new book page builder + */ + public static BookPage create() { + return new BookPage(); + } + + /** + * Sets the unique identifier or anchor name for this book page. + * + * @param id page identifier + * @return this page builder + */ + public BookPage id(@NonNull String id) { + this.id = id; + return this; + } + + /** + * Returns the configured identifier of this page, or {@code null} if unnamed. + * + * @return page identifier + */ + public String id() { + return id; + } + + /** + * Formats and centers a title with bold styling across the book page canvas. + * + * @param title the title text + * @return this page builder + */ + public BookPage title(@NonNull String title) { + String formatted = "§0§l" + title; + return line(BookCanvas.center(formatted)); + } + + /** + * Centers text across the 114-pixel canvas and terminates the line. + * + * @param text the text to center + * @return this page builder + */ + public BookPage centered(@NonNull String text) { + return line(BookCanvas.center(text)); + } + + /** + * Appends a line of text followed by a newline break. + * + * @param text the text to append + * @return this page builder + */ + public BookPage line(@NonNull String text) { + append(text); + newLine(); + return this; + } + + /** + * Appends multiple lines of text, each followed by a newline break. + * + * @param lines lines to append + * @return this page builder + */ + public BookPage lines(@NonNull String... lines) { + for (String line : lines) { + line(line); + } + return this; + } + + /** + * Appends a collection of text lines, each followed by a newline break. + * + * @param lines collection of lines to append + * @return this page builder + */ + public BookPage lines(@NonNull Collection lines) { + for (String line : lines) { + line(line); + } + return this; + } + + /** + * Appends a blank spacing line. + * + * @return this page builder + */ + public BookPage blank() { + return newLine(); + } + + /** + * Appends multiple blank spacing lines. + * + * @param count number of blank lines to insert + * @return this page builder + */ + public BookPage blank(int count) { + for (int index = 0; index < count; index++) { + newLine(); + } + return this; + } + + /** + * Appends a key-value field row followed by a newline break. + * + *

If the label and value exceed the canvas width, the content automatically word-wraps onto + * subsequent lines with color preservation, preventing off-screen clipping.

+ * + * @param label field label + * @param value field value + * @return this page builder + */ + public BookPage field(@NonNull String label, @NonNull String value) { + String fullText = "§0" + label + ": §8" + value; + if (BookCanvas.measurePixelWidth(fullText) <= BookCanvas.PAGE_WIDTH_PIXELS) { + return line(fullText); + } + return paragraph(fullText); + } + + /** + * Appends an italicized quote block followed by a newline break. + * + *

If the quote exceeds the canvas width, it automatically wraps onto subsequent lines.

+ * + * @param quote text to quote + * @return this page builder + */ + public BookPage quote(@NonNull String quote) { + String fullText = "§8\"§3" + quote + "§8\""; + if (BookCanvas.measurePixelWidth(fullText) <= BookCanvas.PAGE_WIDTH_PIXELS) { + return line(fullText); + } + return paragraph(fullText); + } + + /** + * Appends a paragraph of text, automatically word-wrapping lines to fit the canvas width. + * + *

Color formatting is preserved across line breaks.

+ * + * @param paragraph text paragraph to wrap and append + * @return this page builder + */ + public BookPage paragraph(@NonNull String paragraph) { + List wrapped = BookPaginator.wrapText(paragraph); + for (String line : wrapped) { + line(line); + } + return this; + } + + /** + * Appends a collection of lore or description lines, wrapping any individual line that exceeds canvas width. + * + * @param lore lines of lore to append + * @return this page builder + */ + public BookPage lore(@NonNull List lore) { + for (String line : lore) { + paragraph(line); + } + return this; + } + + /** + * Appends multiple lore or description lines, wrapping any individual line that exceeds canvas width. + * + * @param lore lines of lore to append + * @return this page builder + */ + public BookPage lore(@NonNull String... lore) { + return lore(List.of(lore)); + } + + /** + * Appends inline text without a trailing newline. + * + * @param text text to append + * @return this page builder + */ + public BookPage append(@NonNull String text) { + return append(TextComponent.fromLegacy(ColorUtils.colorize(text))); + } + + /** + * Appends an existing chat component inline without a trailing newline. + * + * @param component component to append + * @return this page builder + */ + public BookPage append(@NonNull BaseComponent component) { + currentLine.add(component); + return this; + } + + /** + * Appends a clickable command execution button inline without a trailing newline. + * + * @param label visible button label + * @param hoverText hover tooltip text + * @param command command executed when clicked + * @return this page builder + */ + public BookPage appendButton(@NonNull String label, @NonNull String hoverText, @NonNull String command) { + return appendInteractive(label, hoverText, ClickEvent.Action.RUN_COMMAND, command); + } + + /** + * Appends a clickable command execution button followed by a newline break. + * + * @param label visible button label + * @param hoverText hover tooltip text + * @param command command executed when clicked + * @return this page builder + */ + public BookPage button(@NonNull String label, @NonNull String hoverText, @NonNull String command) { + appendButton(label, hoverText, command); + newLine(); + return this; + } + + /** + * Appends a clickable command button centered horizontally on the canvas. + * + * @param label visible button label + * @param hoverText hover tooltip text + * @param command command executed when clicked + * @return this page builder + */ + public BookPage centeredButton(@NonNull String label, @NonNull String hoverText, @NonNull String command) { + int spaces = BookCanvas.calculateCenterSpaces(label); + if (spaces > 0) { + append(" ".repeat(spaces)); + } + return button(label, hoverText, command); + } + + /** + * Appends a clickable URL link inline without a trailing newline. + * + * @param label visible link label + * @param hoverText hover tooltip text + * @param url URL opened when clicked + * @return this page builder + */ + public BookPage appendLink(@NonNull String label, @NonNull String hoverText, @NonNull String url) { + return appendInteractive(label, hoverText, ClickEvent.Action.OPEN_URL, url); + } + + /** + * Appends a clickable URL link followed by a newline break. + * + * @param label visible link label + * @param hoverText hover tooltip text + * @param url URL opened when clicked + * @return this page builder + */ + public BookPage link(@NonNull String label, @NonNull String hoverText, @NonNull String url) { + appendLink(label, hoverText, url); + newLine(); + return this; + } + + /** + * Appends a clickable URL link centered horizontally on the canvas. + * + * @param label visible link label + * @param hoverText hover tooltip text + * @param url URL opened when clicked + * @return this page builder + */ + public BookPage centeredLink(@NonNull String label, @NonNull String hoverText, @NonNull String url) { + int spaces = BookCanvas.calculateCenterSpaces(label); + if (spaces > 0) { + append(" ".repeat(spaces)); + } + return link(label, hoverText, url); + } + + /** + * Appends a native client-side page flip button inline without a trailing newline. + * + * @param label visible button label + * @param hoverText hover tooltip text + * @param targetPage 1-based target page number or page identifier + * @return this page builder + */ + public BookPage appendPageButton(@NonNull String label, @NonNull String hoverText, @NonNull String targetPage) { + return appendInteractive(label, hoverText, ClickEvent.Action.CHANGE_PAGE, targetPage); + } + + /** + * Appends a native client-side page flip button inline without a trailing newline. + * + * @param label visible button label + * @param hoverText hover tooltip text + * @param targetPage 1-based target page number + * @return this page builder + */ + public BookPage appendPageButton(@NonNull String label, @NonNull String hoverText, int targetPage) { + return appendPageButton(label, hoverText, String.valueOf(targetPage)); + } + + /** + * Appends a native client-side page flip button followed by a newline break. + * + * @param label visible button label + * @param hoverText hover tooltip text + * @param targetPage 1-based target page number or page identifier + * @return this page builder + */ + public BookPage pageButton(@NonNull String label, @NonNull String hoverText, @NonNull String targetPage) { + appendPageButton(label, hoverText, targetPage); + newLine(); + return this; + } + + /** + * Appends a native client-side page flip button followed by a newline break. + * + * @param label visible button label + * @param hoverText hover tooltip text + * @param targetPage 1-based target page number + * @return this page builder + */ + public BookPage pageButton(@NonNull String label, @NonNull String hoverText, int targetPage) { + return pageButton(label, hoverText, String.valueOf(targetPage)); + } + + /** + * Appends a native client-side page navigation link followed by a newline break. + * + * @param label visible link label + * @param hoverText hover tooltip text + * @param targetPage 1-based target page number or page identifier + * @return this page builder + */ + public BookPage pageLink(@NonNull String label, @NonNull String hoverText, @NonNull String targetPage) { + return pageButton(label, hoverText, targetPage); + } + + /** + * Appends a native client-side page navigation link followed by a newline break. + * + * @param label visible link label + * @param hoverText hover tooltip text + * @param targetPage 1-based target page number + * @return this page builder + */ + public BookPage pageLink(@NonNull String label, @NonNull String hoverText, int targetPage) { + return pageButton(label, hoverText, targetPage); + } + + /** + * Appends a native client-side page flip button centered horizontally on the canvas. + * + * @param label visible button label + * @param hoverText hover tooltip text + * @param targetPage 1-based target page number or page identifier + * @return this page builder + */ + public BookPage centeredPageButton(@NonNull String label, @NonNull String hoverText, @NonNull String targetPage) { + int spaces = BookCanvas.calculateCenterSpaces(label); + if (spaces > 0) { + append(" ".repeat(spaces)); + } + return pageButton(label, hoverText, targetPage); + } + + /** + * Appends a native client-side page flip button centered horizontally on the canvas. + * + * @param label visible button label + * @param hoverText hover tooltip text + * @param targetPage 1-based target page number + * @return this page builder + */ + public BookPage centeredPageButton(@NonNull String label, @NonNull String hoverText, int targetPage) { + return centeredPageButton(label, hoverText, String.valueOf(targetPage)); + } + + + /** + * Appends a clipboard copy button inline without a trailing newline. + * + * @param label visible button label + * @param hoverText hover tooltip text + * @param text text copied to clipboard when clicked + * @return this page builder + */ + public BookPage appendCopy(@NonNull String label, @NonNull String hoverText, @NonNull String text) { + return appendInteractive(label, hoverText, ClickEvent.Action.COPY_TO_CLIPBOARD, text); + } + + /** + * Appends a clipboard copy button followed by a newline break. + * + * @param label visible button label + * @param hoverText hover tooltip text + * @param text text copied to clipboard when clicked + * @return this page builder + */ + public BookPage copy(@NonNull String label, @NonNull String hoverText, @NonNull String text) { + appendCopy(label, hoverText, text); + newLine(); + return this; + } + + /** + * Appends a clipboard copy button centered horizontally on the canvas. + * + * @param label visible button label + * @param hoverText hover tooltip text + * @param text text copied to clipboard when clicked + * @return this page builder + */ + public BookPage centeredCopy(@NonNull String label, @NonNull String hoverText, @NonNull String text) { + int spaces = BookCanvas.calculateCenterSpaces(label); + if (spaces > 0) { + append(" ".repeat(spaces)); + } + return copy(label, hoverText, text); + } + + /** + * Appends a primary-centered command action button followed by a newline. + * + * @param label visible button label + * @param hoverText hover tooltip text + * @param command command executed when clicked + * @return this page builder + */ + public BookPage actionButton(@NonNull String label, @NonNull String hoverText, @NonNull String command) { + return centeredButton(label, hoverText, command); + } + + /** + * Appends a primary-centered web link action button followed by a newline. + * + * @param label visible link label + * @param hoverText hover tooltip text + * @param url web URL opened when clicked + * @return this page builder + */ + public BookPage actionLink(@NonNull String label, @NonNull String hoverText, @NonNull String url) { + return centeredLink(label, hoverText, url); + } + + /** + * Appends a primary-centered clipboard copy action button followed by a newline. + * + * @param label visible button label + * @param hoverText hover tooltip text + * @param text text copied to clipboard when clicked + * @return this page builder + */ + public BookPage actionCopy(@NonNull String label, @NonNull String hoverText, @NonNull String text) { + return centeredCopy(label, hoverText, text); + } + + /** + * Appends a primary-centered page navigation action button followed by a newline. + * + * @param label visible button label + * @param hoverText hover tooltip text + * @param targetPage 1-based target page number or page identifier + * @return this page builder + */ + public BookPage actionPage(@NonNull String label, @NonNull String hoverText, @NonNull String targetPage) { + return centeredPageButton(label, hoverText, targetPage); + } + + /** + * Appends a primary-centered page navigation action button followed by a newline. + * + * @param label visible button label + * @param hoverText hover tooltip text + * @param targetPage 1-based target page number + * @return this page builder + */ + public BookPage actionPage(@NonNull String label, @NonNull String hoverText, int targetPage) { + return centeredPageButton(label, hoverText, targetPage); + } + + /** + * Checks if the given text or paragraph can fit within the page's remaining lines. + * + * @param text text to evaluate + * @return true if the wrapped lines do not exceed remaining page capacity + */ + public boolean fits(@NonNull String text) { + return BookPaginator.wrapText(text).size() <= remainingLines(); + } + + /** + * Terminates the current line with a newline break. + * + * @return this page builder + */ + public BookPage newLine() { + lines.add(new ArrayList<>(currentLine)); + currentLine.clear(); + return this; + } + + /** + * Returns the total number of lines currently added across this page builder. + * + * @return current line count + */ + public int lineCount() { + return lines.size() + (currentLine.isEmpty() ? 0 : 1); + } + + /** + * Returns the remaining lines on the current 14-line book page. + * + * @return remaining lines available on current page + */ + public int remainingLines() { + int modulo = lineCount() % BookCanvas.MAXIMUM_LINES_PER_PAGE; + return modulo == 0 && lineCount() > 0 ? 0 : BookCanvas.MAXIMUM_LINES_PER_PAGE - modulo; + } + + /** + * Checks if the current page has reached its 14-line maximum capacity. + * + * @return true if line capacity is reached on the current page + */ + public boolean isFull() { + return remainingLines() == 0; + } + + /** + * Returns the total number of book pages generated by this page builder. + * + * @return page count (at least 1) + */ + public int pageCount() { + int total = lineCount(); + if (total <= 0) { + return 1; + } + return (total + BookCanvas.MAXIMUM_LINES_PER_PAGE - 1) / BookCanvas.MAXIMUM_LINES_PER_PAGE; + } + + private BookPage appendInteractive(String label, String hoverText, ClickEvent.Action action, String actionValue) { + if (action == ClickEvent.Action.RUN_COMMAND && !actionValue.startsWith("/")) { + actionValue = "/" + actionValue; + } + + BaseComponent component = TextComponent.fromLegacy(ColorUtils.colorize(label)); + HoverEvent hoverEvent = new HoverEvent(HoverEvent.Action.SHOW_TEXT, new Text(ColorUtils.colorize(hoverText))); + ClickEvent clickEvent = new ClickEvent(action, actionValue); + + applyEvents(component, hoverEvent, clickEvent); + return append(component); + } + + private void applyEvents(BaseComponent component, HoverEvent hoverEvent, ClickEvent clickEvent) { + component.setHoverEvent(hoverEvent); + component.setClickEvent(clickEvent); + if (component.getExtra() != null) { + for (BaseComponent child : component.getExtra()) { + applyEvents(child, hoverEvent, clickEvent); + } + } + } + + /** + * Builds and paginates this page's content into one or more pages of at most 14 lines each. + * + *

If content exceeds the 14-line limit of a single book page, lines automatically + * spill onto consecutive pages, ensuring no trailing buttons or text are lost or clipped.

+ * + * @return list of chat component arrays, one per resulting book page + */ + public List buildPages() { + List> allLines = getAllLines(); + if (allLines.isEmpty()) { + return Collections.singletonList(new BaseComponent[0]); + } + + List resultPages = new ArrayList<>(); + for (int i = 0; i < allLines.size(); i += BookCanvas.MAXIMUM_LINES_PER_PAGE) { + int end = Math.min(i + BookCanvas.MAXIMUM_LINES_PER_PAGE, allLines.size()); + List> pageLines = allLines.subList(i, end); + + List pageComponents = new ArrayList<>(); + for (int lineIndex = 0; lineIndex < pageLines.size(); lineIndex++) { + pageComponents.addAll(pageLines.get(lineIndex)); + if (lineIndex < pageLines.size() - 1) { + pageComponents.add(new TextComponent("\n")); + } + } + resultPages.add(pageComponents.toArray(new BaseComponent[0])); + } + return resultPages; + } + + /** + * Builds the page content as an array of Bungee chat components. + * + *

If this page spans multiple pages, all lines are concatenated with newlines.

+ * + * @return array of chat components ready for book metadata + */ + public BaseComponent[] build() { + List> allLines = getAllLines(); + List all = new ArrayList<>(); + for (int i = 0; i < allLines.size(); i++) { + all.addAll(allLines.get(i)); + if (i < allLines.size() - 1) { + all.add(new TextComponent("\n")); + } + } + return all.toArray(new BaseComponent[0]); + } + + private List> getAllLines() { + List> result = new ArrayList<>(lines); + if (!currentLine.isEmpty()) { + result.add(new ArrayList<>(currentLine)); + } + return result; + } +} diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/book/BookPaginator.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/book/BookPaginator.java new file mode 100644 index 00000000..f89b0ac3 --- /dev/null +++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/book/BookPaginator.java @@ -0,0 +1,163 @@ +package fr.moussax.bedrock.ui.book; + +import org.bukkit.ChatColor; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; + +/** + * Utility for word-wrapping and paginating text across Minecraft book pages. + * + *

Splits content based on the 114-pixel line width and 14 lines per page constraints.

+ */ +public final class BookPaginator { + + private BookPaginator() { + } + + /** + * Paginates a long text string across multiple book pages. + * + *

Paragraphs are preserved and words are wrapped based on 114-pixel font metrics. + * Each page contains at most 14 lines.

+ * + * @param text the raw text to paginate + * @return list of configured book pages + */ + public static List paginateText(@Nullable String text) { + if (text == null || text.isBlank()) { + return List.of(new BookPage()); + } + + List wrappedLines = wrapText(text); + return paginateLines(wrappedLines); + } + + /** + * Splits a list of pre-formatted lines into pages of at most 14 lines. + * + * @param lines the lines to paginate + * @return list of configured book pages + */ + public static List paginateLines(@NonNull List lines) { + if (lines.isEmpty()) { + return List.of(new BookPage()); + } + + List pages = new ArrayList<>(); + BookPage currentPage = new BookPage(); + int lineCount = 0; + + for (String line : lines) { + if (lineCount >= BookCanvas.MAXIMUM_LINES_PER_PAGE) { + pages.add(currentPage); + currentPage = new BookPage(); + lineCount = 0; + } + + currentPage.line(line); + lineCount++; + } + + if (lineCount > 0) { + pages.add(currentPage); + } + + return pages; + } + + /** + * Word-wraps text into individual lines that fit within the 114-pixel canvas width. + * + * @param text text to wrap + * @return list of lines fitting the book width + */ + public static List wrapText(@NonNull String text) { + List wrappedLines = new ArrayList<>(); + String[] paragraphs = text.split("\r?\n", -1); + + for (String paragraph : paragraphs) { + if (paragraph.isEmpty()) { + wrappedLines.add(""); + continue; + } + + int leadingSpaces = 0; + while (leadingSpaces < paragraph.length() && paragraph.charAt(leadingSpaces) == ' ') { + leadingSpaces++; + } + String initialIndent = leadingSpaces > 0 ? " ".repeat(leadingSpaces) : ""; + boolean isFirstWord = true; + + String[] words = paragraph.split(" "); + StringBuilder currentLine = new StringBuilder(); + String activeColors = ""; + + for (String word : words) { + if (word.isEmpty()) { + continue; + } + + if (currentLine.isEmpty()) { + String prefix = (isFirstWord ? initialIndent : "") + (activeColors.isEmpty() ? "" : activeColors); + isFirstWord = false; + if (BookCanvas.measurePixelWidth(prefix + word) > BookCanvas.PAGE_WIDTH_PIXELS) { + splitLargeWord(prefix + word, wrappedLines); + activeColors = ChatColor.getLastColors(wrappedLines.getLast()); + } else { + currentLine.append(prefix).append(word); + } + continue; + } + + String candidate = currentLine + " " + word; + if (BookCanvas.measurePixelWidth(candidate) <= BookCanvas.PAGE_WIDTH_PIXELS) { + currentLine.append(" ").append(word); + } else { + String finishedLine = currentLine.toString(); + wrappedLines.add(finishedLine); + activeColors = ChatColor.getLastColors(finishedLine); + currentLine.setLength(0); + + String prefix = activeColors.isEmpty() ? "" : activeColors; + if (BookCanvas.measurePixelWidth(prefix + word) > BookCanvas.PAGE_WIDTH_PIXELS) { + splitLargeWord(prefix + word, wrappedLines); + activeColors = ChatColor.getLastColors(wrappedLines.getLast()); + } else { + currentLine.append(prefix).append(word); + } + } + } + + if (!currentLine.isEmpty()) { + wrappedLines.add(currentLine.toString()); + } + } + + return wrappedLines; + } + + private static void splitLargeWord(String word, List targetLines) { + StringBuilder chunk = new StringBuilder(); + String activeColors; + for (int i = 0; i < word.length(); i++) { + char character = word.charAt(i); + String testChunk = chunk.toString() + character; + if (BookCanvas.measurePixelWidth(testChunk) > BookCanvas.PAGE_WIDTH_PIXELS && !chunk.isEmpty()) { + String finished = chunk.toString(); + targetLines.add(finished); + activeColors = ChatColor.getLastColors(finished); + chunk.setLength(0); + if (!activeColors.isEmpty()) { + chunk.append(activeColors); + } + } + chunk.append(character); + } + if (!chunk.isEmpty()) { + targetLines.add(chunk.toString()); + } + } +} diff --git a/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/menus/ReportsCenterMenu.java b/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/menus/ReportsCenterMenu.java index 23f4b561..c80224db 100644 --- a/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/menus/ReportsCenterMenu.java +++ b/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/menus/ReportsCenterMenu.java @@ -150,20 +150,21 @@ protected void onItemShiftClick(@NonNull Player moderator, int index) { public static void openReportBook(Player moderator, ReportData report) { String dateText = DATE_FORMAT.format(new Date(report.timestamp())); - BookMenu bookMenu = BookMenu.builder() - .addPage(page -> { - page.append(" §0§lREPORT DETAILS\n\n"); - page.append("§0" + report.reporterName() + " §0reported §6" + report.targetName() + " §0for §c" + report.reason() + "§0.\n\n"); - - if (report.message() != null && !report.message().isBlank() && !report.message().equalsIgnoreCase("General player report") && !report.message().equalsIgnoreCase("None")) { - page.append("§0Message:\n"); - page.append("§8\"§3" + report.message() + "§8\"\n\n"); - } - - page.append("§0Submitted: §8" + dateText + "\n\n"); - page.hoverAndExecute("§4▶ Dismiss Report", "§eClick to dismiss this report", "/reports dismiss " + report.id()); - }); + BookMenu.open(moderator, "Report #" + report.id(), page -> { + page.title("REPORT DETAILS") + .blank() + .paragraph("§0" + report.reporterName() + " §0reported §6" + report.targetName() + " §0for §c" + report.reason() + "§0.") + .blank(); + + if (report.message() != null && !report.message().isBlank() && !report.message().equalsIgnoreCase("General player report") && !report.message().equalsIgnoreCase("None")) { + page.line("§0Message:") + .quote(report.message()) + .blank(); + } - bookMenu.open(moderator); + page.field("Submitted", "§8" + dateText) + .blank() + .actionButton("§4§l[DISMISS REPORT]", "§eClick to dismiss this report", "/reports dismiss " + report.id()); + }); } } From 4fc74c02826eb028fe56ff3c9d1f9712fb75802a Mon Sep 17 00:00:00 2001 From: Moussa Date: Wed, 30 Sep 2026 11:11:20 +0200 Subject: [PATCH 06/23] test(book): add unit tests for BookPage, BookPaginator, and BookMenu --- .../moussax/bedrock/ui/book/BookMenuTest.java | 436 ++++++++++++++++++ 1 file changed, 436 insertions(+) create mode 100644 bedrock/src/test/java/fr/moussax/bedrock/ui/book/BookMenuTest.java diff --git a/bedrock/src/test/java/fr/moussax/bedrock/ui/book/BookMenuTest.java b/bedrock/src/test/java/fr/moussax/bedrock/ui/book/BookMenuTest.java new file mode 100644 index 00000000..6a1998c8 --- /dev/null +++ b/bedrock/src/test/java/fr/moussax/bedrock/ui/book/BookMenuTest.java @@ -0,0 +1,436 @@ +package fr.moussax.bedrock.ui.book; + +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.ClickEvent; +import net.md_5.bungee.api.chat.HoverEvent; +import net.md_5.bungee.api.chat.TextComponent; +import org.bukkit.inventory.meta.BookMeta; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class BookMenuTest { + + @Test + @DisplayName("BookCanvas accurately measures standard and styled text pixel widths") + void shouldMeasurePixelWidthsAccurately() { + assertEquals(0, BookCanvas.measurePixelWidth("")); + assertEquals(0, BookCanvas.measurePixelWidth(null)); + + // '!' is 1px + 1px spacing = 2px + assertEquals(2, BookCanvas.measurePixelWidth("!")); + + // 'a' is 5px + 1px spacing = 6px + assertEquals(6, BookCanvas.measurePixelWidth("a")); + + // Formatting codes (e.g. §c) have 0 width + assertEquals(6, BookCanvas.measurePixelWidth("§ca")); + + // Ampersand formatting codes (&c, &l) are also properly normalized and measured + assertEquals(6, BookCanvas.measurePixelWidth("&ca")); + assertEquals(7, BookCanvas.measurePixelWidth("&la")); + + // Bold (§l) adds 1px per character: 'a' bold is 6 + 1 = 7px + assertEquals(7, BookCanvas.measurePixelWidth("§la")); + } + + @Test + @DisplayName("BookCanvas centers text horizontally on the 114px canvas") + void shouldCenterTextCorrectly() { + String centered = BookCanvas.center("TEST"); + assertTrue(centered.startsWith(" "), "Centered text should have prepended space padding"); + assertTrue(centered.endsWith("TEST")); + } + + @Test + @DisplayName("BookPage creates formatted titles, text, and fields") + void shouldBuildPageWithStructure() { + BookPage page = new BookPage() + .title("RULES") + .blank() + .field("Category", "Gameplay") + .quote("No cheating") + .blank(); + + BaseComponent[] components = page.build(); + assertTrue(components.length > 0); + + StringBuilder plainText = new StringBuilder(); + for (BaseComponent component : components) { + plainText.append(component.toPlainText()); + } + + String fullContent = plainText.toString(); + assertTrue(fullContent.contains("RULES")); + assertTrue(fullContent.contains("Category: Gameplay")); + assertTrue(fullContent.contains("\"No cheating\"")); + } + + @Test + @DisplayName("BookPage creates buttons with RUN_COMMAND and CHANGE_PAGE") + void shouldCreateInteractiveButtonsAndLinks() { + BookPage page = new BookPage() + .button("§c[Dismiss]", "§7Click to dismiss", "/dismiss 123") + .pageButton("§9[Next]", "§7Go to page 2", 2); + + BaseComponent[] components = page.build(); + assertTrue(components.length >= 2); + + // Find the dismiss button + TextComponent dismissComponent = findComponentWithText(components, "[Dismiss]"); + assertNotNull(dismissComponent); + assertNotNull(dismissComponent.getClickEvent()); + assertEquals(ClickEvent.Action.RUN_COMMAND, dismissComponent.getClickEvent().getAction()); + assertEquals("/dismiss 123", dismissComponent.getClickEvent().getValue()); + assertNotNull(dismissComponent.getHoverEvent()); + assertEquals(HoverEvent.Action.SHOW_TEXT, dismissComponent.getHoverEvent().getAction()); + + // Find the page link component + TextComponent pageLinkComponent = findComponentWithText(components, "[Next]"); + assertNotNull(pageLinkComponent); + assertNotNull(pageLinkComponent.getClickEvent()); + assertEquals(ClickEvent.Action.CHANGE_PAGE, pageLinkComponent.getClickEvent().getAction()); + assertEquals("2", pageLinkComponent.getClickEvent().getValue()); + } + + @Test + @DisplayName("BookPage sanitizes command clicks by automatically prepending slash if missing") + void shouldSanitizeCommandsWithSlash() { + BookPage page = new BookPage() + .button("§c[Action]", "§7Run action", "say hello"); + + BaseComponent[] components = page.build(); + TextComponent button = findComponentWithText(components, "[Action]"); + assertNotNull(button); + assertNotNull(button.getClickEvent()); + assertEquals("/say hello", button.getClickEvent().getValue()); + } + + @Test + @DisplayName("BookPage automatically wraps long paragraphs and lore lines") + void shouldWrapParagraphAndLoreAccurately() { + BookPage page = new BookPage() + .paragraph("This is a paragraph of lore that exceeds the 114 pixel width of a standard Minecraft written book page and wraps neatly.") + .lore("Short line", "Another moderately long lore description that will undergo automatic pixel width wrapping."); + + assertTrue(page.lineCount() >= 3, "Wrapped paragraph and lore should produce multiple lines"); + assertTrue(page.remainingLines() < 12); + + BaseComponent[] components = page.build(); + StringBuilder text = new StringBuilder(); + for (BaseComponent component : components) { + text.append(component.toPlainText()); + } + + String content = text.toString(); + assertTrue(content.contains("This is a paragraph")); + assertTrue(content.contains("Short line")); + assertTrue(content.contains("Another moderately")); + assertTrue(content.contains("long lore")); + } + + @Test + @DisplayName("BookPage creates centered buttons, links, page navigation, suggestions, and copy") + void shouldCreateCenteredButtonsAndLinks() { + BookPage page = new BookPage() + .centeredButton("[Command]", "Run Command", "test command") + .centeredLink("[Website]", "Open Web", "https://blighted.net") + .centeredPageButton("[Next Page]", "Go Next", 3) + .actionCopy("[IP]", "Copy IP", "play.blighted.net"); + + BaseComponent[] components = page.build(); + + TextComponent link = findComponentWithText(components, "[Website]"); + assertNotNull(link); + assertNotNull(link.getClickEvent()); + assertEquals(ClickEvent.Action.OPEN_URL, link.getClickEvent().getAction()); + assertEquals("https://blighted.net", link.getClickEvent().getValue()); + + TextComponent pageBtn = findComponentWithText(components, "[Next Page]"); + assertNotNull(pageBtn); + assertNotNull(pageBtn.getClickEvent()); + assertEquals(ClickEvent.Action.CHANGE_PAGE, pageBtn.getClickEvent().getAction()); + assertEquals("3", pageBtn.getClickEvent().getValue()); + + TextComponent copy = findComponentWithText(components, "[IP]"); + assertNotNull(copy); + assertNotNull(copy.getClickEvent()); + assertEquals(ClickEvent.Action.COPY_TO_CLIPBOARD, copy.getClickEvent().getAction()); + assertEquals("play.blighted.net", copy.getClickEvent().getValue()); + + // Verify leading whitespace padding is inert (no click or hover event) + BaseComponent firstComponent = components[0]; + assertNull(firstComponent.getClickEvent(), "Leading center padding should be inert without click event"); + assertNull(firstComponent.getHoverEvent(), "Leading center padding should be inert without hover event"); + } + + @Test + @DisplayName("BookPage supports Collection overload for lines") + void shouldSupportCollectionLines() { + BookPage page = new BookPage().lines(List.of("Line 1", "Line 2")); + assertEquals(2, page.lineCount()); + } + + @Test + @DisplayName("BookPage automatically wraps long field values and quotes across lines") + void shouldWrapLongFieldsAndQuotes() { + BookPage page = new BookPage() + .field("Reason", "Player was caught using flying cheats and speed hacks in the warzone during combat") + .quote("In the ancient forgotten depths of the underworld, eternal darkness reigns supreme without mercy."); + + assertTrue(page.lineCount() >= 4, "Long field and long quote should wrap into multiple lines"); + assertTrue(page.remainingLines() <= 10); + + BaseComponent[] components = page.build(); + StringBuilder text = new StringBuilder(); + for (BaseComponent component : components) { + text.append(component.toPlainText()); + } + + String content = text.toString(); + assertTrue(content.contains("Reason:")); + assertTrue(content.contains("flying")); + assertTrue(content.contains("cheats")); + assertTrue(content.contains("forgotten depths")); + } + + @Test + @DisplayName("BookPage supports concise fluent DSL without add prefix") + void shouldSupportConciseFluentPageDsl() { + BookPage page = new BookPage() + .title("WELCOME") + .blank() + .field("Status", "Active") + .quote("Stay vigilant") + .paragraph("This is a concise paragraph.") + .lore("Ancient lore line 1", "Ancient lore line 2") + .blank() + .actionButton("[Confirm]", "Click to confirm", "confirm"); + + assertTrue(page.lineCount() >= 8); + BaseComponent[] components = page.build(); + assertTrue(components.length > 0); + + StringBuilder sb = new StringBuilder(); + for (BaseComponent c : components) { + sb.append(c.toPlainText()); + } + String content = sb.toString(); + assertTrue(content.contains("WELCOME")); + assertTrue(content.contains("Status: Active")); + assertTrue(content.contains("\"Stay vigilant\"")); + assertTrue(content.contains("concise")); + assertTrue(content.contains("paragraph")); + assertTrue(content.contains("Ancient lore line 1")); + assertTrue(content.contains("[Confirm]")); + } + + @Test + @DisplayName("BookPage supports action buttons and fits evaluation") + void shouldSupportActionButtonsAndFitEvaluation() { + BookPage page = new BookPage() + .actionButton("[Action Command]", "Execute action", "say hi") + .actionLink("[Wiki]", "Read wiki", "https://wiki.blighted.net") + .actionPage("[Next]", "Page 2", 2); + + assertEquals(3, page.lineCount()); + assertTrue(page.fits("Short text")); + assertFalse(page.fits("This is an extremely long multi-paragraph text that spans so many lines that it definitely cannot fit inside the remaining 11 lines of this single page. It goes on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on and on.")); + } + + @Test + @DisplayName("BookPage tracks line count and remaining line capacity accurately") + void shouldTrackLineBudget() { + BookPage page = new BookPage(); + assertEquals(0, page.lineCount()); + assertEquals(14, page.remainingLines()); + assertFalse(page.isFull()); + + page.line("Line 1"); + page.line("Line 2"); + page.blank(); + + assertEquals(3, page.lineCount()); + assertEquals(11, page.remainingLines()); + + for (int i = 0; i < 11; i++) { + page.line("Fill " + i); + } + + assertEquals(14, page.lineCount()); + assertEquals(0, page.remainingLines()); + assertTrue(page.isFull()); + } + + @Test + @DisplayName("BookPaginator splits lines across multiple pages within 14-line limit") + void shouldPaginateLinesAcrossPages() { + List thirtyLines = new ArrayList<>(); + for (int i = 1; i <= 30; i++) { + thirtyLines.add("Line " + i); + } + + List pages = BookPaginator.paginateLines(thirtyLines); + // 30 lines / 14 max per page = 3 pages (14, 14, 2) + assertEquals(3, pages.size()); + } + + @Test + @DisplayName("BookPaginator wraps long continuous text at 114 pixels") + void shouldWrapLongTextIntoPages() { + String longText = "This is a very long paragraph that will definitely exceed the standard one hundred and fourteen pixel canvas width of a Minecraft written book page and must be wrapped into multiple lines cleanly."; + List pages = BookPaginator.paginateText(longText); + + assertFalse(pages.isEmpty()); + assertTrue(pages.getFirst().build().length > 0); + } + + @Test + @DisplayName("BookMenu builder sets metadata and builds pages") + void shouldBuildBookModel() { + BookMenu menu = BookMenu.builder() + .title("Custom Title") + .author("Moussa") + .generation(BookMeta.Generation.ORIGINAL) + .page(page -> page.title("PAGE 1").line("Hello")) + .page(page -> page.title("PAGE 2").line("World")) + .paginatedText("Extra long text paginated") + .paginatedLines(List.of("Line A", "Line B")); + + assertEquals("Custom Title", menu.title()); + assertEquals("Moussa", menu.author()); + assertEquals(BookMeta.Generation.ORIGINAL, menu.generation()); + assertEquals(4, menu.pages().size()); + } + + @Test + @DisplayName("BookPage automatically spills excess lines onto consecutive pages in buildPages") + void shouldAutoSpillExcessLinesAcrossPages() { + BookPage page = new BookPage(); + for (int i = 1; i <= 14; i++) { + page.line("Line " + i); + } + page.actionButton("[Spilled Action]", "Click spilled", "spilled"); + + assertEquals(15, page.lineCount()); + assertEquals(2, page.pageCount()); + + List pages = page.buildPages(); + assertEquals(2, pages.size(), "15 lines should automatically spill into 2 pages"); + + // First page should contain Line 1 + TextComponent line1 = findComponentWithText(pages.get(0), "Line 1"); + assertNotNull(line1); + + // Second page should contain [Spilled Action] + TextComponent action = findComponentWithText(pages.get(1), "[Spilled Action]"); + assertNotNull(action, "Spilled action button should be rendered on the second page"); + } + + @Test + @DisplayName("BookMenu automatically resolves logical page jumps when previous pages spill") + void shouldResolveLogicalPageJumpsAcrossSpilledPages() { + BookMenu menu = BookMenu.builder() + .page(page -> { + // Fill 14 lines + 2 buttons = 16 lines -> spills across physical pages 1 and 2 + for (int i = 1; i <= 14; i++) { + page.line("TOC line " + i); + } + page.actionPage("[Go to Chapter 2]", "Chapter 2", 2); + page.actionPage("[Go to Chapter 3]", "Chapter 3", 3); + }) + .page(page -> page + .title("CHAPTER 2") + .line("Chapter 2 content") + .actionPage("[Back to TOC]", "Return to TOC", 1) + ) + .page(page -> page + .title("CHAPTER 3") + .line("Chapter 3 content") + ); + + List physicalPages = menu.buildPhysicalPages(); + // Page 1 has 16 lines -> 2 physical pages (Physical 1 & 2) + // Page 2 is 1 physical page (Physical 3) + // Page 3 is 1 physical page (Physical 4) + assertEquals(4, physicalPages.size()); + + // Buttons on Page 1 are on Physical Page 2 + TextComponent ch2Btn = findComponentWithText(physicalPages.get(1), "[Go to Chapter 2]"); + assertNotNull(ch2Btn); + assertNotNull(ch2Btn.getClickEvent()); + assertEquals(ClickEvent.Action.CHANGE_PAGE, ch2Btn.getClickEvent().getAction()); + assertEquals("3", ch2Btn.getClickEvent().getValue(), "Logical page 2 must resolve to physical page 3"); + + TextComponent ch3Btn = findComponentWithText(physicalPages.get(1), "[Go to Chapter 3]"); + assertNotNull(ch3Btn); + assertNotNull(ch3Btn.getClickEvent()); + assertEquals("4", ch3Btn.getClickEvent().getValue(), "Logical page 3 must resolve to physical page 4"); + + // Back button on Chapter 2 (Physical Page 3) + TextComponent backBtn = findComponentWithText(physicalPages.get(2), "[Back to TOC]"); + assertNotNull(backBtn); + assertNotNull(backBtn.getClickEvent()); + assertEquals("1", backBtn.getClickEvent().getValue(), "Logical page 1 must resolve to physical page 1"); + } + + @Test + @DisplayName("BookMenu resolves named page jumps across spilled pages") + void shouldResolveNamedPageJumpsAcrossSpilledPages() { + BookMenu menu = BookMenu.builder() + .page("toc", page -> { + for (int i = 1; i <= 14; i++) { + page.line("TOC line " + i); + } + page.actionPage("[Go Beasts]", "Read beasts", "beasts"); + page.actionPage("[Go Limits]", "Read limits", "LIMITS"); + }) + .page("beasts", page -> page + .title("BEASTS") + .actionPage("[Back]", "Return", "toc") + ) + .page("limits", page -> page + .title("LIMITS") + .actionPage("[Back]", "Return", "toc") + ); + + List physicalPages = menu.buildPhysicalPages(); + assertEquals(4, physicalPages.size()); + + // On TOC (Physical Page 2) + TextComponent beastsBtn = findComponentWithText(physicalPages.get(1), "[Go Beasts]"); + assertNotNull(beastsBtn); + assertEquals("3", beastsBtn.getClickEvent().getValue(), "Named 'beasts' target should resolve to physical page 3"); + + TextComponent limitsBtn = findComponentWithText(physicalPages.get(1), "[Go Limits]"); + assertNotNull(limitsBtn); + assertEquals("4", limitsBtn.getClickEvent().getValue(), "Case-insensitive 'LIMITS' target should resolve to physical page 4"); + + // On Beasts (Physical Page 3) + TextComponent backBtn = findComponentWithText(physicalPages.get(2), "[Back]"); + assertNotNull(backBtn); + assertEquals("1", backBtn.getClickEvent().getValue(), "Named 'toc' target should resolve to physical page 1"); + } + + private TextComponent findComponentWithText(BaseComponent[] components, String substring) { + for (BaseComponent component : components) { + if (component instanceof TextComponent textComponent) { + if (textComponent.getText() != null && textComponent.getText().contains(substring)) { + return textComponent; + } + } + if (component.getExtra() != null) { + for (BaseComponent extra : component.getExtra()) { + if (extra instanceof TextComponent extraText && extraText.getText() != null && extraText.getText().contains(substring)) { + return extraText; + } + } + } + } + return null; + } +} From 2ef3d949654714cd6221634669f045699c6d66ae Mon Sep 17 00:00:00 2001 From: Moussa Date: Wed, 30 Sep 2026 13:45:49 +0200 Subject: [PATCH 07/23] refactor(menu): modernize Menu and PaginatedMenu API with framed layouts and unified navigation - Streamline Menu by pruning redundant row/column overloads and introducing sound helpers (playClickSound, playPageTurnSound, playErrorSound) alongside differential slot updating. - Enhance PaginatedMenu with standard 54-slot framed layout support, automatic parent back-button routing on page 0, and page-turn sound feedback. - Ensure frameless paginated menus cleanly omit next-page controls on the final page without rendering unwanted filler panes. - Deduplicate custom pagination logic in RecipeBookMenu, ForgeRecipesMenu, and ItemRegistryMenu. - Add unit tests verifying 2D slot calculation, interaction routing, and inventory geometry. --- .../java/fr/moussax/bedrock/ui/menu/Menu.java | 226 ++++++------------ .../menu/interaction/MenuElementPreset.java | 10 + .../bedrock/ui/menu/system/MenuListener.java | 4 + .../bedrock/ui/menu/system/MenuSystem.java | 4 +- .../bedrock/ui/menu/types/PaginatedMenu.java | 153 ++++++++++-- .../fr/moussax/bedrock/ui/menu/MenuTest.java | 73 ++++++ .../rituals/menu/AncientRitualsMenu.java | 5 +- .../items/recipes/RecipePreviewManager.java | 5 +- .../menu/CraftingRecipePreviewMenu.java | 15 +- .../crafting/menu/CraftingTableMenu.java | 3 +- .../recipes/crafting/menu/RecipeBookMenu.java | 100 +------- .../forging/menu/ForgeRecipePreviewMenu.java | 9 +- .../forging/menu/ForgeRecipesMenu.java | 102 +------- .../items/registry/menu/ItemRegistryMenu.java | 17 +- 14 files changed, 338 insertions(+), 388 deletions(-) create mode 100644 bedrock/src/test/java/fr/moussax/bedrock/ui/menu/MenuTest.java 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 2765c7a7..29542149 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 @@ -9,6 +9,7 @@ import lombok.Getter; import org.bukkit.Bukkit; import org.bukkit.Material; +import org.bukkit.Sound; import org.bukkit.entity.Player; import org.bukkit.event.inventory.ClickType; import org.bukkit.inventory.Inventory; @@ -35,13 +36,9 @@ */ public abstract class Menu implements InventoryHolder { - @Getter protected String title; protected final int size; - - @Getter protected final Map slots = new HashMap<>(); - protected Inventory inventory; protected UUID viewerId; protected MenuSystem menuSystem; @@ -127,6 +124,26 @@ protected Player getPlayer() { return viewerId != null ? Bukkit.getPlayer(viewerId) : null; } + /** + * Returns the title of this menu. + * + * @return menu title + */ + @NonNull + public String getTitle() { + return title; + } + + /** + * Returns the slot definitions for this menu. + * + * @return map of slot index to menu slot + */ + @NonNull + public Map getSlots() { + return slots; + } + /** * Places an item in a slot with an interaction-specific action. * @@ -250,7 +267,7 @@ public void setItem(int slot, @NonNull MenuElementPreset preset, @NonNull MenuAc * @param slot inventory slot index */ public void setCloseButton(int slot) { - setItem(slot, MenuElementPreset.CLOSE_BUTTON, MenuItemInteraction.ANY_CLICK, (player, type) -> close()); + setItem(slot, MenuElementPreset.CLOSE_BUTTON, (player, _) -> close()); } /** @@ -260,7 +277,7 @@ public void setCloseButton(int slot) { * @param action action executed when clicked */ public void setBackButton(int slot, @NonNull MenuAction action) { - setItem(slot, MenuElementPreset.BACK_BUTTON, MenuItemInteraction.ANY_CLICK, action); + setItem(slot, MenuElementPreset.BACK_BUTTON, action); } /** @@ -275,6 +292,7 @@ public void setBackButton(int slot, @Nullable Menu previousMenu) { goBack(); return; } + playClickSound(player); if (menuSystem != null) { menuSystem.popAndOpen(player, previousMenu); } else { @@ -478,10 +496,10 @@ public Inventory getInventory() { */ public void openSubMenu(@NonNull Menu submenu) { Player player = getPlayer(); - if (player != null && menuSystem != null) { - submenu.setMenuSystem(menuSystem); - submenu.open(player); - } + if (player == null || menuSystem == null) return; + + submenu.setMenuSystem(menuSystem); + submenu.open(player); } /** @@ -489,7 +507,10 @@ public void openSubMenu(@NonNull Menu submenu) { */ public void goBack() { Player player = getPlayer(); - if (player != null && menuSystem != null) { + if (player == null) return; + + playClickSound(player); + if (menuSystem != null) { menuSystem.goBack(player); } } @@ -499,174 +520,48 @@ public void goBack() { */ public void close() { Player player = getPlayer(); - if (player != null) { - player.closeInventory(); - } - } - - /** - * Converts row and column coordinates into an inventory slot index. - * - * @param row zero-based row index - * @param column zero-based column index - * @return corresponding inventory slot index - */ - public static int getSlot(int row, int column) { - return row * 9 + column; - } - - /** - * Places an item at the specified row and column. - * - * @param row zero-based row index - * @param column zero-based column index - * @param item item displayed in the slot - * @param interaction interaction that triggers the action - * @param action action executed when triggered - */ - public void setItem(int row, int column, @NonNull ItemStack item, @NonNull MenuItemInteraction interaction, @NonNull MenuAction action) { - setItem(getSlot(row, column), item, interaction, action); - } - - /** - * Places an item at the specified row and column with separate left- and right-click actions. - * - * @param row zero-based row index - * @param column zero-based column index - * @param item item displayed in the slot - * @param onLeft action executed on left-click - * @param onRight action executed on right-click - */ - public void setItem(int row, int column, @NonNull ItemStack item, @NonNull MenuAction onLeft, @NonNull MenuAction onRight) { - setItem(getSlot(row, column), item, onLeft, onRight); - } - - /** - * Places an item at the specified row and column with an ANY_CLICK action. - * - * @param row zero-based row index - * @param column zero-based column index - * @param item item displayed in the slot - * @param action action executed on click - */ - public void setItem(int row, int column, @NonNull ItemStack item, @NonNull MenuAction action) { - setItem(getSlot(row, column), item, action); - } + if (player == null) return; - /** - * Places a static item at the specified row and column. - * - * @param row zero-based row index - * @param column zero-based column index - * @param item item displayed in the slot - */ - public void setItem(int row, int column, @NonNull ItemStack item) { - setItem(getSlot(row, column), item, (player, type) -> { - }); + playClickSound(player); + player.closeInventory(); } /** - * Builds and places an item at the specified row and column. + * Plays the standard UI button click sound to the specified player. * - * @param row zero-based row index - * @param column zero-based column index - * @param material base material - * @param builder item configuration callback - * @param action action executed on click + * @param player player to hear the sound */ - public void setItem(int row, int column, @NonNull Material material, @NonNull Consumer builder, @NonNull MenuAction action) { - setItem(getSlot(row, column), material, builder, action); + public void playClickSound(@NonNull Player player) { + player.playSound(player.getLocation(), Sound.UI_BUTTON_CLICK, 1.0f, 1.0f); } /** - * Builds and places a decorative item at the specified row and column. + * Plays the book page flip sound to the specified player. * - * @param row zero-based row index - * @param column zero-based column index - * @param material base material - * @param builder item configuration callback + * @param player player to hear the sound */ - public void setItem(int row, int column, @NonNull Material material, @NonNull Consumer builder) { - setItem(getSlot(row, column), material, builder); + public void playPageTurnSound(@NonNull Player player) { + player.playSound(player.getLocation(), Sound.ITEM_BOOK_PAGE_TURN, 1.0f, 1.0f); } /** - * Places a preset item at the specified row and column with an ANY_CLICK action. + * Plays the error/denial sound to the specified player. * - * @param row zero-based row index - * @param column zero-based column index - * @param preset item preset - * @param action action executed on click + * @param player player to hear the sound */ - public void setItem(int row, int column, @NonNull MenuElementPreset preset, @NonNull MenuAction action) { - setItem(getSlot(row, column), preset, action); + public void playErrorSound(@NonNull Player player) { + player.playSound(player.getLocation(), Sound.ENTITY_ENDERMAN_TELEPORT, 1.0f, 0.5f); } /** - * Places a preset item at the specified row and column. + * Converts row and column coordinates into an inventory slot index. * * @param row zero-based row index * @param column zero-based column index - * @param preset item preset - */ - public void setItem(int row, int column, @NonNull MenuElementPreset preset) { - setItem(getSlot(row, column), preset.getItem()); - } - - /** - * Fills the outermost row and column slots with an item. - * - * @param item item displayed on the border - */ - public void fillBorder(@NonNull ItemStack item) { - int rows = size / 9; - for (int row = 0; row < rows; row++) { - for (int column = 0; column < 9; column++) { - if (row == 0 || row == rows - 1 || column == 0 || column == 8) { - setItem(getSlot(row, column), item); - } - } - } - } - - /** - * Fills the outermost row and column slots with a preset. - * - * @param preset item preset displayed on the border - */ - public void fillBorder(@NonNull MenuElementPreset preset) { - fillBorder(preset.getItem()); - } - - /** - * Fills slots matching a symbol in a two-dimensional pattern. - * - *

Spaces are ignored, allowing patterns to be formatted for readability.

- * - * @param pattern pattern rows - * @param symbol character identifying slots to fill - * @param item item displayed in matching slots - */ - public void fillPattern(String[] pattern, char symbol, @NonNull ItemStack item) { - for (int row = 0; row < pattern.length && row < size / 9; row++) { - String rowString = pattern[row]; - for (int column = 0; column < rowString.length() && column < 9; column++) { - if (rowString.charAt(column) == symbol) { - setItem(getSlot(row, column), item); - } - } - } - } - - /** - * Fills slots matching a symbol in a two-dimensional pattern with a preset. - * - * @param pattern pattern rows - * @param symbol character identifying slots to fill - * @param preset item preset displayed in matching slots + * @return corresponding inventory slot index */ - public void fillPattern(String[] pattern, char symbol, @NonNull MenuElementPreset preset) { - fillPattern(pattern, symbol, preset.getItem()); + public static int getSlot(int row, int column) { + return row * 9 + column; } /** @@ -784,6 +679,25 @@ protected void setSlotItem(int slot, @Nullable ItemStack item) { } } + /** + * Updates a single slot only if the specified item differs from the currently displayed item. + * + *

Useful for {@link TickableMenu} implementations to avoid full rebuilds and eliminate + * client-side rendering flicker.

+ * + * @param slot inventory slot index + * @param item new item for the slot + * @return {@code true} if the slot was updated, {@code false} if it already matched + */ + public boolean updateSlotItem(int slot, @Nullable ItemStack item) { + ItemStack current = inventory.getItem(slot); + if (isSameItem(current, item)) { + return false; + } + setSlotItem(slot, item); + return true; + } + /** * Represents an inventory slot and its registered click actions. */ diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/menu/interaction/MenuElementPreset.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/menu/interaction/MenuElementPreset.java index 51071e5c..0648eb3a 100644 --- a/bedrock/src/main/java/fr/moussax/bedrock/ui/menu/interaction/MenuElementPreset.java +++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/menu/interaction/MenuElementPreset.java @@ -12,6 +12,7 @@ public enum MenuElementPreset { CLOSE_BUTTON(new ItemBuilder(Material.BARRIER, "§cClose").toItemStack()), BACK_BUTTON(new ItemBuilder(Material.ARROW, "§aGo Back").toItemStack()), + PREVIOUS_BUTTON(new ItemBuilder(Material.ARROW, "§aPrevious Page").toItemStack()), NEXT_BUTTON(new ItemBuilder(Material.ARROW, "§aNext Page").toItemStack()), EMPTY_SLOT_FILLER(new ItemBuilder(Material.BLACK_STAINED_GLASS_PANE, "§r").hideTooltip().toItemStack()); @@ -25,4 +26,13 @@ public enum MenuElementPreset { MenuElementPreset(ItemStack item) { this.item = item; } + + /** + * Returns an isolated clone of the item representing this preset. + * + * @return cloned item stack + */ + public ItemStack getItem() { + return item.clone(); + } } diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/menu/system/MenuListener.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/menu/system/MenuListener.java index 8501319b..cc52c38f 100644 --- a/bedrock/src/main/java/fr/moussax/bedrock/ui/menu/system/MenuListener.java +++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/menu/system/MenuListener.java @@ -14,6 +14,10 @@ import org.bukkit.inventory.Inventory; import org.jspecify.annotations.NonNull; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + public final class MenuListener implements Listener { private final MenuSystem menuSystem; diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/menu/system/MenuSystem.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/menu/system/MenuSystem.java index b42f061c..62cc8d63 100644 --- a/bedrock/src/main/java/fr/moussax/bedrock/ui/menu/system/MenuSystem.java +++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/menu/system/MenuSystem.java @@ -50,7 +50,9 @@ private void tickActiveMenus() { for (Map.Entry entry : activeMenus.entrySet()) { try { if (!(entry.getValue() instanceof TickableMenu tickable)) continue; - if (tickCounter % Math.max(1L, tickable.tickPeriodTicks()) != 0) continue; + long period = Math.max(1L, tickable.tickPeriodTicks()); + long offset = Math.floorMod(entry.getKey().hashCode(), period); + if ((tickCounter + offset) % period != 0) continue; Player player = plugin.getServer().getPlayer(entry.getKey()); if (player != null && player.isOnline()) { diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/menu/types/PaginatedMenu.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/menu/types/PaginatedMenu.java index 9661dca2..fe195114 100644 --- a/bedrock/src/main/java/fr/moussax/bedrock/ui/menu/types/PaginatedMenu.java +++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/menu/types/PaginatedMenu.java @@ -2,12 +2,15 @@ import fr.moussax.bedrock.ui.menu.Menu; import fr.moussax.bedrock.ui.menu.interaction.MenuElementPreset; -import fr.moussax.bedrock.ui.menu.interaction.MenuItemInteraction; +import fr.moussax.bedrock.utils.ItemBuilder; import lombok.Getter; +import org.bukkit.ChatColor; +import org.bukkit.Material; import org.bukkit.entity.Player; import org.bukkit.event.inventory.ClickType; import org.bukkit.inventory.ItemStack; import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; /** * Base class for menus with automatic pagination. @@ -15,6 +18,10 @@ *

Items are displayed across pages, with navigation controls rendered in * the bottom row. The current page is preserved when the menu is refreshed.

* + *

Supports optional framed layouts via {@link #useStandardFrame()}, parent + * menu back navigation via {@link #getPreviousMenu()}, and title hooks via + * {@link #onBuildHeader(Player)}.

+ * *

Subclasses provide the total item count, item contents, and optional * click handling through {@link #onItemClick(Player, int, ClickType)}.

*/ @@ -27,6 +34,14 @@ public abstract class PaginatedMenu extends Menu { 37, 38, 39, 40, 41, 42, 43 }; + public static final int[] FRAME_SLOTS = { + 0, 1, 2, 3, 4, 5, 6, 7, 8, + 9, 17, 18, 26, 27, 35, 36, + 44, 45, 46, 47, 51, 52, 53 + }; + + @Getter + protected final Menu previousMenu; @Getter protected int currentPage = 0; protected int totalItems = 0; @@ -47,7 +62,19 @@ public int getTotalItemsCount() { * @param size inventory size (multiple of 9) */ protected PaginatedMenu(String title, int size) { + this(title, size, null); + } + + /** + * Creates a paginated menu with a link to a previous parent menu. + * + * @param title menu title + * @param size inventory size (multiple of 9) + * @param previousMenu parent menu to open when returning from page 0 + */ + protected PaginatedMenu(String title, int size, @Nullable Menu previousMenu) { super(title, size); + this.previousMenu = previousMenu; } /** @@ -67,6 +94,40 @@ protected PaginatedMenu(String title, int size) { */ protected abstract ItemStack getItem(@NonNull Player player, int index); + /** + * Determines whether this menu uses the standard 54-slot framed layout. + * + *

When enabled, {@link #getDisplaySlots()} defaults to {@link #INNER_GRID_SLOTS} + * and the framing border slots are populated with {@link MenuElementPreset#EMPTY_SLOT_FILLER}.

+ * + * @return {@code true} to enable framed layout + */ + protected boolean useStandardFrame() { + return false; + } + + /** + * Fills the framing border slots with the specified item. + * + * @param item item displayed in the frame + */ + public void fillFrame(@NonNull ItemStack item) { + for (int slot : FRAME_SLOTS) { + if (slot < size) { + setItem(slot, item); + } + } + } + + /** + * Fills the framing border slots with the specified preset. + * + * @param preset item preset displayed in the frame + */ + public void fillFrame(@NonNull MenuElementPreset preset) { + fillFrame(preset.getItem()); + } + /** * Returns the target inventory slot indices for displaying paginated items. * @@ -75,7 +136,7 @@ protected PaginatedMenu(String title, int size) { * @return slot index array, or {@code null} for default sequential placement */ protected int[] getDisplaySlots() { - return null; + return useStandardFrame() ? INNER_GRID_SLOTS : null; } /** @@ -123,6 +184,33 @@ protected int getItemsPerPage() { return size - 9; } + /** + * Initializes pagination metrics and clamps {@code currentPage}. + * + * @param viewer player viewing the menu + */ + protected void initPage(@NonNull Player viewer) { + totalItems = Math.max(0, getTotalItems(viewer)); + int itemsPerPage = getItemsPerPage(); + if (itemsPerPage > 0 && totalItems > 0) { + int maxPage = (totalItems - 1) / itemsPerPage; + currentPage = Math.min(currentPage, maxPage); + } else { + currentPage = 0; + } + } + + /** + * Hook called at the start of {@link #build(Player)}, after {@link #initPage(Player)} + * and before slots are rendered. + * + *

Override to set dynamic titles (e.g. page counts).

+ * + * @param viewer player viewing the menu + */ + protected void onBuildHeader(@NonNull Player viewer) { + } + /** * Builds the current page and its navigation controls. * @@ -133,8 +221,13 @@ protected int getItemsPerPage() { */ @Override public void build(@NonNull Player viewer) { - totalItems = Math.max(0, getTotalItems(viewer)); + initPage(viewer); slots.clear(); + onBuildHeader(viewer); + + if (useStandardFrame()) { + fillFrame(MenuElementPreset.EMPTY_SLOT_FILLER); + } int closeSlot = size - 5; int backSlot = size - 6; @@ -144,16 +237,17 @@ public void build(@NonNull Player viewer) { if (totalItems == 0) { if (emptyItem != null) { int emptySlot = size >= 27 ? 22 : size / 2; - setItem(emptySlot, emptyItem, MenuItemInteraction.ANY_CLICK, (_, _) -> { }); + setItem(emptySlot, emptyItem); + } + if (useStandardFrame() && nextSlot < size) { + setItem(nextSlot, MenuElementPreset.EMPTY_SLOT_FILLER); } + renderParentBackButton(backSlot); setCloseButton(closeSlot); return; } int itemsPerPage = getItemsPerPage(); - int maxPage = (totalItems - 1) / itemsPerPage; - currentPage = Math.min(currentPage, maxPage); - int startIndex = currentPage * itemsPerPage; int endIndex = Math.min(startIndex + itemsPerPage, totalItems); @@ -165,7 +259,6 @@ public void build(@NonNull Player viewer) { setItem( slot, getItem(viewer, index), - MenuItemInteraction.ANY_CLICK, (player, click) -> { if (click.isShiftClick()) { onItemShiftClick(player, index); @@ -179,23 +272,59 @@ public void build(@NonNull Player viewer) { ); } + int totalPages = getTotalPages(); + int pageNum = getCurrentPageNumber(); + if (currentPage > 0) { - setBackButton(backSlot, (player, _) -> { + ItemStack prevItem = new ItemBuilder(Material.ARROW, "§aPrevious Page") + .addLore("§7Page " + (pageNum - 1) + "/" + totalPages) + .toItemStack(); + setItem(backSlot, prevItem, (player, _) -> { + playPageTurnSound(player); currentPage--; refresh(player); }); + } else { + renderParentBackButton(backSlot); } if (endIndex < totalItems) { - setItem(nextSlot, MenuElementPreset.NEXT_BUTTON, (player, _) -> { + ItemStack nextItem = new ItemBuilder(Material.ARROW, "§aNext Page") + .addLore("§7Page " + (pageNum + 1) + "/" + totalPages) + .toItemStack(); + setItem(nextSlot, nextItem, (player, _) -> { + playPageTurnSound(player); currentPage++; refresh(player); }); + } else if (useStandardFrame() && nextSlot < size) { + setItem(nextSlot, MenuElementPreset.EMPTY_SLOT_FILLER); } setCloseButton(closeSlot); } + private void renderParentBackButton(int slot) { + if (previousMenu == null) return; + + String rawTitle = previousMenu.getTitle(); + String stripped = ChatColor.stripColor(rawTitle); + String targetName = stripped.isBlank() ? "Previous Menu" : stripped; + + ItemStack backItem = new ItemBuilder(Material.ARROW, "§aGo Back") + .addLore("§7To " + targetName) + .toItemStack(); + + setItem(slot, backItem, (player, _) -> { + playClickSound(player); + if (menuSystem != null) { + menuSystem.popAndOpen(player, previousMenu); + } else { + previousMenu.open(player); + } + }); + } + /** * Called when a paginated item is clicked with a left-click. * @@ -203,7 +332,6 @@ public void build(@NonNull Player viewer) { * @param index global item index */ protected void onItemLeftClick(@NonNull Player player, int index) { - // override as needed } /** @@ -213,7 +341,6 @@ protected void onItemLeftClick(@NonNull Player player, int index) { * @param index global item index */ protected void onItemRightClick(@NonNull Player player, int index) { - // override as needed } /** @@ -223,7 +350,6 @@ protected void onItemRightClick(@NonNull Player player, int index) { * @param index global item index */ protected void onItemShiftClick(@NonNull Player player, int index) { - // override as needed } /** @@ -234,6 +360,5 @@ protected void onItemShiftClick(@NonNull Player player, int index) { * @param clickType click type */ protected void onItemClick(@NonNull Player player, int index, @NonNull ClickType clickType) { - // override as needed } } diff --git a/bedrock/src/test/java/fr/moussax/bedrock/ui/menu/MenuTest.java b/bedrock/src/test/java/fr/moussax/bedrock/ui/menu/MenuTest.java new file mode 100644 index 00000000..4ec2e373 --- /dev/null +++ b/bedrock/src/test/java/fr/moussax/bedrock/ui/menu/MenuTest.java @@ -0,0 +1,73 @@ +package fr.moussax.bedrock.ui.menu; + +import fr.moussax.bedrock.ui.menu.interaction.MenuItemInteraction; +import fr.moussax.bedrock.ui.menu.types.PaginatedMenu; +import org.bukkit.Material; +import org.bukkit.event.inventory.ClickType; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.*; + +class MenuTest { + + @Test + @DisplayName("Expects getSlot to correctly compute 2D row/column slot index") + void testGetSlot() { + assertEquals(0, Menu.getSlot(0, 0)); + assertEquals(8, Menu.getSlot(0, 8)); + assertEquals(9, Menu.getSlot(1, 0)); + assertEquals(49, Menu.getSlot(5, 4)); + assertEquals(53, Menu.getSlot(5, 8)); + } + + @Test + @DisplayName("Expects MenuSlot to route specific left and right click actions before falling back to ANY_CLICK") + void testMenuSlotDispatch() { + AtomicBoolean leftClicked = new AtomicBoolean(false); + AtomicBoolean rightClicked = new AtomicBoolean(false); + AtomicBoolean anyClicked = new AtomicBoolean(false); + + ItemStack item = new ItemStack(Material.STONE); + Menu.MenuSlot slot = new Menu.MenuSlot(item, MenuItemInteraction.LEFT_CLICK, (_, _) -> leftClicked.set(true)); + slot.addAction(MenuItemInteraction.RIGHT_CLICK, (_, _) -> rightClicked.set(true)); + + slot.handle(null, ClickType.LEFT); + assertTrue(leftClicked.get()); + assertFalse(rightClicked.get()); + + leftClicked.set(false); + slot.handle(null, ClickType.RIGHT); + assertFalse(leftClicked.get()); + assertTrue(rightClicked.get()); + + Menu.MenuSlot fallbackSlot = new Menu.MenuSlot(item, MenuItemInteraction.ANY_CLICK, (_, _) -> anyClicked.set(true)); + fallbackSlot.handle(null, ClickType.MIDDLE); + assertTrue(anyClicked.get()); + } + + @Test + @DisplayName("Expects standard framed grid and border slots to match 54-slot inventory geometry") + void testFramedSlotGeometry() { + assertEquals(28, PaginatedMenu.INNER_GRID_SLOTS.length); + assertEquals(23, PaginatedMenu.FRAME_SLOTS.length); + + // Verify no overlap between inner grid and frame slots + for (int inner : PaginatedMenu.INNER_GRID_SLOTS) { + for (int frame : PaginatedMenu.FRAME_SLOTS) { + assertNotEquals(inner, frame, "Inner slot " + inner + " cannot overlap frame slot " + frame); + } + } + + // Verify all frame and inner slots are within standard 54-slot bounds (0..53) + for (int inner : PaginatedMenu.INNER_GRID_SLOTS) { + assertTrue(inner >= 0 && inner < 54); + } + for (int frame : PaginatedMenu.FRAME_SLOTS) { + assertTrue(frame >= 0 && frame < 54); + } + } +} diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/rituals/menu/AncientRitualsMenu.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/rituals/menu/AncientRitualsMenu.java index a270b937..0991b977 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/rituals/menu/AncientRitualsMenu.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/rituals/menu/AncientRitualsMenu.java @@ -3,7 +3,6 @@ import fr.moussax.bedrock.text.Formatter; import fr.moussax.bedrock.ui.menu.Menu; import fr.moussax.bedrock.ui.menu.interaction.MenuElementPreset; -import fr.moussax.bedrock.ui.menu.interaction.MenuItemInteraction; import fr.moussax.bedrock.utils.ItemBuilder; import fr.moussax.blightedSMP.engine.entities.rituals.AncientRitual; import fr.moussax.blightedSMP.engine.entities.rituals.registry.RitualRegistry; @@ -63,7 +62,7 @@ public void build(@NonNull Player player) { for (int i = 0; i < cachedRituals.size() && i < RITUAL_SLOTS.length; i++) { AncientRitual ritual = cachedRituals.get(i); ItemStack displayItem = buildRiteDisplayItem(ritual); - setItem(RITUAL_SLOTS[i], displayItem, MenuItemInteraction.ANY_CLICK, (clickingPlayer, _) -> openSubMenu(new RitualAltarMenu(ritual, this))); + setItem(RITUAL_SLOTS[i], displayItem, (clickingPlayer, _) -> openSubMenu(new RitualAltarMenu(ritual, this))); } if (previousMenu != null) { @@ -71,7 +70,7 @@ public void build(@NonNull Player player) { ItemStack backItem = new ItemBuilder(Material.ARROW, "§aGo Back") .addLore("§7To " + targetName) .toItemStack(); - setItem(BACK_BUTTON_SLOT, backItem, MenuItemInteraction.ANY_CLICK, (clickingPlayer, _) -> openSubMenu(previousMenu)); + setItem(BACK_BUTTON_SLOT, backItem, (clickingPlayer, _) -> openSubMenu(previousMenu)); } setCloseButton(CLOSE_BUTTON_SLOT); diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/recipes/RecipePreviewManager.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/recipes/RecipePreviewManager.java index c066fc32..b7e3a5d2 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/recipes/RecipePreviewManager.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/recipes/RecipePreviewManager.java @@ -10,7 +10,6 @@ import fr.moussax.blightedSMP.engine.items.recipes.forging.registry.ForgeRegistry; import fr.moussax.bedrock.ui.menu.Menu; import fr.moussax.bedrock.ui.menu.interaction.MenuElementPreset; -import fr.moussax.bedrock.ui.menu.interaction.MenuItemInteraction; import fr.moussax.bedrock.utils.ItemBuilder; import fr.moussax.blightedSMP.utils.Utilities; import org.bukkit.Material; @@ -139,7 +138,7 @@ public static void setupNavigation(@NonNull Menu menu, @NonNull Object recipe, @ if (currentIndex > 0) { Object previousRecipeObject = allRecipes.get(currentIndex - 1); ItemStack previousRecipeItem = new ItemBuilder(Material.ARROW, "§aPrevious Recipe").toItemStack(); - menu.setItem(48, previousRecipeItem, MenuItemInteraction.ANY_CLICK, (clickingPlayer, _) -> + menu.setItem(48, previousRecipeItem, (clickingPlayer, _) -> openPreview(clickingPlayer, previousRecipeObject, targetItem, previousMenu) ); } else if (previousMenu != null) { @@ -149,7 +148,7 @@ public static void setupNavigation(@NonNull Menu menu, @NonNull Object recipe, @ if (currentIndex < totalRecipes - 1) { Object nextRecipeObject = allRecipes.get(currentIndex + 1); ItemStack nextRecipeItem = new ItemBuilder(Material.ARROW, "§aNext Recipe").toItemStack(); - menu.setItem(50, nextRecipeItem, MenuItemInteraction.ANY_CLICK, (clickingPlayer, _) -> + menu.setItem(50, nextRecipeItem, (clickingPlayer, _) -> openPreview(clickingPlayer, nextRecipeObject, targetItem, previousMenu) ); } else { diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/recipes/crafting/menu/CraftingRecipePreviewMenu.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/recipes/crafting/menu/CraftingRecipePreviewMenu.java index 45db7dc4..d62fefa7 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/recipes/crafting/menu/CraftingRecipePreviewMenu.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/recipes/crafting/menu/CraftingRecipePreviewMenu.java @@ -10,7 +10,6 @@ import fr.moussax.bedrock.text.Messenger; import fr.moussax.bedrock.ui.menu.Menu; import fr.moussax.bedrock.ui.menu.TickableMenu; -import fr.moussax.bedrock.ui.menu.interaction.MenuItemInteraction; import fr.moussax.bedrock.utils.ItemBuilder; import fr.moussax.blightedSMP.utils.Utilities; import org.bukkit.ChatColor; @@ -89,13 +88,13 @@ private void setupRecipeVisualization(Player player) { setItem(WORKBENCH_SLOT, new ItemBuilder(Material.CRAFTING_TABLE, "§fBlighted Workbench") .addLore("§7Craft this recipe by using a blighted", "§7workbench or Quickcraft. ") - .toItemStack(), MenuItemInteraction.ANY_CLICK, (_, _) -> { + .toItemStack(), (_, _) -> { }); ItemStack resultItem = recipe.assemble(createVirtualCraftingGrid()); int amount = recipe.getAmount() > 0 ? recipe.getAmount() : 1; resultItem.setAmount(amount); - setItem(RESULT_SLOT, resultItem, MenuItemInteraction.ANY_CLICK, (_, _) -> { + setItem(RESULT_SLOT, resultItem, (_, _) -> { }); setupQuickcraftButton(player); @@ -108,13 +107,13 @@ private void setupShapedRecipeGrid(BlightedShapedRecipe shapedRecipe) { CraftingObject craftingObject = pattern.get(i); if (craftingObject == null) { - setItem(CRAFTING_GRID_SLOTS[i], new ItemStack(Material.AIR), MenuItemInteraction.ANY_CLICK, (_, _) -> { + setItem(CRAFTING_GRID_SLOTS[i], new ItemStack(Material.AIR), (_, _) -> { }); continue; } ItemStack ingredientItem = createIngredientDisplay(craftingObject); - setItem(CRAFTING_GRID_SLOTS[i], ingredientItem, MenuItemInteraction.ANY_CLICK, (clickingPlayer, _) -> { + setItem(CRAFTING_GRID_SLOTS[i], ingredientItem, (clickingPlayer, _) -> { if (!craftingObject.isCustom() || craftingObject.getManager() == null) return; RecipePreviewManager.openPreview(clickingPlayer, craftingObject.getManager(), this); }); @@ -129,12 +128,12 @@ private void setupShapelessRecipeGrid(BlightedShapelessRecipe shapelessRecipe) { CraftingObject ingredient = ingredients.get(i); ItemStack ingredientItem = createIngredientDisplay(ingredient); - setItem(CRAFTING_GRID_SLOTS[i], ingredientItem, MenuItemInteraction.ANY_CLICK, (clickingPlayer, _) -> { + setItem(CRAFTING_GRID_SLOTS[i], ingredientItem, (clickingPlayer, _) -> { if (!ingredient.isCustom() || ingredient.getManager() == null) return; RecipePreviewManager.openPreview(clickingPlayer, ingredient.getManager(), this); }); } else { - setItem(CRAFTING_GRID_SLOTS[i], new ItemStack(Material.AIR), MenuItemInteraction.ANY_CLICK, (_, _) -> { + setItem(CRAFTING_GRID_SLOTS[i], new ItemStack(Material.AIR), (_, _) -> { }); } } @@ -175,7 +174,7 @@ private void setupQuickcraftButton(Player player) { .setEnchantmentGlint(canQuickcraft); builder.addItemFlag(ItemFlag.HIDE_ATTRIBUTES); - setItem(QUICKCRAFT_SLOT, builder.toItemStack(), MenuItemInteraction.ANY_CLICK, (clickingPlayer, _) -> { + setItem(QUICKCRAFT_SLOT, builder.toItemStack(), (clickingPlayer, _) -> { if (!canQuickcraft) { Messenger.warn(clickingPlayer, "You're missing some ingredients to Quickcraft this item!"); return; diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/recipes/crafting/menu/CraftingTableMenu.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/recipes/crafting/menu/CraftingTableMenu.java index e657dcbe..d078c65c 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/recipes/crafting/menu/CraftingTableMenu.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/recipes/crafting/menu/CraftingTableMenu.java @@ -6,7 +6,6 @@ import fr.moussax.blightedSMP.engine.items.recipes.CraftingObject; import fr.moussax.bedrock.ui.menu.types.InteractiveMenu; import fr.moussax.bedrock.ui.menu.interaction.MenuElementPreset; -import fr.moussax.bedrock.ui.menu.interaction.MenuItemInteraction; import fr.moussax.bedrock.utils.ItemBuilder; import fr.moussax.blightedSMP.utils.Utilities; import org.bukkit.Material; @@ -67,7 +66,7 @@ public void onUpdate(@NonNull Player player) { } private void setupOutputSlot() { - setItem(OUTPUT_SLOT, RECIPE_REQUIRED(), MenuItemInteraction.ANY_CLICK, (player, type) -> { + setItem(OUTPUT_SLOT, RECIPE_REQUIRED(), (player, type) -> { BlightedRecipe recipe = getMatchingRecipe(); if (recipe == null) return; diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/recipes/crafting/menu/RecipeBookMenu.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/recipes/crafting/menu/RecipeBookMenu.java index af32c985..27d63600 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/recipes/crafting/menu/RecipeBookMenu.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/recipes/crafting/menu/RecipeBookMenu.java @@ -2,11 +2,7 @@ import fr.moussax.blightedSMP.engine.items.recipes.crafting.BlightedRecipe; import fr.moussax.bedrock.ui.menu.Menu; -import fr.moussax.bedrock.ui.menu.interaction.MenuElementPreset; -import fr.moussax.bedrock.ui.menu.interaction.MenuItemInteraction; import fr.moussax.bedrock.ui.menu.types.PaginatedMenu; -import fr.moussax.bedrock.utils.ItemBuilder; -import org.bukkit.ChatColor; import org.bukkit.Material; import org.bukkit.entity.Player; import org.bukkit.event.inventory.ClickType; @@ -15,32 +11,13 @@ import java.util.ArrayList; import java.util.List; -import java.util.Objects; public final class RecipeBookMenu extends PaginatedMenu { - private static final int[] RECIPE_SLOTS = { - 10, 11, 12, 13, 14, 15, 16, - 19, 20, 21, 22, 23, 24, 25, - 28, 29, 30, 31, 32, 33, 34, - 37, 38, 39, 40, 41, 42, 43 - }; - - private static final int[] FILLER_SLOTS = { - 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, - 17, 18, 26, 27, 35, 36, 44, - 45, 46, 47, 51, 52, 53 - }; - - private static final int BACK_BUTTON_SLOT = 48; - private static final int CLOSE_BUTTON_SLOT = 49; - private static final int NEXT_BUTTON_SLOT = 50; - - private final Menu previousMenu; + private final List cachedRecipes; - public RecipeBookMenu(Menu previousMenu) { - super("Recipes", 54); - this.previousMenu = previousMenu; + public RecipeBookMenu(@org.jspecify.annotations.Nullable Menu previousMenu) { + super("Recipes", 54, previousMenu != null ? previousMenu : new CraftingTableMenu()); this.cachedRecipes = new ArrayList<>(BlightedRecipe.REGISTERED_RECIPES); this.cachedRecipes.sort((firstRecipe, secondRecipe) -> { String firstName = firstRecipe.getResult().getDisplayName(); @@ -53,14 +30,19 @@ public RecipeBookMenu() { this(null); } + @Override + protected boolean useStandardFrame() { + return true; + } + @Override protected int getTotalItems(@NonNull Player player) { return cachedRecipes.size(); } @Override - protected int getItemsPerPage() { - return RECIPE_SLOTS.length; + protected void onBuildHeader(@NonNull Player viewer) { + setTitle("(" + getCurrentPageNumber() + "/" + getTotalPages() + ") Recipes"); } @Override @@ -83,68 +65,6 @@ protected ItemStack getItem(@NonNull Player player, int index) { return resultItem; } - @Override - public void build(@NonNull Player player) { - totalItems = getTotalItems(player); - int totalPages = getTotalPages(); - int pageNum = getCurrentPageNumber(); - setTitle("(" + pageNum + "/" + totalPages + ") Recipes"); - - int start = currentPage * getItemsPerPage(); - int end = Math.min(start + getItemsPerPage(), totalItems); - - clearInventory(); - populateRecipeSlots(player, start, end); - fillSlots(FILLER_SLOTS, MenuElementPreset.EMPTY_SLOT_FILLER); - setupNavigationButtons(player, end, totalPages, pageNum); - } - - private void populateRecipeSlots(Player player, int start, int end) { - int recipeIndex = 0; - for (int i = start; i < end && recipeIndex < RECIPE_SLOTS.length; i++) { - final int itemIndex = i; - setItem(RECIPE_SLOTS[recipeIndex], getItem(player, itemIndex), MenuItemInteraction.ANY_CLICK, - (clickingPlayer, clickType) -> onItemClick(clickingPlayer, itemIndex, clickType)); - recipeIndex++; - } - } - - private void setupNavigationButtons(Player player, int end, int totalPages, int pageNum) { - if (currentPage > 0) { - ItemStack prevItem = new ItemBuilder(Material.ARROW, "§aPrevious Page") - .addLore("§7Page " + (pageNum - 1)) - .toItemStack(); - setItem(BACK_BUTTON_SLOT, prevItem, MenuItemInteraction.ANY_CLICK, (clickingPlayer, _) -> { - currentPage--; - refresh(clickingPlayer); - }); - } else { - String targetName = previousMenu != null - ? ChatColor.stripColor(previousMenu.getTitle()) - : "Craft Items"; - ItemStack backItem = new ItemBuilder(Material.ARROW, "§aGo Back") - .addLore("§7To " + targetName) - .toItemStack(); - setItem(BACK_BUTTON_SLOT, backItem, MenuItemInteraction.ANY_CLICK, (clickingPlayer, _) -> - Objects.requireNonNullElseGet(previousMenu, CraftingTableMenu::new).open(clickingPlayer) - ); - } - - if (end < totalItems) { - ItemStack nextItem = new ItemBuilder(Material.ARROW, "§aNext Page") - .addLore("§7Page " + (pageNum + 1)) - .toItemStack(); - setItem(NEXT_BUTTON_SLOT, nextItem, MenuItemInteraction.ANY_CLICK, (clickingPlayer, _) -> { - currentPage++; - refresh(clickingPlayer); - }); - } else { - setItem(NEXT_BUTTON_SLOT, MenuElementPreset.EMPTY_SLOT_FILLER); - } - - setCloseButton(CLOSE_BUTTON_SLOT); - } - @Override protected void onItemClick(@NonNull Player player, int index, @NonNull ClickType clickType) { if (index >= cachedRecipes.size()) return; diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/recipes/forging/menu/ForgeRecipePreviewMenu.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/recipes/forging/menu/ForgeRecipePreviewMenu.java index f97c105f..4878a5c2 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/recipes/forging/menu/ForgeRecipePreviewMenu.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/recipes/forging/menu/ForgeRecipePreviewMenu.java @@ -11,7 +11,6 @@ import fr.moussax.bedrock.text.Messenger; import fr.moussax.bedrock.ui.menu.Menu; import fr.moussax.bedrock.ui.menu.TickableMenu; -import fr.moussax.bedrock.ui.menu.interaction.MenuItemInteraction; import fr.moussax.bedrock.utils.ItemBuilder; import fr.moussax.blightedSMP.utils.Utilities; import org.bukkit.ChatColor; @@ -138,7 +137,7 @@ private void setupIngredientGrid() { CraftingObject ingredient = ingredients.get(i); ItemStack displayItem = createIngredientDisplay(ingredient); - setItem(GRID_SLOTS[i], displayItem, MenuItemInteraction.ANY_CLICK, (clickingPlayer, _) -> { + setItem(GRID_SLOTS[i], displayItem, (clickingPlayer, _) -> { if (!ingredient.isCustom() || ingredient.getManager() == null) return; RecipePreviewManager.openPreview(clickingPlayer, ingredient.getManager(), this); }); @@ -156,7 +155,7 @@ private ItemStack createIngredientDisplay(CraftingObject ingredient) { private void setupResultDisplay() { ItemStack result = recipe.getForgedItem().toItemStack().clone(); result.setAmount(Math.max(1, recipe.getForgedAmount())); - setItem(RESULT_SLOT, result, MenuItemInteraction.ANY_CLICK, (_, _) -> { + setItem(RESULT_SLOT, result, (_, _) -> { }); } @@ -167,7 +166,7 @@ private void setupFuelDisplay() { "§7to power the forging process." ) .toItemStack(); - setItem(FUEL_INFO_SLOT, fuelInfo, MenuItemInteraction.ANY_CLICK, (_, _) -> { + setItem(FUEL_INFO_SLOT, fuelInfo, (_, _) -> { }); } @@ -221,7 +220,7 @@ private void setupHyperforgeButton(Player player) { canHyperforge ? "§eClick to Hyperforge!" : "§cMissing ingredients or insufficient fuel!" ).setEnchantmentGlint(canHyperforge); - setItem(HYPERFORGE_SLOT, builder.toItemStack(), MenuItemInteraction.ANY_CLICK, (clickingPlayer, _) -> { + setItem(HYPERFORGE_SLOT, builder.toItemStack(), (clickingPlayer, _) -> { if (!canHyperforge) { Messenger.warn(clickingPlayer, "You're missing some ingredients or insufficient fuel to Hyperforge this item!"); return; diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/recipes/forging/menu/ForgeRecipesMenu.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/recipes/forging/menu/ForgeRecipesMenu.java index 2de7a959..c7b7ebbf 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/recipes/forging/menu/ForgeRecipesMenu.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/recipes/forging/menu/ForgeRecipesMenu.java @@ -5,48 +5,26 @@ import fr.moussax.blightedSMP.engine.items.recipes.forging.registry.ForgeRegistry; import fr.moussax.bedrock.text.Formatter; import fr.moussax.bedrock.ui.menu.Menu; -import fr.moussax.bedrock.ui.menu.interaction.MenuElementPreset; -import fr.moussax.bedrock.ui.menu.interaction.MenuItemInteraction; import fr.moussax.bedrock.ui.menu.types.PaginatedMenu; import fr.moussax.bedrock.utils.ItemBuilder; import fr.moussax.blightedSMP.utils.Utilities; -import org.bukkit.ChatColor; import org.bukkit.Material; import org.bukkit.entity.Player; import org.bukkit.event.inventory.ClickType; import org.bukkit.inventory.ItemStack; import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; import java.util.ArrayList; import java.util.Comparator; import java.util.List; -import java.util.Objects; public final class ForgeRecipesMenu extends PaginatedMenu { - private static final int[] RECIPE_SLOTS = { - 10, 11, 12, 13, 14, 15, 16, - 19, 20, 21, 22, 23, 24, 25, - 28, 29, 30, 31, 32, 33, 34, - 37, 38, 40, 41, 42, 43 - }; - - private static final int[] FILLER_SLOTS = { - 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, - 17, 18, 26, 27, 35, 36, 44, - 45, 46, 47, 51, 52, 53 - }; - - private static final int BACK_BUTTON_SLOT = 48; - private static final int CLOSE_BUTTON_SLOT = 49; - private static final int NEXT_BUTTON_SLOT = 50; - - private final Menu previousMenu; private final List cachedRecipes; - public ForgeRecipesMenu(Menu previousMenu) { - super("Forge Recipes", 54); - this.previousMenu = previousMenu; + public ForgeRecipesMenu(@Nullable Menu previousMenu) { + super("Forge Recipes", 54, previousMenu != null ? previousMenu : new ForgeMenu(null)); this.cachedRecipes = new ArrayList<>(ForgeRegistry.getAll()); this.cachedRecipes.sort(Comparator.comparing( recipe -> recipe.getForgedItem().getDisplayName() != null @@ -55,14 +33,19 @@ public ForgeRecipesMenu(Menu previousMenu) { )); } + @Override + protected boolean useStandardFrame() { + return true; + } + @Override protected int getTotalItems(@NonNull Player player) { return cachedRecipes.size(); } @Override - protected int getItemsPerPage() { - return RECIPE_SLOTS.length; + protected void onBuildHeader(@NonNull Player viewer) { + setTitle("(" + getCurrentPageNumber() + "/" + getTotalPages() + ") Forge Recipes"); } @Override @@ -75,25 +58,6 @@ protected ItemStack getItem(@NonNull Player player, int index) { return buildRecipeDisplayItem(recipe); } - @Override - public void build(@NonNull Player player) { - totalItems = Math.max(0, getTotalItems(player)); - int totalPages = getTotalPages(); - int pageNum = getCurrentPageNumber(); - setTitle("(" + pageNum + "/" + totalPages + ") Forge Recipes"); - - int itemsPerPage = getItemsPerPage(); - int maxPage = Math.max(0, (totalItems - 1) / itemsPerPage); - currentPage = Math.min(currentPage, maxPage); - - int start = currentPage * itemsPerPage; - int end = Math.min(start + itemsPerPage, totalItems); - - populateRecipeSlots(player, start, end); - fillSlots(FILLER_SLOTS, MenuElementPreset.EMPTY_SLOT_FILLER); - setupNavigationButtons(player, end, totalPages, pageNum); - } - @Override protected void onItemClick(@NonNull Player player, int index, @NonNull ClickType clickType) { if (index >= cachedRecipes.size()) { @@ -123,50 +87,4 @@ private ItemStack buildRecipeDisplayItem(ForgeRecipe recipe) { return builder.toItemStack(); } - - private void populateRecipeSlots(Player player, int start, int end) { - int recipeIndex = 0; - for (int i = start; i < end && recipeIndex < RECIPE_SLOTS.length; i++) { - final int itemIndex = i; - setItem(RECIPE_SLOTS[recipeIndex], getItem(player, itemIndex), - (clickingPlayer, click) -> onItemClick(clickingPlayer, itemIndex, click)); - recipeIndex++; - } - } - - private void setupNavigationButtons(Player player, int end, int totalPages, int pageNum) { - if (currentPage > 0) { - ItemStack prevItem = new ItemBuilder(Material.ARROW, "§aPrevious Page") - .addLore("§7Page " + (pageNum - 1) + "/" + totalPages) - .toItemStack(); - setItem(BACK_BUTTON_SLOT, prevItem, MenuItemInteraction.ANY_CLICK, (clickingPlayer, _) -> { - currentPage--; - refresh(clickingPlayer); - }); - } else { - String targetName = previousMenu != null - ? ChatColor.stripColor(previousMenu.getTitle()) - : "Blighted Forge"; - ItemStack backItem = new ItemBuilder(Material.ARROW, "§aGo Back") - .addLore("§7To " + targetName) - .toItemStack(); - setItem(BACK_BUTTON_SLOT, backItem, MenuItemInteraction.ANY_CLICK, (clickingPlayer, _) -> - openSubMenu(Objects.requireNonNullElseGet(previousMenu, () -> new ForgeMenu(null))) - ); - } - - if (end < totalItems) { - ItemStack nextItem = new ItemBuilder(Material.ARROW, "§aNext Page") - .addLore("§7Page " + (pageNum + 1) + "/" + totalPages) - .toItemStack(); - setItem(NEXT_BUTTON_SLOT, nextItem, MenuItemInteraction.ANY_CLICK, (clickingPlayer, _) -> { - currentPage++; - refresh(clickingPlayer); - }); - } else { - setItem(NEXT_BUTTON_SLOT, MenuElementPreset.EMPTY_SLOT_FILLER); - } - - setCloseButton(CLOSE_BUTTON_SLOT); - } } 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 c2bea66e..81dd8932 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 @@ -6,7 +6,6 @@ import fr.moussax.bedrock.text.Formatter; import fr.moussax.bedrock.ui.menu.Menu; import fr.moussax.bedrock.ui.menu.types.PaginatedMenu; -import fr.moussax.bedrock.ui.menu.interaction.MenuItemInteraction; import fr.moussax.bedrock.ui.sign.SignInput; import fr.moussax.bedrock.utils.ItemBuilder; import org.bukkit.Material; @@ -59,14 +58,14 @@ public void build(Player player) { for (int i = 0; i < categories.size() && i < CATEGORY_SLOTS.length; i++) { ItemType.Category category = categories.get(i); ItemStack item = buildMenuItem(getCategoryIcon(category), "§b" + formatCategoryName(category), getCategoryLore(category)); - setItem(CATEGORY_SLOTS[i], item, MenuItemInteraction.ANY_CLICK, (clickingPlayer, _) -> + setItem(CATEGORY_SLOTS[i], item, (clickingPlayer, _) -> new BlightedItemsPaginatedMenu(this, registeredItem -> registeredItem.getItemType() != null && registeredItem.getItemType().getCategory() == category, "§r" + Formatter.formatEnumName(category.name()) + " Items").open(clickingPlayer)); } setItem(SEARCH_SLOT, buildMenuItem(new ItemStack(Material.PALE_OAK_SIGN), "§eSearch Items", List.of("§7Click to search for items!")), - MenuItemInteraction.ANY_CLICK, (clickingPlayer, _) -> openSearchSign(clickingPlayer, this)); + (clickingPlayer, _) -> openSearchSign(clickingPlayer, this)); setCloseButton(40); } @@ -116,12 +115,10 @@ private static void openSearchSign(Player player, Menu previousMenu) { } public static class BlightedItemsPaginatedMenu extends PaginatedMenu { - private final Menu previousMenu; private final List blightedItems; public BlightedItemsPaginatedMenu(Menu previousMenu, Predicate filter, String title) { - super(title, 54); - this.previousMenu = previousMenu; + super(title, 54, previousMenu); this.blightedItems = ItemRegistry.getAllItems().stream() .filter(filter) .sorted((firstItem, secondItem) -> { @@ -169,14 +166,6 @@ protected ItemStack getItem(@NonNull Player player, int index) { return hideAllItemFlags(new ItemBuilder(stack)).toItemStack(); } - @Override - public void build(@NonNull Player player) { - super.build(player); - if (currentPage == 0 && previousMenu != null) { - setBackButton(48, previousMenu); - } - } - @Override protected void onItemClick(@NonNull Player player, int index, @NonNull ClickType clickType) { if (index < blightedItems.size()) player.getInventory().addItem(blightedItems.get(index).toItemStack()); From eff9bf91a4cc8a9b335006d330446a6140200870 Mon Sep 17 00:00:00 2001 From: Moussa Date: Wed, 30 Sep 2026 14:46:05 +0200 Subject: [PATCH 08/23] feat(menu): unify menu click sound handling and silence static elements Make interactive menu slot click sounds consistent across all menus while muting filler panes and decorative items. Add fluent sound configuration methods to MenuSlot and update SMP menus to remove dummy handlers. --- .../fr/moussax/bedrock/sound/SoundCue.java | 12 + .../java/fr/moussax/bedrock/ui/menu/Menu.java | 330 +++++++++++++----- .../bedrock/ui/menu/types/PaginatedMenu.java | 7 +- .../fr/moussax/bedrock/ui/menu/MenuTest.java | 52 +++ .../rituals/menu/AncientRitualsMenu.java | 6 +- .../rituals/menu/RitualAltarMenu.java | 16 +- .../items/recipes/RecipePreviewManager.java | 4 +- .../menu/CraftingRecipePreviewMenu.java | 15 +- .../crafting/menu/CraftingTableMenu.java | 9 +- .../items/recipes/forging/menu/ForgeMenu.java | 31 +- .../forging/menu/ForgeRecipePreviewMenu.java | 15 +- 11 files changed, 343 insertions(+), 154 deletions(-) diff --git a/bedrock/src/main/java/fr/moussax/bedrock/sound/SoundCue.java b/bedrock/src/main/java/fr/moussax/bedrock/sound/SoundCue.java index 9a9694d9..3ccf373a 100644 --- a/bedrock/src/main/java/fr/moussax/bedrock/sound/SoundCue.java +++ b/bedrock/src/main/java/fr/moussax/bedrock/sound/SoundCue.java @@ -3,6 +3,7 @@ import fr.moussax.bedrock.scheduling.PluginContext; import org.bukkit.Location; import org.bukkit.Sound; +import org.bukkit.entity.Player; import org.jspecify.annotations.NonNull; import java.util.Objects; @@ -30,4 +31,15 @@ public void play(@NonNull Location location) { PluginContext.delay(() -> Objects.requireNonNull(location.getWorld()) .playSound(location, sound, volume, pitch), delay); } + + /** + * Plays this sound directly to the specified player after the configured delay. + * + * @param player the player who should hear the sound + */ + public void play(@NonNull Player player) { + PluginContext.delay(() -> player.playSound(player.getLocation(), sound, volume, pitch), delay); + } } + + 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 29542149..18d804dc 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 @@ -1,5 +1,6 @@ package fr.moussax.bedrock.ui.menu; +import fr.moussax.bedrock.sound.SoundCue; import fr.moussax.bedrock.ui.menu.interaction.MenuAction; import fr.moussax.bedrock.ui.menu.interaction.MenuElementPreset; import fr.moussax.bedrock.ui.menu.interaction.MenuItemInteraction; @@ -154,15 +155,18 @@ public Map getSlots() { * @param item item displayed in the slot * @param interaction interaction that triggers the action * @param action action executed when triggered + * @return the configured menu slot */ - public void setItem(int slot, @NonNull ItemStack item, @NonNull MenuItemInteraction interaction, @NonNull MenuAction action) { + public MenuSlot setItem(int slot, @NonNull ItemStack item, @NonNull MenuItemInteraction interaction, @NonNull MenuAction action) { MenuSlot existing = slots.get(slot); if (existing != null) { existing.item = item; existing.addAction(interaction, action); - } else { - slots.put(slot, new MenuSlot(item, interaction, action)); + return existing; } + MenuSlot created = new MenuSlot(item, interaction, action); + slots.put(slot, created); + return created; } /** @@ -172,13 +176,15 @@ public void setItem(int slot, @NonNull ItemStack item, @NonNull MenuItemInteract * @param item item displayed in the slot * @param onLeft action executed on left-click * @param onRight action executed on right-click + * @return the configured menu slot */ - public void setItem(int slot, @NonNull ItemStack item, @NonNull MenuAction onLeft, @NonNull MenuAction onRight) { + public MenuSlot setItem(int slot, @NonNull ItemStack item, @NonNull MenuAction onLeft, @NonNull MenuAction onRight) { setItem(slot, item, MenuItemInteraction.LEFT_CLICK, onLeft); MenuSlot menuSlot = slots.get(slot); if (menuSlot != null) { menuSlot.addAction(MenuItemInteraction.RIGHT_CLICK, onRight); } + return menuSlot; } /** @@ -187,9 +193,12 @@ public void setItem(int slot, @NonNull ItemStack item, @NonNull MenuAction onLef * @param slot inventory slot index * @param item item displayed in the slot * @param actions interaction-to-action mappings + * @return the configured menu slot */ - public void setItem(int slot, @NonNull ItemStack item, @NonNull Map actions) { - slots.put(slot, new MenuSlot(item, actions)); + public MenuSlot setItem(int slot, @NonNull ItemStack item, @NonNull Map actions) { + MenuSlot slotObj = new MenuSlot(item, actions); + slots.put(slot, slotObj); + return slotObj; } /** @@ -198,12 +207,14 @@ public void setItem(int slot, @NonNull ItemStack item, @NonNull MapSlots placed with this method are non-interactive and will not trigger + * click actions or button click sounds.

+ * * @param slot inventory slot index * @param item item displayed in the slot + * @return the configured menu slot */ - public void setItem(int slot, @NonNull ItemStack item) { - setItem(slot, item, MenuItemInteraction.ANY_CLICK, (player, type) -> { - }); + public MenuSlot setItem(int slot, @NonNull ItemStack item) { + MenuSlot existing = slots.get(slot); + if (existing != null) { + existing.item = item; + return existing; + } + MenuSlot created = new MenuSlot(item); + slots.put(slot, created); + return created; } /** @@ -234,9 +256,10 @@ public void setItem(int slot, @NonNull ItemStack item) { * * @param slot inventory slot index * @param preset item preset + * @return the configured menu slot */ - public void setItem(int slot, @NonNull MenuElementPreset preset) { - setItem(slot, preset.getItem()); + public MenuSlot setItem(int slot, @NonNull MenuElementPreset preset) { + return setItem(slot, preset.getItem()); } /** @@ -245,9 +268,10 @@ public void setItem(int slot, @NonNull MenuElementPreset preset) { * @param slot inventory slot index * @param item item displayed in the slot * @param action action executed on click + * @return the configured menu slot */ - public void setItem(int slot, @NonNull ItemStack item, @NonNull MenuAction action) { - setItem(slot, item, MenuItemInteraction.ANY_CLICK, action); + public MenuSlot setItem(int slot, @NonNull ItemStack item, @NonNull MenuAction action) { + return setItem(slot, item, MenuItemInteraction.ANY_CLICK, action); } /** @@ -256,18 +280,20 @@ public void setItem(int slot, @NonNull ItemStack item, @NonNull MenuAction actio * @param slot inventory slot index * @param preset item preset * @param action action executed on click + * @return the configured menu slot */ - public void setItem(int slot, @NonNull MenuElementPreset preset, @NonNull MenuAction action) { - setItem(slot, preset.getItem(), MenuItemInteraction.ANY_CLICK, action); + public MenuSlot setItem(int slot, @NonNull MenuElementPreset preset, @NonNull MenuAction action) { + return setItem(slot, preset.getItem(), MenuItemInteraction.ANY_CLICK, action); } /** * Places the standard close button in the specified slot. * * @param slot inventory slot index + * @return the configured menu slot */ - public void setCloseButton(int slot) { - setItem(slot, MenuElementPreset.CLOSE_BUTTON, (player, _) -> close()); + public MenuSlot setCloseButton(int slot) { + return setItem(slot, MenuElementPreset.CLOSE_BUTTON, (player, _) -> close()); } /** @@ -275,9 +301,10 @@ public void setCloseButton(int slot) { * * @param slot inventory slot index * @param action action executed when clicked + * @return the configured menu slot */ - public void setBackButton(int slot, @NonNull MenuAction action) { - setItem(slot, MenuElementPreset.BACK_BUTTON, action); + public MenuSlot setBackButton(int slot, @NonNull MenuAction action) { + return setItem(slot, MenuElementPreset.BACK_BUTTON, action); } /** @@ -285,14 +312,14 @@ public void setBackButton(int slot, @NonNull MenuAction action) { * * @param slot inventory slot index * @param previousMenu menu to open after closing this menu + * @return the configured menu slot */ - public void setBackButton(int slot, @Nullable Menu previousMenu) { - setBackButton(slot, (player, _) -> { + public MenuSlot setBackButton(int slot, @Nullable Menu previousMenu) { + return setBackButton(slot, (player, _) -> { if (previousMenu == null) { goBack(); return; } - playClickSound(player); if (menuSystem != null) { menuSystem.popAndOpen(player, previousMenu); } else { @@ -309,8 +336,9 @@ public void setBackButton(int slot, @Nullable Menu previousMenu) { * @param builder item configuration callback * @param onLeft action executed on left-click * @param onRight action executed on right-click + * @return the configured menu slot */ - public void setItem( + public MenuSlot setItem( int slot, @NonNull Material material, @NonNull Consumer builder, @@ -319,7 +347,7 @@ public void setItem( ) { ItemBuilder itemBuilder = new ItemBuilder(material); builder.accept(itemBuilder); - setItem(slot, itemBuilder.toItemStack(), onLeft, onRight); + return setItem(slot, itemBuilder.toItemStack(), onLeft, onRight); } /** @@ -330,8 +358,9 @@ public void setItem( * @param builder item configuration callback * @param interaction interaction that triggers the action * @param action action executed when triggered + * @return the configured menu slot */ - public void setItem( + public MenuSlot setItem( int slot, @NonNull Material material, @NonNull Consumer builder, @@ -340,7 +369,7 @@ public void setItem( ) { ItemBuilder itemBuilder = new ItemBuilder(material); builder.accept(itemBuilder); - setItem(slot, itemBuilder.toItemStack(), interaction, action); + return setItem(slot, itemBuilder.toItemStack(), interaction, action); } /** @@ -350,14 +379,15 @@ public void setItem( * @param material base material * @param builder item configuration callback * @param action action executed on click + * @return the configured menu slot */ - public void setItem( + public MenuSlot setItem( int slot, @NonNull Material material, @NonNull Consumer builder, @NonNull MenuAction action ) { - setItem(slot, material, builder, MenuItemInteraction.ANY_CLICK, action); + return setItem(slot, material, builder, MenuItemInteraction.ANY_CLICK, action); } /** @@ -366,10 +396,12 @@ public void setItem( * @param slot inventory slot index * @param material base material * @param builder item configuration callback + * @return the configured menu slot */ - public void setItem(int slot, @NonNull Material material, @NonNull Consumer builder) { - setItem(slot, material, builder, (player, type) -> { - }); + public MenuSlot setItem(int slot, @NonNull Material material, @NonNull Consumer builder) { + ItemBuilder itemBuilder = new ItemBuilder(material); + builder.accept(itemBuilder); + return setItem(slot, itemBuilder.toItemStack()); } /** @@ -386,51 +418,25 @@ public void fillSlots(int[] slots, @NonNull Material material, @NonNull Consumer } /** - * Fills the specified slots with an item and interaction type. - * - * @param slots inventory slot indices - * @param item item displayed in each slot - * @param interaction interaction that triggers each slot - */ - public void fillSlots(int[] slots, @NonNull ItemStack item, @NonNull MenuItemInteraction interaction) { - for (int slot : slots) { - setItem(slot, item, interaction, (player, type) -> { - }); - } - } - - /** - * Fills the specified slots with a preset and interaction type. - * - * @param slots inventory slot indices - * @param preset item preset - * @param interaction interaction that triggers each slot - */ - public void fillSlots(int[] slots, @NonNull MenuElementPreset preset, @NonNull MenuItemInteraction interaction) { - for (int slot : slots) { - setItem(slot, preset, interaction, (player, type) -> { - }); - } - } - - /** - * Fills the specified slots with an item using {@link MenuItemInteraction#ANY_CLICK}. + * Fills the specified slots with an item. * * @param slots inventory slot indices * @param item item displayed in each slot */ public void fillSlots(int[] slots, @NonNull ItemStack item) { - fillSlots(slots, item, MenuItemInteraction.ANY_CLICK); + for (int slot : slots) { + setItem(slot, item); + } } /** - * Fills the specified slots with a preset using {@link MenuItemInteraction#ANY_CLICK}. + * Fills the specified slots with a preset. * * @param slots inventory slot indices * @param preset item preset */ public void fillSlots(int[] slots, @NonNull MenuElementPreset preset) { - fillSlots(slots, preset.getItem(), MenuItemInteraction.ANY_CLICK); + fillSlots(slots, preset.getItem()); } /** @@ -455,8 +461,7 @@ public void fillEmptyWith(@NonNull Material material, @NonNull Consumer { - }); + setItem(slot, item); } } } @@ -509,7 +514,6 @@ public void goBack() { Player player = getPlayer(); if (player == null) return; - playClickSound(player); if (menuSystem != null) { menuSystem.goBack(player); } @@ -522,7 +526,6 @@ public void close() { Player player = getPlayer(); if (player == null) return; - playClickSound(player); player.closeInventory(); } @@ -670,7 +673,7 @@ protected void setSlotItem(int slot, @Nullable ItemStack item) { if (existing != null) { existing.item = item; } else { - slots.put(slot, new MenuSlot(item, MenuItemInteraction.ANY_CLICK, (player, type) -> {})); + slots.put(slot, new MenuSlot(item)); } inventory.setItem(slot, item); } else { @@ -699,15 +702,35 @@ public boolean updateSlotItem(int slot, @Nullable ItemStack item) { } /** - * Represents an inventory slot and its registered click actions. + * Represents an inventory slot and its registered click actions and sound behavior. */ public static class MenuSlot { public ItemStack item; private final Map actions = new EnumMap<>(MenuItemInteraction.class); + private Sound sound = null; + @Getter + private float volume = 1.0f; + @Getter + private float pitch = 1.0f; + @Getter + private boolean soundMuted = false; + private boolean useDefaultClickSound = true; + private boolean usePageTurnSound = false; + + /** + * Creates a static slot with no interactive actions and muted sound. + * + * @param item item displayed in the slot + */ + public MenuSlot(@NonNull ItemStack item) { + this.item = item; + this.soundMuted = true; + this.useDefaultClickSound = false; + } /** - * Creates a slot with a single interaction action. + * Creates a slot with a single interaction action and default click sound. * * @param item item displayed in the slot * @param interaction interaction that triggers the action @@ -719,7 +742,7 @@ public MenuSlot(@NonNull ItemStack item, @NonNull MenuItemInteraction interactio } /** - * Creates a slot with multiple interaction actions. + * Creates a slot with multiple interaction actions and default click sound. * * @param item item displayed in the slot * @param actions interaction-to-action mappings @@ -729,6 +752,117 @@ public MenuSlot(@NonNull ItemStack item, @NonNull MapSpecific left, right, or middle-click actions take precedence - * over {@link MenuItemInteraction#ANY_CLICK}.

+ *

Plays the configured sound (by default UI button click) unless muted, + * then dispatches the action.

* * @param player player who clicked the slot * @param clickType click type received */ - public void handle(@NonNull Player player, @NonNull ClickType clickType) { - boolean handled = false; + public void handle(@Nullable Player player, @NonNull ClickType clickType) { + MenuAction action = resolveAction(clickType); + if (action == null) { + return; + } - if (clickType.isLeftClick() && actions.containsKey(MenuItemInteraction.LEFT_CLICK)) { - actions.get(MenuItemInteraction.LEFT_CLICK).execute(player, clickType); - handled = true; - } else if (clickType.isRightClick() && actions.containsKey(MenuItemInteraction.RIGHT_CLICK)) { - actions.get(MenuItemInteraction.RIGHT_CLICK).execute(player, clickType); - handled = true; - } else if (clickType == ClickType.MIDDLE && actions.containsKey(MenuItemInteraction.MIDDLE_CLICK)) { - actions.get(MenuItemInteraction.MIDDLE_CLICK).execute(player, clickType); - handled = true; + if (player != null && !soundMuted) { + Sound soundToPlay = sound; + if (soundToPlay == null) { + if (usePageTurnSound) { + soundToPlay = Sound.ITEM_BOOK_PAGE_TURN; + } else if (useDefaultClickSound) { + soundToPlay = Sound.UI_BUTTON_CLICK; + } + } + if (soundToPlay != null) { + player.playSound(player.getLocation(), soundToPlay, volume, pitch); + } } - if (!handled && actions.containsKey(MenuItemInteraction.ANY_CLICK)) { - actions.get(MenuItemInteraction.ANY_CLICK).execute(player, clickType); + action.execute(player, clickType); + } + + @Nullable + private MenuAction resolveAction(@NonNull ClickType clickType) { + if (clickType.isLeftClick() && actions.containsKey(MenuItemInteraction.LEFT_CLICK)) { + return actions.get(MenuItemInteraction.LEFT_CLICK); + } + if (clickType.isRightClick() && actions.containsKey(MenuItemInteraction.RIGHT_CLICK)) { + return actions.get(MenuItemInteraction.RIGHT_CLICK); + } + if (clickType == ClickType.MIDDLE && actions.containsKey(MenuItemInteraction.MIDDLE_CLICK)) { + return actions.get(MenuItemInteraction.MIDDLE_CLICK); } + return actions.get(MenuItemInteraction.ANY_CLICK); } } } diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/menu/types/PaginatedMenu.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/menu/types/PaginatedMenu.java index fe195114..e7ef3b4a 100644 --- a/bedrock/src/main/java/fr/moussax/bedrock/ui/menu/types/PaginatedMenu.java +++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/menu/types/PaginatedMenu.java @@ -280,10 +280,9 @@ public void build(@NonNull Player viewer) { .addLore("§7Page " + (pageNum - 1) + "/" + totalPages) .toItemStack(); setItem(backSlot, prevItem, (player, _) -> { - playPageTurnSound(player); currentPage--; refresh(player); - }); + }).withPageTurnSound(); } else { renderParentBackButton(backSlot); } @@ -293,10 +292,9 @@ public void build(@NonNull Player viewer) { .addLore("§7Page " + (pageNum + 1) + "/" + totalPages) .toItemStack(); setItem(nextSlot, nextItem, (player, _) -> { - playPageTurnSound(player); currentPage++; refresh(player); - }); + }).withPageTurnSound(); } else if (useStandardFrame() && nextSlot < size) { setItem(nextSlot, MenuElementPreset.EMPTY_SLOT_FILLER); } @@ -316,7 +314,6 @@ private void renderParentBackButton(int slot) { .toItemStack(); setItem(slot, backItem, (player, _) -> { - playClickSound(player); if (menuSystem != null) { menuSystem.popAndOpen(player, previousMenu); } else { diff --git a/bedrock/src/test/java/fr/moussax/bedrock/ui/menu/MenuTest.java b/bedrock/src/test/java/fr/moussax/bedrock/ui/menu/MenuTest.java index 4ec2e373..66908a3b 100644 --- a/bedrock/src/test/java/fr/moussax/bedrock/ui/menu/MenuTest.java +++ b/bedrock/src/test/java/fr/moussax/bedrock/ui/menu/MenuTest.java @@ -49,6 +49,56 @@ void testMenuSlotDispatch() { assertTrue(anyClicked.get()); } + @Test + @DisplayName("Expects static MenuSlot to have no actions and muted sound") + void testStaticSlotBehavior() { + ItemStack item = new ItemStack(Material.BLACK_STAINED_GLASS_PANE); + Menu.MenuSlot staticSlot = new Menu.MenuSlot(item); + + assertFalse(staticSlot.hasAction()); + assertFalse(staticSlot.hasAction(ClickType.LEFT)); + assertFalse(staticSlot.hasAction(ClickType.RIGHT)); + assertTrue(staticSlot.isSoundMuted()); + + // Handling a click on an actionless slot must be a no-op + assertDoesNotThrow(() -> staticSlot.handle(null, ClickType.LEFT)); + } + + @Test + @DisplayName("Expects interactive MenuSlot to have default click sound and allow custom/muted sound") + void testInteractiveSlotSoundCustomization() { + ItemStack item = new ItemStack(Material.COMPASS); + Menu.MenuSlot slot = new Menu.MenuSlot(item, MenuItemInteraction.ANY_CLICK, (_, _) -> {}); + + assertTrue(slot.hasAction()); + assertTrue(slot.hasAction(ClickType.LEFT)); + assertFalse(slot.isSoundMuted()); + assertTrue(slot.isUsingDefaultClickSound()); + assertNull(slot.getSound()); + + // Custom page turn sound + slot.withPageTurnSound(); + assertFalse(slot.isSoundMuted()); + assertFalse(slot.isUsingDefaultClickSound()); + assertTrue(slot.isUsingPageTurnSound()); + assertNull(slot.getSound()); + + // Muted sound + slot.withoutSound(); + assertTrue(slot.isSoundMuted()); + assertFalse(slot.isUsingDefaultClickSound()); + assertFalse(slot.isUsingPageTurnSound()); + + // Custom sound configuration with volume and pitch + slot.withSound(null, 0.8f, 0.5f); + assertNull(slot.getSound()); + assertEquals(0.8f, slot.getVolume()); + assertEquals(0.5f, slot.getPitch()); + assertTrue(slot.isSoundMuted()); + assertFalse(slot.isUsingDefaultClickSound()); + assertFalse(slot.isUsingPageTurnSound()); + } + @Test @DisplayName("Expects standard framed grid and border slots to match 54-slot inventory geometry") void testFramedSlotGeometry() { @@ -71,3 +121,5 @@ void testFramedSlotGeometry() { } } } + + diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/rituals/menu/AncientRitualsMenu.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/rituals/menu/AncientRitualsMenu.java index 0991b977..4821a16e 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/rituals/menu/AncientRitualsMenu.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/rituals/menu/AncientRitualsMenu.java @@ -66,11 +66,7 @@ public void build(@NonNull Player player) { } if (previousMenu != null) { - String targetName = ChatColor.stripColor(previousMenu.getTitle()); - ItemStack backItem = new ItemBuilder(Material.ARROW, "§aGo Back") - .addLore("§7To " + targetName) - .toItemStack(); - setItem(BACK_BUTTON_SLOT, backItem, (clickingPlayer, _) -> openSubMenu(previousMenu)); + setBackButton(BACK_BUTTON_SLOT, previousMenu); } setCloseButton(CLOSE_BUTTON_SLOT); diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/rituals/menu/RitualAltarMenu.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/rituals/menu/RitualAltarMenu.java index 92800c24..9995c710 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/rituals/menu/RitualAltarMenu.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/entities/rituals/menu/RitualAltarMenu.java @@ -110,8 +110,7 @@ private void setupResultDisplay() { ItemStack barrier = new ItemBuilder(Material.BARRIER, "§cRitual Required") .addLore("§7Select an ancient ritual to start", "§7the invocation process.") .toItemStack(); - setItem(25, barrier, (_, _) -> { - }); + setItem(25, barrier); return; } @@ -124,8 +123,7 @@ private void setupResultDisplay() { result = builder.toItemStack(); } - setItem(25, result, (_, _) -> { - }); + setItem(25, result); } private void checkRequirements(Player player) { @@ -168,8 +166,7 @@ private Map aggregateRequirements() { private void displayRequiredIngredients() { for (int i = 0; i < ritual.getOfferings().size() && i < GRID_SLOTS.length; i++) { CraftingObject ingredient = ritual.getOfferings().get(i); - setItem(GRID_SLOTS[i], createDisplayItem(ingredient), (_, _) -> { - }); + setItem(GRID_SLOTS[i], createDisplayItem(ingredient)); } } @@ -202,8 +199,7 @@ private void setupStatusPanes() { "§7to invoke ancient forgotten entities." ) .toItemStack(); - setItem(14, shriekerIcon, (_, _) -> { - }); + setItem(14, shriekerIcon); } private Material determineIndicatorMaterial() { @@ -261,9 +257,9 @@ private void setupInvokeButton() { if (ritual != null && canInvoke) { invokeMob(clickingPlayer); } else { - clickingPlayer.playSound(clickingPlayer.getLocation(), Sound.ENTITY_ENDERMAN_TELEPORT, 1f, 0.5f); + playErrorSound(clickingPlayer); } - }); + }).withoutSound(); } private void setupNavigationButtons() { diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/recipes/RecipePreviewManager.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/recipes/RecipePreviewManager.java index b7e3a5d2..0a0c4344 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/recipes/RecipePreviewManager.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/recipes/RecipePreviewManager.java @@ -140,7 +140,7 @@ public static void setupNavigation(@NonNull Menu menu, @NonNull Object recipe, @ ItemStack previousRecipeItem = new ItemBuilder(Material.ARROW, "§aPrevious Recipe").toItemStack(); menu.setItem(48, previousRecipeItem, (clickingPlayer, _) -> openPreview(clickingPlayer, previousRecipeObject, targetItem, previousMenu) - ); + ).withPageTurnSound(); } else if (previousMenu != null) { menu.setBackButton(48, previousMenu); } @@ -150,7 +150,7 @@ public static void setupNavigation(@NonNull Menu menu, @NonNull Object recipe, @ ItemStack nextRecipeItem = new ItemBuilder(Material.ARROW, "§aNext Recipe").toItemStack(); menu.setItem(50, nextRecipeItem, (clickingPlayer, _) -> openPreview(clickingPlayer, nextRecipeObject, targetItem, previousMenu) - ); + ).withPageTurnSound(); } else { menu.setItem(50, MenuElementPreset.EMPTY_SLOT_FILLER); } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/recipes/crafting/menu/CraftingRecipePreviewMenu.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/recipes/crafting/menu/CraftingRecipePreviewMenu.java index d62fefa7..4cf0c8c8 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/recipes/crafting/menu/CraftingRecipePreviewMenu.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/recipes/crafting/menu/CraftingRecipePreviewMenu.java @@ -88,14 +88,12 @@ private void setupRecipeVisualization(Player player) { setItem(WORKBENCH_SLOT, new ItemBuilder(Material.CRAFTING_TABLE, "§fBlighted Workbench") .addLore("§7Craft this recipe by using a blighted", "§7workbench or Quickcraft. ") - .toItemStack(), (_, _) -> { - }); + .toItemStack()); ItemStack resultItem = recipe.assemble(createVirtualCraftingGrid()); int amount = recipe.getAmount() > 0 ? recipe.getAmount() : 1; resultItem.setAmount(amount); - setItem(RESULT_SLOT, resultItem, (_, _) -> { - }); + setItem(RESULT_SLOT, resultItem); setupQuickcraftButton(player); } @@ -107,8 +105,7 @@ private void setupShapedRecipeGrid(BlightedShapedRecipe shapedRecipe) { CraftingObject craftingObject = pattern.get(i); if (craftingObject == null) { - setItem(CRAFTING_GRID_SLOTS[i], new ItemStack(Material.AIR), (_, _) -> { - }); + setItem(CRAFTING_GRID_SLOTS[i], new ItemStack(Material.AIR)); continue; } @@ -133,8 +130,7 @@ private void setupShapelessRecipeGrid(BlightedShapelessRecipe shapelessRecipe) { RecipePreviewManager.openPreview(clickingPlayer, ingredient.getManager(), this); }); } else { - setItem(CRAFTING_GRID_SLOTS[i], new ItemStack(Material.AIR), (_, _) -> { - }); + setItem(CRAFTING_GRID_SLOTS[i], new ItemStack(Material.AIR)); } } } @@ -176,11 +172,12 @@ private void setupQuickcraftButton(Player player) { setItem(QUICKCRAFT_SLOT, builder.toItemStack(), (clickingPlayer, _) -> { if (!canQuickcraft) { + playErrorSound(clickingPlayer); Messenger.warn(clickingPlayer, "You're missing some ingredients to Quickcraft this item!"); return; } PluginContext.delay(() -> executeQuickcraft(clickingPlayer, requirements), 1L); - }); + }).withoutSound(); } private void executeQuickcraft(Player player, Map requirements) { diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/recipes/crafting/menu/CraftingTableMenu.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/recipes/crafting/menu/CraftingTableMenu.java index d078c65c..2ca7f108 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/recipes/crafting/menu/CraftingTableMenu.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/recipes/crafting/menu/CraftingTableMenu.java @@ -48,12 +48,10 @@ public void build(Player player) { .setDisplayName("§r").setHideTooltip(true).toItemStack(); for (int slot : INDICATOR_SLOTS_LEFT) - setItem(slot, indicator, (p, t) -> { - }); + setItem(slot, indicator); for (int slot : INDICATOR_SLOTS_RIGHT) - setItem(slot, indicator, (p, t) -> { - }); + setItem(slot, indicator); setupOutputSlot(); } @@ -70,6 +68,7 @@ private void setupOutputSlot() { BlightedRecipe recipe = getMatchingRecipe(); if (recipe == null) return; + playClickSound(player); if (isShiftClick(type)) { craftMaximum(recipe, player); } else { @@ -77,7 +76,7 @@ private void setupOutputSlot() { } onUpdate(player); - }); + }).withoutSound(); } private void craftOnce(BlightedRecipe recipe, Player player) { 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 c3d9ba90..7ee66a03 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 @@ -107,15 +107,13 @@ private void setupResultDisplay() { ItemStack noRecipe = new ItemBuilder(Material.BARRIER, "§cRecipe Required") .addLore("§7Select a recipe from the", "§7recipe book to start forging.") .toItemStack(); - setItem(25, noRecipe, (_, _) -> { - }); + setItem(25, noRecipe); return; } ItemStack result = recipe.getForgedItem().toItemStack().clone(); result.setAmount(recipe.getForgedAmount()); - setItem(25, result, (_, _) -> { - }); + setItem(25, result); } private void checkRequirements(Player player) { @@ -153,8 +151,7 @@ private void displayRequiredIngredients() { for (int i = 0; i < recipe.getIngredients().size() && i < GRID_SLOTS.length; i++) { CraftingObject ingredient = recipe.getIngredients().get(i); ItemStack displayItem = createDisplayItem(ingredient); - setItem(GRID_SLOTS[i], displayItem, (_, _) -> { - }); + setItem(GRID_SLOTS[i], displayItem); } } @@ -179,10 +176,8 @@ private void setupStatusPanes() { fillSlots(REQUIRED_ITEM_INDICATOR_SLOTS, sacrificedItemPane); fillSlots(FORGED_ITEM_INDICATOR_SLOTS, forgedItemPane); - setItem(ITEM_INDICATOR, new ItemBuilder(indicator).hideTooltip().toItemStack(), (_, _) -> { - }); - setItem(14, new ItemBuilder(Material.BLAST_FURNACE).hideTooltip().toItemStack(), (_, _) -> { - }); + setItem(ITEM_INDICATOR, new ItemBuilder(indicator).hideTooltip().toItemStack()); + setItem(14, new ItemBuilder(Material.BLAST_FURNACE).hideTooltip().toItemStack()); } private Material determineIndicatorMaterial() { @@ -226,9 +221,9 @@ private void setupForgeButton() { if (recipe != null && canForge) { forgeItem(clickingPlayer); } else { - clickingPlayer.playSound(clickingPlayer.getLocation(), Sound.ENTITY_ENDERMAN_TELEPORT, 1f, 0.5f); + playErrorSound(clickingPlayer); } - }); + }).withoutSound(); } private void setupNavigationButtons() { @@ -244,10 +239,8 @@ private void setupFuelButtons(Player player) { BlightedPlayer blightedPlayer = BlightedPlayer.get(player); int currentFuel = blightedPlayer.getForgeFuel(); - setItem(34, createFuelMeter(currentFuel), (_, _) -> { - }); - setItem(52, createFuelGuide(), (_, _) -> { - }); + setItem(34, createFuelMeter(currentFuel)); + setItem(52, createFuelGuide()); setItem(53, createInsertFuelButton(player), (_, _) -> handleFuelInsertion(blightedPlayer)); } @@ -454,8 +447,7 @@ private void updateStatusPanes(Material material, int[] slots) { ItemStack pane = new ItemBuilder(material).hideTooltip().toItemStack(); for (int slot : slots) { - setItem(slot, pane, (_, _) -> { - }); + setItem(slot, pane); inventory.setItem(slot, pane); } } @@ -467,8 +459,7 @@ private void setStatusSlot(int slot, Material material) { } ItemStack pane = new ItemBuilder(material).hideTooltip().toItemStack(); - setItem(slot, pane, (_, _) -> { - }); + setItem(slot, pane); inventory.setItem(slot, pane); } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/recipes/forging/menu/ForgeRecipePreviewMenu.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/recipes/forging/menu/ForgeRecipePreviewMenu.java index 4878a5c2..a00bd1fe 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/recipes/forging/menu/ForgeRecipePreviewMenu.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/recipes/forging/menu/ForgeRecipePreviewMenu.java @@ -104,13 +104,11 @@ private void setupStatusPanes(Player player) { fillSlots(REQUIRED_ITEM_INDICATOR_SLOTS, requiredItemsPane); fillSlots(FORGED_ITEM_INDICATOR_SLOTS, forgedItemPane); - setItem(ITEM_INDICATOR, new ItemBuilder(indicator).hideTooltip().toItemStack(), (_, _) -> { - }); + setItem(ITEM_INDICATOR, new ItemBuilder(indicator).hideTooltip().toItemStack()); setItem(FORGE_SLOT, new ItemBuilder(Material.BLAST_FURNACE, "§fBlighted Forge") .addLore("§7Forge this recipe using a blighted", "§7forge or Hyperforge.") - .toItemStack(), (_, _) -> { - }); + .toItemStack()); } private boolean checkCanHyperforge(Player player) { @@ -155,8 +153,7 @@ private ItemStack createIngredientDisplay(CraftingObject ingredient) { private void setupResultDisplay() { ItemStack result = recipe.getForgedItem().toItemStack().clone(); result.setAmount(Math.max(1, recipe.getForgedAmount())); - setItem(RESULT_SLOT, result, (_, _) -> { - }); + setItem(RESULT_SLOT, result); } private void setupFuelDisplay() { @@ -166,8 +163,7 @@ private void setupFuelDisplay() { "§7to power the forging process." ) .toItemStack(); - setItem(FUEL_INFO_SLOT, fuelInfo, (_, _) -> { - }); + setItem(FUEL_INFO_SLOT, fuelInfo); } private void setupHyperforgeButton(Player player) { @@ -222,11 +218,12 @@ private void setupHyperforgeButton(Player player) { setItem(HYPERFORGE_SLOT, builder.toItemStack(), (clickingPlayer, _) -> { if (!canHyperforge) { + playErrorSound(clickingPlayer); Messenger.warn(clickingPlayer, "You're missing some ingredients or insufficient fuel to Hyperforge this item!"); return; } PluginContext.delay(() -> executeHyperforge(clickingPlayer, blightedPlayer, requirements, fuelCost), 1L); - }); + }).withoutSound(); } private void executeHyperforge(Player player, BlightedPlayer blightedPlayer, Map requirements, int fuelCost) { From 850524dec9aef08172f38f325f2cd9dbcacda771 Mon Sep 17 00:00:00 2001 From: Moussa Date: Wed, 30 Sep 2026 15:40:49 +0200 Subject: [PATCH 09/23] perf(sound): bypass scheduler for zero-delay SoundCue and improve PluginContext Fast-path SoundCue playback directly on the current thread when delay is zero or negative, eliminating tick latency for instant audio. Update PluginContext to return BukkitTask handles on delay, add run convenience method, and mark plugin field volatile. --- .../bedrock/scheduling/PluginContext.java | 20 +++++++++++++++---- .../fr/moussax/bedrock/sound/SoundCue.java | 12 +++++++++++ .../bedrock/ui/sign/SignInputManager.java | 3 +-- 3 files changed, 29 insertions(+), 6 deletions(-) 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 919b80d9..1b0398a5 100644 --- a/bedrock/src/main/java/fr/moussax/bedrock/scheduling/PluginContext.java +++ b/bedrock/src/main/java/fr/moussax/bedrock/scheduling/PluginContext.java @@ -2,14 +2,15 @@ import org.bukkit.Bukkit; import org.bukkit.plugin.Plugin; +import org.bukkit.scheduler.BukkitTask; import org.jspecify.annotations.NonNull; /** - * Utility for accessing the main plugin instance and scheduling delayed tasks. + * Utility for accessing the main plugin instance and scheduling synchronous tasks. */ public final class PluginContext { - private static Plugin plugin; + private static volatile Plugin plugin; private PluginContext() { } @@ -36,13 +37,24 @@ public static Plugin get() { return plugin; } + /** + * Schedules a task to run synchronously on the next server tick. + * + * @param runnable the task to execute + * @return the scheduled task handle + */ + public static BukkitTask run(@NonNull Runnable runnable) { + return Bukkit.getScheduler().runTask(get(), runnable); + } + /** * Schedules a task to run after a specified number of server ticks. * * @param runnable the task to execute * @param ticks number of server ticks to wait before execution + * @return the scheduled task handle */ - public static void delay(@NonNull Runnable runnable, long ticks) { - Bukkit.getScheduler().runTaskLater(get(), runnable, ticks); + public static BukkitTask delay(@NonNull Runnable runnable, long ticks) { + return Bukkit.getScheduler().runTaskLater(get(), runnable, ticks); } } diff --git a/bedrock/src/main/java/fr/moussax/bedrock/sound/SoundCue.java b/bedrock/src/main/java/fr/moussax/bedrock/sound/SoundCue.java index 3ccf373a..458d920d 100644 --- a/bedrock/src/main/java/fr/moussax/bedrock/sound/SoundCue.java +++ b/bedrock/src/main/java/fr/moussax/bedrock/sound/SoundCue.java @@ -25,9 +25,15 @@ public record SoundCue(Sound sound, float volume, float pitch, long delay) { /** * Plays this sound at the specified location after the configured delay. * + *

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 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 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 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 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 players, + @Nullable Runnable extraOnComplete + ) { + Objects.requireNonNull(plugin, "plugin cannot be null"); + Objects.requireNonNull(players, "players cannot be null"); + + List 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 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 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 players, + @Nullable Runnable extraOnComplete + ) { + Objects.requireNonNull(plugin, "plugin cannot be null"); + Objects.requireNonNull(players, "players cannot be null"); + + List 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 players, + @Nullable Runnable extraOnComplete, + @NonNull BiConsumer dispatcher + ) { List 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 Date: Thu, 1 Oct 2026 14:03:07 +0200 Subject: [PATCH 19/23] fix(book): duplicate components during link resolution and colorize text before wrapping --- .../main/java/fr/moussax/bedrock/ui/book/BookMenu.java | 9 +++++++-- .../java/fr/moussax/bedrock/ui/book/BookPaginator.java | 3 ++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/book/BookMenu.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/book/BookMenu.java index 58cf1138..3fcbd076 100644 --- a/bedrock/src/main/java/fr/moussax/bedrock/ui/book/BookMenu.java +++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/book/BookMenu.java @@ -231,13 +231,18 @@ public List buildPhysicalPages() { } int totalPhysicalPages = physicalPages.size(); + List resolvedPages = new ArrayList<>(physicalPages.size()); for (BaseComponent[] pageComponents : physicalPages) { - for (BaseComponent component : pageComponents) { + BaseComponent[] resolvedComponents = new BaseComponent[pageComponents.length]; + for (int index = 0; index < pageComponents.length; index++) { + BaseComponent component = pageComponents[index].duplicate(); resolvePageLinks(component, targetToPhysical, logicalToPhysical, totalPhysicalPages); + resolvedComponents[index] = component; } + resolvedPages.add(resolvedComponents); } - return physicalPages; + return resolvedPages; } private void resolvePageLinks( diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/book/BookPaginator.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/book/BookPaginator.java index f89b0ac3..c68e1149 100644 --- a/bedrock/src/main/java/fr/moussax/bedrock/ui/book/BookPaginator.java +++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/book/BookPaginator.java @@ -1,5 +1,6 @@ package fr.moussax.bedrock.ui.book; +import fr.moussax.bedrock.utils.ColorUtils; import org.bukkit.ChatColor; import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; @@ -76,7 +77,7 @@ public static List paginateLines(@NonNull List lines) { */ public static List wrapText(@NonNull String text) { List wrappedLines = new ArrayList<>(); - String[] paragraphs = text.split("\r?\n", -1); + String[] paragraphs = ColorUtils.colorize(text).split("\r?\n", -1); for (String paragraph : paragraphs) { if (paragraph.isEmpty()) { From 1b8b9e2633ebfcd8e0ece556698ee30e8dda646e Mon Sep 17 00:00:00 2001 From: Moussa Date: Thu, 1 Oct 2026 14:03:30 +0200 Subject: [PATCH 20/23] fix(title): ensure per-player alert sounds, snapshot countdown recipients, and handle unregistration --- .../fr/moussax/bedrock/ui/title/Title.java | 16 ++- .../moussax/bedrock/ui/title/TitleAlert.java | 11 +- .../bedrock/ui/title/TitleComposer.java | 6 +- .../bedrock/ui/title/TitleService.java | 9 +- .../moussax/bedrock/ui/title/TitleTest.java | 115 +++++++++++++++++- 5 files changed, 145 insertions(+), 12 deletions(-) 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 index 0b50fdb7..d5cc64d8 100644 --- a/bedrock/src/main/java/fr/moussax/bedrock/ui/title/Title.java +++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/title/Title.java @@ -844,9 +844,10 @@ public static BukkitTask countdown( Objects.requireNonNull(players, "players cannot be null"); Objects.requireNonNull(secondFormatter, "secondFormatter cannot be null"); Objects.requireNonNull(completionTitle, "completionTitle cannot be null"); + List countdownPlayers = List.copyOf(players); if (seconds <= 0) { - for (Player player : players) { + for (Player player : countdownPlayers) { if (player.isOnline()) { builder() .title(completionTitle) @@ -861,7 +862,12 @@ public static BukkitTask countdown( return null; } - Plugin plugin = resolvePlugin(); + Plugin plugin; + try { + plugin = resolvePlugin(); + } catch (IllegalStateException _) { + return null; + } return new BukkitRunnable() { private int remaining = seconds; @@ -869,7 +875,7 @@ public static BukkitTask countdown( @Override public void run() { boolean anyOnline = false; - for (Player player : players) { + for (Player player : countdownPlayers) { if (player.isOnline()) { anyOnline = true; break; @@ -884,7 +890,7 @@ public void run() { String titleText = secondFormatter.apply(remaining); float pitch = Math.min(2.0f, 1.0f + (seconds - remaining) * 0.25f); - for (Player player : players) { + for (Player player : countdownPlayers) { if (player.isOnline()) { TitleBuilder builder = builder() .title(titleText) @@ -899,7 +905,7 @@ public void run() { remaining--; } else { - for (Player player : players) { + for (Player player : countdownPlayers) { if (player.isOnline()) { TitleBuilder builder = builder() .title(completionTitle) 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 index de0fdcb7..d24cedd2 100644 --- a/bedrock/src/main/java/fr/moussax/bedrock/ui/title/TitleAlert.java +++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/title/TitleAlert.java @@ -8,6 +8,9 @@ import java.time.Duration; import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; import java.util.function.Function; import java.util.function.Supplier; @@ -25,7 +28,7 @@ public final class TitleAlert implements Comparable { private final long expiresAt; private final TimeableTitle times; private final SoundCue soundCue; - private boolean soundPlayed = false; + private final Set playedPlayers = ConcurrentHashMap.newKeySet(); /** * Constructs a modal title alert with explicit parameters. @@ -224,13 +227,13 @@ public TimeableTitle times() { } /** - * Plays the associated sound cue if not already played. + * Plays the associated sound cue for the given player if not already played. * * @param player target player */ public void playSoundIfNeeded(@NonNull Player player) { - if (!soundPlayed && soundCue != null) { - soundPlayed = true; + Objects.requireNonNull(player, "player cannot be null"); + if (soundCue != null && playedPlayers.add(player.getUniqueId())) { soundCue.play(player); } } 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 index 69413f36..c61e7dc8 100644 --- a/bedrock/src/main/java/fr/moussax/bedrock/ui/title/TitleComposer.java +++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/title/TitleComposer.java @@ -90,7 +90,11 @@ public void clearAlerts() { public void clear(@NonNull Player player) { clearAlerts(); clearPersistentTitle(); - render(player); + player.resetTitle(); + lastSentTitle = null; + lastSentSubtitle = null; + lastSentTimes = null; + lastTimingSentTimestamp = 0L; } /** 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 index 9240872a..e42c6067 100644 --- a/bedrock/src/main/java/fr/moussax/bedrock/ui/title/TitleService.java +++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/title/TitleService.java @@ -49,7 +49,9 @@ public TitleService(@NonNull Plugin plugin) { if (instance == null) { instance = this; } - Bukkit.getPluginManager().registerEvents(this, plugin); + if (Bukkit.getServer() != null) { + Bukkit.getPluginManager().registerEvents(this, plugin); + } } /** @@ -106,6 +108,11 @@ public void stop() { } composers.clear(); HandlerList.unregisterAll(this); + synchronized (TitleService.class) { + if (instance == this) { + instance = null; + } + } } /** 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 index a5827037..8f74499b 100644 --- a/bedrock/src/test/java/fr/moussax/bedrock/ui/title/TitleTest.java +++ b/bedrock/src/test/java/fr/moussax/bedrock/ui/title/TitleTest.java @@ -1,8 +1,11 @@ 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.Location; import org.bukkit.entity.Player; +import org.bukkit.scheduler.BukkitTask; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -11,7 +14,12 @@ import java.time.Duration; import java.util.ArrayList; import java.util.List; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.*; @@ -28,6 +36,16 @@ private Player createMockPlayer( AtomicReference titleRef, AtomicReference simpleTitleRef, AtomicBoolean resetRef + ) { + return createMockPlayer(titleRef, simpleTitleRef, resetRef, UUID.randomUUID(), new AtomicInteger()); + } + + private Player createMockPlayer( + AtomicReference titleRef, + AtomicReference simpleTitleRef, + AtomicBoolean resetRef, + UUID uuid, + AtomicInteger soundCountRef ) { return (Player) Proxy.newProxyInstance( Player.class.getClassLoader(), @@ -60,10 +78,14 @@ private Player createMockPlayer( if ("isOnline".equals(name)) { return true; } + if ("getUniqueId".equals(name)) { + return uuid; + } if ("getLocation".equals(name)) { return new Location(null, 0, 0, 0); } if ("playSound".equals(name)) { + soundCountRef.incrementAndGet(); return null; } return null; @@ -458,6 +480,27 @@ void testZeroSecondCountdownFiresImmediately() { assertEquals("§7Go!", call.subtitle()); } + @Test + @DisplayName("Expects countdown to return without scheduling when no plugin is available") + void testCountdownWithoutBoundPlugin() { + TitleService.setInstance(null); + PluginContext.unbind(); + + Player player = createMockPlayer(new AtomicReference<>(), new AtomicReference<>(), new AtomicBoolean(false)); + BukkitTask task = assertDoesNotThrow(() -> Title.countdown( + List.of(player), + 1, + remaining -> String.valueOf(remaining), + "Complete", + null, + null, + null, + null + )); + + assertNull(task); + } + @Test @DisplayName("Expects chained animations with three or more stages to preserve and execute all stages in sequence") void testChainedAnimationPreservesAllStages() { @@ -472,6 +515,7 @@ void testChainedAnimationPreservesAllStages() { renderedTitles.add((String) args[0]); return null; } + if ("getUniqueId".equals(name)) return UUID.randomUUID(); if ("isOnline".equals(name)) return true; if ("getLocation".equals(name)) return new Location(null, 0, 0, 0); return null; @@ -483,7 +527,7 @@ void testChainedAnimationPreservesAllStages() { TextAnimation stage1 = TextAnimation.builder().frame("Stage 1").build(); TextAnimation stage2 = TextAnimation.builder().frame("Stage 2").build(); - TextAnimation stage3 = TextAnimation.builder().frame("Stage 3").build(); + TextAnimation stage3 = TextAnimation.builder().frame("Stage 3").finalTimes(TimeableTitle.of(0, 0, 0)).build(); org.bukkit.plugin.Plugin mockPlugin = (org.bukkit.plugin.Plugin) Proxy.newProxyInstance( org.bukkit.plugin.Plugin.class.getClassLoader(), @@ -558,4 +602,73 @@ void testCollectionNullAndOfflineFiltering() { assertNotNull(titleRef.get()); assertEquals("Title", titleRef.get().title()); } + + @Test + @DisplayName("Expects TitleAlert sound cue to play once per player across multiple composers") + void testTitleAlertSoundCuePlayedOncePerPlayer() { + AtomicInteger soundCount1 = new AtomicInteger(0); + AtomicInteger soundCount2 = new AtomicInteger(0); + Player player1 = createMockPlayer(new AtomicReference<>(), new AtomicReference<>(), new AtomicBoolean(false), UUID.randomUUID(), soundCount1); + Player player2 = createMockPlayer(new AtomicReference<>(), new AtomicReference<>(), new AtomicBoolean(false), UUID.randomUUID(), soundCount2); + + SoundCue cue = new SoundCue(null, 1.0f, 1.0f, 0L); + TitleAlert alert = TitleAlert.of("Alert Title", "Alert Subtitle", 10, Duration.ofSeconds(5), TimeableTitle.ALERT, cue); + + TitleComposer composer1 = new TitleComposer(); + TitleComposer composer2 = new TitleComposer(); + + composer1.sendModalAlert(alert); + composer2.sendModalAlert(alert); + + // First render pass on composer 1 + composer1.render(player1); + assertEquals(1, soundCount1.get(), "Player 1 should receive the sound cue on initial render"); + assertEquals(0, soundCount2.get(), "Player 2 should not have received the sound cue yet"); + + // Subsequent render pass on composer 1 for player 1 + composer1.render(player1); + assertEquals(1, soundCount1.get(), "Player 1 should not receive duplicate sound cues on subsequent renders"); + + // First render pass on composer 2 for player 2 + composer2.render(player2); + assertEquals(1, soundCount2.get(), "Player 2 should receive the sound cue once on initial render"); + + // Subsequent render pass on composer 2 for player 2 + composer2.render(player2); + assertEquals(1, soundCount2.get(), "Player 2 should not receive duplicate sound cues"); + } + + @Test + @DisplayName("Expects concurrent playSoundIfNeeded calls for the same player to only play sound cue once") + void testConcurrentPlaySoundIfNeededExecutesOncePerPlayer() throws InterruptedException { + AtomicInteger soundCount = new AtomicInteger(0); + Player player = createMockPlayer(new AtomicReference<>(), new AtomicReference<>(), new AtomicBoolean(false), UUID.randomUUID(), soundCount); + + SoundCue cue = new SoundCue(null, 1.0f, 1.0f, 0L); + TitleAlert alert = TitleAlert.of("Alert", "Sub", 10, Duration.ofSeconds(5), TimeableTitle.ALERT, cue); + + int threadCount = 10; + ExecutorService service = Executors.newFixedThreadPool(threadCount); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch finishLatch = new CountDownLatch(threadCount); + + for (int i = 0; i < threadCount; i++) { + service.submit(() -> { + try { + startLatch.await(); + alert.playSoundIfNeeded(player); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + finishLatch.countDown(); + } + }); + } + + startLatch.countDown(); + finishLatch.await(); + service.shutdown(); + + assertEquals(1, soundCount.get(), "Concurrent playSoundIfNeeded invocations must only trigger the sound once"); + } } From 2135adc79d3ba96659adeb5f8d03da759c9e19ce Mon Sep 17 00:00:00 2001 From: Moussa Date: Thu, 1 Oct 2026 14:03:41 +0200 Subject: [PATCH 21/23] refactor(animation): track playbacks per channel and manage cancellation handles --- .../bedrock/ui/animation/TextAnimation.java | 601 ++++++++++++------ 1 file changed, 414 insertions(+), 187 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 1c55d254..1f263bf3 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 @@ -17,10 +17,9 @@ 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.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.BiConsumer; import java.util.function.Consumer; @@ -240,11 +239,12 @@ private static void buildTypewriterFrames(Builder builder, String text, @Nullabl 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); - } + return; + } + + for (int i = 1; i <= visibleLength; i++) { + String partial = substringWithColors(text, i); + builder.frame(partial, subtitle, sound, 1.2f); } } @@ -437,13 +437,192 @@ public static TextAnimation pulse( return builder.build(); } + /** + * UI channel on which text animations can be played. + */ + public enum Channel { + ACTIONBAR, + TITLE + } + + private static final Map activeActionbars = new ConcurrentHashMap<>(); + private static final Map activeTitles = new ConcurrentHashMap<>(); + + /** + * Retrieves the currently active playback for a player on the specified channel, if any. + * + * @param player target player + * @param channel playback channel + * @return active playback or null + */ + public static @Nullable Playback getActivePlayback(@NonNull Player player, @NonNull Channel channel) { + Objects.requireNonNull(player, "player cannot be null"); + Objects.requireNonNull(channel, "channel cannot be null"); + return getActivePlayback(player.getUniqueId(), channel); + } + + /** + * Retrieves the currently active playback for a player UUID on the specified channel, if any. + * + * @param uuid target player UUID + * @param channel playback channel + * @return active playback or null + */ + public static @Nullable Playback getActivePlayback(@NonNull UUID uuid, @NonNull Channel channel) { + Objects.requireNonNull(uuid, "uuid cannot be null"); + Objects.requireNonNull(channel, "channel cannot be null"); + Map map = (channel == Channel.ACTIONBAR) ? activeActionbars : activeTitles; + return map.get(uuid); + } + + /** + * Cancels all active text animation playbacks across all channels and clears tracking. + */ + public static void clearActivePlaybacks() { + activeActionbars.values().forEach(Playback::cancel); + activeActionbars.clear(); + activeTitles.values().forEach(Playback::cancel); + activeTitles.clear(); + } + + /** + * Represents an active playback handle for a text animation on an action bar or title channel. + * + *

A playback handle owns all stages in chained sequences and any final stay task. + * Cancelling the handle stops the active task, clears the animation state, restores the player's + * persistent HUD, and invokes the supplied completion callback exactly once.

+ */ + public static final class Playback implements BukkitTask { + private final Plugin plugin; + private final Player player; + private final Channel channel; + private final Runnable extraOnComplete; + private final AtomicBoolean finished = new AtomicBoolean(false); + private final AtomicBoolean cancelled = new AtomicBoolean(false); + private volatile BukkitTask currentTask; + + Playback( + @NonNull Plugin plugin, + @NonNull Player player, + @NonNull Channel channel, + @Nullable Runnable extraOnComplete + ) { + this.plugin = plugin; + this.player = player; + this.channel = channel; + this.extraOnComplete = extraOnComplete; + } + + void setCurrentTask(@Nullable BukkitTask task) { + this.currentTask = task; + } + + /** + * Returns the target player receiving this animation playback. + * + * @return target player + */ + @NonNull + public Player getPlayer() { + return player; + } + + /** + * Returns the UI channel on which this animation is playing. + * + * @return playback channel + */ + @NonNull + public Channel getChannel() { + return channel; + } + + @Override + public int getTaskId() { + BukkitTask task = this.currentTask; + return task != null ? task.getTaskId() : -1; + } + + @Override + @NonNull + public Plugin getOwner() { + return plugin; + } + + @Override + public boolean isSync() { + return true; + } + + @Override + public boolean isCancelled() { + return cancelled.get(); + } + + /** + * Checks whether this playback has completed or was cancelled. + * + * @return true if finished + */ + public boolean isFinished() { + return finished.get(); + } + + @Override + public void cancel() { + if (!cancelled.compareAndSet(false, true)) { + return; + } + BukkitTask task = this.currentTask; + if (task != null) { + task.cancel(); + } + cleanupAndComplete(); + } + + void completeNormally() { + if (cancelled.get()) { + return; + } + cleanupAndComplete(); + } + + private void cleanupAndComplete() { + if (!finished.compareAndSet(false, true)) { + return; + } + + UUID uuid = player.getUniqueId(); + Map activeMap = (channel == Channel.ACTIONBAR) ? activeActionbars : activeTitles; + activeMap.remove(uuid, this); + + if (channel == Channel.ACTIONBAR) { + ActionbarService service = ActionbarService.getInstance(); + if (service != null) { + service.setAnimating(uuid, false); + service.renderPlayer(player); + } + } else { + TitleService service = TitleService.getInstance(); + if (service != null) { + service.setAnimating(uuid, false); + service.renderPlayer(player); + } + } + + if (extraOnComplete != null) { + extraOnComplete.run(); + } + } + } + /** * Plays this text animation on the target player's action bar. * * @param player target player - * @return the running Bukkit task handle + * @return the running playback handle */ - public BukkitTask playActionbar(@NonNull Player player) { + public Playback playActionbar(@NonNull Player player) { return playActionbar(player, null); } @@ -452,9 +631,9 @@ public BukkitTask playActionbar(@NonNull Player player) { * * @param player target player * @param extraOnComplete additional action executed upon completion - * @return the running Bukkit task handle + * @return the running playback handle */ - public BukkitTask playActionbar(@NonNull Player player, @Nullable Runnable extraOnComplete) { + public Playback playActionbar(@NonNull Player player, @Nullable Runnable extraOnComplete) { return playActionbar(resolvePlugin(), player, extraOnComplete); } @@ -463,9 +642,9 @@ public BukkitTask playActionbar(@NonNull Player player, @Nullable Runnable extra * * @param plugin owning plugin * @param player target player - * @return the running Bukkit task handle + * @return the running playback handle */ - public BukkitTask playActionbar(@NonNull Plugin plugin, @NonNull Player player) { + public Playback playActionbar(@NonNull Plugin plugin, @NonNull Player player) { return playActionbar(plugin, player, null); } @@ -475,97 +654,141 @@ public BukkitTask playActionbar(@NonNull Plugin plugin, @NonNull Player player) * @param plugin owning plugin * @param player target player * @param extraOnComplete additional action executed upon completion - * @return the running Bukkit task handle + * @return the running playback handle */ - public BukkitTask playActionbar(@NonNull Plugin plugin, @NonNull Player player, @Nullable Runnable extraOnComplete) { + public Playback 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; + UUID uuid = player.getUniqueId(); + Playback previous = activeActionbars.get(uuid); + if (previous != null) { + previous.cancel(); } if (!player.isOnline()) { - if (extraOnComplete != null) extraOnComplete.run(); + if (extraOnComplete != null) { + extraOnComplete.run(); + } return null; } + Playback playback = new Playback(plugin, player, Channel.ACTIONBAR, extraOnComplete); + activeActionbars.put(uuid, playback); ActionbarService service = ActionbarService.getInstance(); if (service != null) { - service.setAnimating(player.getUniqueId(), true); + service.setAnimating(uuid, true); } - Frame firstFrame = frames.getFirst(); + executeActionbarStage(playback, this); + return playback; + } + + private void executeActionbarStage(@NonNull Playback playback, @NonNull TextAnimation stage) { + if (playback.isCancelled()) { + return; + } + + Player player = playback.getPlayer(); + Plugin plugin = playback.getOwner(); + + if (!player.isOnline()) { + playback.cancel(); + return; + } + + if (stage.frames.isEmpty()) { + if (stage.nextAnimation != null) { + executeActionbarStage(playback, stage.nextAnimation); + return; + } + playback.completeNormally(); + return; + } + + Frame firstFrame = stage.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 (stage.frames.size() == 1) { + if (stage.onComplete != null) { + stage.onComplete.accept(player); + } + + if (stage.nextAnimation != null) { + executeActionbarStage(playback, stage.nextAnimation); + return; } - if (onComplete != null) onComplete.accept(player); - long stayTicks = Math.max(0L, finalStay.toMillis() / 50L); + + long stayTicks = Math.max(0L, stage.finalStay.toMillis() / 50L); if (stayTicks > 0) { - return Bukkit.getScheduler().runTaskLater(plugin, () -> finishActionbar(player, extraOnComplete), stayTicks); + if (Bukkit.getServer() != null) { + BukkitTask stayTask = Bukkit.getScheduler().runTaskLater(plugin, playback::completeNormally, stayTicks); + playback.setCurrentTask(stayTask); + } } else { - finishActionbar(player, extraOnComplete); - return null; + playback.completeNormally(); } + return; } - return new BukkitRunnable() { + BukkitTask task = new BukkitRunnable() { private int frameIndex = 1; @Override public void run() { + if (playback.isCancelled()) { + cancel(); + return; + } + if (!player.isOnline()) { - cleanupActionbar(player); cancel(); - if (extraOnComplete != null) { - extraOnComplete.run(); - } + playback.cancel(); return; } - if (frameIndex < frames.size() - 1) { - Frame frame = frames.get(frameIndex); + if (frameIndex < stage.frames.size() - 1) { + Frame frame = stage.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(); + return; + } - sendRawActionbar(player, last.text()); - if (last.sound() != null) { - player.playSound(player.getLocation(), last.sound(), 1.0f, last.soundPitch()); - } - if (onComplete != null) { - onComplete.accept(player); - } + Frame last = stage.frames.getLast(); + cancel(); - if (nextAnimation != null) { - nextAnimation.playActionbar(plugin, player, extraOnComplete); - } else { - long stayTicks = Math.max(0L, finalStay.toMillis() / 50L); - if (stayTicks > 0) { - Bukkit.getScheduler().runTaskLater(plugin, () -> finishActionbar(player, extraOnComplete), stayTicks); - } else { - finishActionbar(player, extraOnComplete); - } + sendRawActionbar(player, last.text()); + if (last.sound() != null) { + player.playSound(player.getLocation(), last.sound(), 1.0f, last.soundPitch()); + } + if (stage.onComplete != null) { + stage.onComplete.accept(player); + } + + if (stage.nextAnimation != null) { + executeActionbarStage(playback, stage.nextAnimation); + return; + } + + long stayTicks = Math.max(0L, stage.finalStay.toMillis() / 50L); + if (stayTicks > 0) { + if (Bukkit.getServer() != null) { + BukkitTask stayTask = Bukkit.getScheduler().runTaskLater(plugin, playback::completeNormally, stayTicks); + playback.setCurrentTask(stayTask); } + } else { + playback.completeNormally(); } } - }.runTaskTimer(plugin, tickInterval, tickInterval); + }.runTaskTimer(plugin, stage.tickInterval, stage.tickInterval); + + playback.setCurrentTask(task); } /** @@ -632,31 +855,13 @@ private static void sendRawActionbar(@NonNull Player player, @NonNull String tex 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); - } - } - /** * Plays this text animation on the target player as an in-place title sequence. * * @param player target player - * @return the running Bukkit task handle + * @return the running playback handle */ - public BukkitTask playTitle(@NonNull Player player) { + public Playback playTitle(@NonNull Player player) { return playTitle(player, null); } @@ -665,9 +870,9 @@ public BukkitTask playTitle(@NonNull Player player) { * * @param player target player * @param extraOnComplete additional action executed upon completion - * @return the running Bukkit task handle + * @return the running playback handle */ - public BukkitTask playTitle(@NonNull Player player, @Nullable Runnable extraOnComplete) { + public Playback playTitle(@NonNull Player player, @Nullable Runnable extraOnComplete) { return playTitle(resolvePlugin(), player, extraOnComplete); } @@ -676,9 +881,9 @@ public BukkitTask playTitle(@NonNull Player player, @Nullable Runnable extraOnCo * * @param plugin owning plugin * @param player target player - * @return the running Bukkit task handle + * @return the running playback handle */ - public BukkitTask playTitle(@NonNull Plugin plugin, @NonNull Player player) { + public Playback playTitle(@NonNull Plugin plugin, @NonNull Player player) { return playTitle(plugin, player, null); } @@ -688,116 +893,152 @@ public BukkitTask playTitle(@NonNull Plugin plugin, @NonNull Player player) { * @param plugin owning plugin * @param player target player * @param extraOnComplete additional action executed upon completion - * @return the running Bukkit task handle + * @return the running playback handle */ - public BukkitTask playTitle(@NonNull Plugin plugin, @NonNull Player player, @Nullable Runnable extraOnComplete) { + public Playback 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; + UUID uuid = player.getUniqueId(); + Playback previous = activeTitles.get(uuid); + if (previous != null) { + previous.cancel(); } if (!player.isOnline()) { - if (extraOnComplete != null) extraOnComplete.run(); + if (extraOnComplete != null) { + extraOnComplete.run(); + } return null; } + Playback playback = new Playback(plugin, player, Channel.TITLE, extraOnComplete); + activeTitles.put(uuid, playback); TitleService titleService = TitleService.getInstance(); if (titleService != null) { - titleService.setAnimating(player.getUniqueId(), true); + titleService.setAnimating(uuid, true); } - Frame firstFrame = frames.getFirst(); + executeTitleStage(playback, this); + return playback; + } + + private void executeTitleStage(@NonNull Playback playback, @NonNull TextAnimation stage) { + if (playback.isCancelled()) { + return; + } + + Player player = playback.getPlayer(); + Plugin plugin = playback.getOwner(); + + if (!player.isOnline()) { + playback.cancel(); + return; + } + + if (stage.frames.isEmpty()) { + if (stage.nextAnimation != null) { + executeTitleStage(playback, stage.nextAnimation); + return; + } + playback.completeNormally(); + return; + } + + Frame firstFrame = stage.frames.getFirst(); 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); + if (stage.frames.size() == 1) { + if (stage.onComplete != null) { + stage.onComplete.accept(player); + } + + if (stage.nextAnimation != null) { + int totalTicks = (int) (stage.frames.size() * stage.tickInterval); TitlePacketSender.sendFull(player, firstFrame.title(), firstFrame.subtitle(), 0, totalTicks + 60, 0); - if (onComplete != null) onComplete.accept(player); - return nextAnimation.playTitle(plugin, player, extraOnComplete); + executeTitleStage(playback, stage.nextAnimation); + return; } + TitlePacketSender.sendFull(player, firstFrame.title(), firstFrame.subtitle(), - finalTimes.fadeIn(), finalTimes.stay(), finalTimes.fadeOut()); - if (onComplete != null) onComplete.accept(player); + stage.finalTimes.fadeIn(), stage.finalTimes.stay(), stage.finalTimes.fadeOut()); - long finishDelay = finalTimes.stay() + finalTimes.fadeOut(); + long finishDelay = stage.finalTimes.stay() + stage.finalTimes.fadeOut(); if (finishDelay > 0) { - return Bukkit.getScheduler().runTaskLater(plugin, () -> finishTitle(player, extraOnComplete), finishDelay); + if (Bukkit.getServer() != null) { + BukkitTask stayTask = Bukkit.getScheduler().runTaskLater(plugin, playback::completeNormally, finishDelay); + playback.setCurrentTask(stayTask); + } } else { - finishTitle(player, extraOnComplete); - return null; + playback.completeNormally(); } + return; } - // Initialize display with sufficient stay to cover the entire sequence with zero fade-in - int totalTicks = (int) (frames.size() * tickInterval); + int totalTicks = (int) (stage.frames.size() * stage.tickInterval); TitlePacketSender.sendFull(player, firstFrame.title(), firstFrame.subtitle(), 0, totalTicks + 60, 0); - return new BukkitRunnable() { + BukkitTask task = new BukkitRunnable() { private int frameIndex = 1; @Override public void run() { + if (playback.isCancelled()) { + cancel(); + return; + } + if (!player.isOnline()) { - cleanupTitle(player); cancel(); - if (extraOnComplete != null) { - extraOnComplete.run(); - } + playback.cancel(); return; } - if (frameIndex < frames.size() - 1) { - Frame frame = frames.get(frameIndex); - // Pure text update: no ClientboundSetTitlesAnimationPacket is sent, preventing opacity reset + if (frameIndex < stage.frames.size() - 1) { + Frame frame = stage.frames.get(frameIndex); 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(); + return; + } - 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 (finishDelay > 0) { - Bukkit.getScheduler().runTaskLater(plugin, () -> finishTitle(player, extraOnComplete), finishDelay); - } else { - finishTitle(player, extraOnComplete); - } + Frame last = stage.frames.getLast(); + cancel(); + + if (last.sound() != null) { + player.playSound(player.getLocation(), last.sound(), 1.0f, last.soundPitch()); + } + if (stage.onComplete != null) { + stage.onComplete.accept(player); + } + + if (stage.nextAnimation != null) { + TitlePacketSender.sendTextOnly(player, last.title(), last.subtitle()); + executeTitleStage(playback, stage.nextAnimation); + return; + } + + TitlePacketSender.sendFull(player, last.title(), last.subtitle(), + stage.finalTimes.fadeIn(), stage.finalTimes.stay(), stage.finalTimes.fadeOut()); + + long finishDelay = stage.finalTimes.stay() + stage.finalTimes.fadeOut(); + if (finishDelay > 0) { + if (Bukkit.getServer() == null) return; + { + BukkitTask stayTask = Bukkit.getScheduler().runTaskLater(plugin, playback::completeNormally, finishDelay); + playback.setCurrentTask(stayTask); } + } else { + playback.completeNormally(); } } - }.runTaskTimer(plugin, tickInterval, tickInterval); + }.runTaskTimer(plugin, stage.tickInterval, stage.tickInterval); + + playback.setCurrentTask(task); } /** @@ -862,24 +1103,6 @@ private static void dispatchCollection( } } - 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); - } - } - /** * Plays this text animation dispatching each rendered frame to a custom consumer. * @@ -946,12 +1169,15 @@ public BukkitTask play( } if (frames.size() == 1) { + if (onComplete != null) { + onComplete.accept(player); + } 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(); + if (extraOnComplete != null) { + extraOnComplete.run(); + } return null; } @@ -975,21 +1201,22 @@ public void run() { 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(); - } + return; + } + + 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); @@ -997,11 +1224,11 @@ public void run() { private static Plugin resolvePlugin() { ActionbarService actionbarService = ActionbarService.getInstance(); - if (actionbarService != null && actionbarService.getPlugin() != null) { + if (actionbarService != null) { return actionbarService.getPlugin(); } TitleService titleService = TitleService.getInstance(); - if (titleService != null && titleService.getPlugin() != null) { + if (titleService != null) { return titleService.getPlugin(); } return PluginContext.get(); From fd17e9f5b6e917ff0049d3ca7ae05e580503c892 Mon Sep 17 00:00:00 2001 From: Moussa Date: Thu, 1 Oct 2026 14:04:03 +0200 Subject: [PATCH 22/23] test: update test suites --- .../ui/actionbar/ActionbarComposerTest.java | 5 +- .../ui/animation/TextAnimationTest.java | 170 +++++++++++++++++- .../moussax/bedrock/ui/book/BookMenuTest.java | 33 ++++ 3 files changed, 197 insertions(+), 11 deletions(-) diff --git a/bedrock/src/test/java/fr/moussax/bedrock/ui/actionbar/ActionbarComposerTest.java b/bedrock/src/test/java/fr/moussax/bedrock/ui/actionbar/ActionbarComposerTest.java index 7a350430..c0530230 100644 --- a/bedrock/src/test/java/fr/moussax/bedrock/ui/actionbar/ActionbarComposerTest.java +++ b/bedrock/src/test/java/fr/moussax/bedrock/ui/actionbar/ActionbarComposerTest.java @@ -3,8 +3,6 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import java.util.Objects; - import static org.junit.jupiter.api.Assertions.*; class ActionbarComposerTest { @@ -256,8 +254,7 @@ void testDynamicSupplierModalAlert() { @DisplayName("Expects automated countdown alert to format remaining seconds") void testCountdownAlert() { TimedAlert alert = TimedAlert.countdown("Rebooting in %ds...", 0, 5); - assertEquals("Rebooting in 5ds...", Objects.requireNonNull(alert.message(null)).replace("5s...", "5ds...")); - assertTrue(Objects.requireNonNull(alert.message(null)).startsWith("Rebooting in ")); + assertEquals("Rebooting in 5s...", alert.message(null)); } @Test 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 4d14b4a1..60b4fb28 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 @@ -1,5 +1,6 @@ package fr.moussax.bedrock.ui.animation; +import fr.moussax.bedrock.scheduling.PluginContext; import fr.moussax.bedrock.ui.actionbar.Actionbar; import fr.moussax.bedrock.ui.actionbar.ActionbarService; import fr.moussax.bedrock.ui.title.TimeableTitle; @@ -9,7 +10,6 @@ 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; @@ -21,6 +21,7 @@ import java.util.List; import java.util.UUID; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import static org.junit.jupiter.api.Assertions.*; @@ -29,12 +30,15 @@ class TextAnimationTest { @BeforeEach void setUp() { PluginContext.bind(createMockPlugin()); + TextAnimation.setCustomActionbarHandler((_, _) -> { + }); } @AfterEach void tearDown() { TitlePacketSender.setCustomHandler(null); TextAnimation.setCustomActionbarHandler(null); + TextAnimation.clearActivePlaybacks(); ActionbarService.setInstance(null); TitleService.setInstance(null); PluginContext.unbind(); @@ -46,11 +50,23 @@ private Player createMockPlayer(UUID uuid, List titleRef) { 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; + switch (name) { + case "getUniqueId" -> { + return uuid; + } + case "isOnline" -> { + return true; + } + case "getName" -> { + return "TestPlayer"; + } + case "getLocation" -> { + return new Location(null, 0, 0, 0); + } + case "playSound" -> { + return null; + } + } if ("sendTitle".equals(name) && args != null && args.length > 0) { titleRef.add((String) args[0]); return null; @@ -269,11 +285,151 @@ void testOfflinePlayerCompletesBarrier() { AtomicBoolean completed = new AtomicBoolean(false); TextAnimation anim = TextAnimation.builder().frame("Test").build(); - anim.play(createMockPlugin(), offlinePlayer, (_, _) -> {}, () -> completed.set(true)); + anim.play(createMockPlugin(), offlinePlayer, (_, _) -> { + }, () -> completed.set(true)); assertTrue(completed.get(), "Offline player must trigger completion callback"); } + @Test + @DisplayName("Expects playActionbar replacement to cancel previous playback and invoke its callback") + void testPlayActionbarReplacementCancelsPreviousPlayback() { + Plugin plugin = createMockPlugin(); + UUID uuid = UUID.randomUUID(); + Player player = createMockPlayer(uuid, new ArrayList<>()); + ActionbarService service = new ActionbarService(plugin); + + AtomicBoolean completed1 = new AtomicBoolean(false); + AtomicBoolean completed2 = new AtomicBoolean(false); + + TextAnimation anim1 = TextAnimation.builder().frame("Frame 1").finalStay(Duration.ofSeconds(10)).build(); + TextAnimation anim2 = TextAnimation.builder().frame("Frame 2").finalStay(Duration.ofSeconds(10)).build(); + + TextAnimation.Playback playback1 = anim1.playActionbar(plugin, player, () -> completed1.set(true)); + assertNotNull(playback1); + assertEquals(playback1, TextAnimation.getActivePlayback(player, TextAnimation.Channel.ACTIONBAR)); + assertFalse(playback1.isCancelled()); + assertTrue(service.isAnimating(uuid)); + + TextAnimation.Playback playback2 = anim2.playActionbar(plugin, player, () -> completed2.set(true)); + assertNotNull(playback2); + assertTrue(playback1.isCancelled(), "Previous playback must be cancelled on replacement"); + assertTrue(completed1.get(), "Previous playback callback must be invoked upon cancellation"); + assertEquals(playback2, TextAnimation.getActivePlayback(player, TextAnimation.Channel.ACTIONBAR)); + } + + @Test + @DisplayName("Expects playTitle replacement to cancel previous playback and invoke its callback") + void testPlayTitleReplacementCancelsPreviousPlayback() { + Plugin plugin = createMockPlugin(); + UUID uuid = UUID.randomUUID(); + Player player = createMockPlayer(uuid, new ArrayList<>()); + TitleService service = new TitleService(plugin); + + AtomicBoolean completed1 = new AtomicBoolean(false); + AtomicBoolean completed2 = new AtomicBoolean(false); + + TextAnimation anim1 = TextAnimation.builder().frame("Title 1").finalTimes(TimeableTitle.of(0, 100, 0)).build(); + TextAnimation anim2 = TextAnimation.builder().frame("Title 2").finalTimes(TimeableTitle.of(0, 100, 0)).build(); + + TextAnimation.Playback playback1 = anim1.playTitle(plugin, player, () -> completed1.set(true)); + assertNotNull(playback1); + assertEquals(playback1, TextAnimation.getActivePlayback(player, TextAnimation.Channel.TITLE)); + assertFalse(playback1.isCancelled()); + assertTrue(service.isAnimating(uuid)); + + TextAnimation.Playback playback2 = anim2.playTitle(plugin, player, () -> completed2.set(true)); + assertNotNull(playback2); + assertTrue(playback1.isCancelled(), "Previous playback must be cancelled on replacement"); + assertTrue(completed1.get(), "Previous playback callback must be invoked upon cancellation"); + assertEquals(playback2, TextAnimation.getActivePlayback(player, TextAnimation.Channel.TITLE)); + } + + @Test + @DisplayName("Expects actionbar and title playbacks to be tracked independently per channel") + void testChannelsAreIndependent() { + Plugin plugin = createMockPlugin(); + UUID uuid = UUID.randomUUID(); + Player player = createMockPlayer(uuid, new ArrayList<>()); + + TextAnimation animBar = TextAnimation.builder().frame("Actionbar").finalStay(Duration.ofSeconds(10)).build(); + TextAnimation animTitle = TextAnimation.builder().frame("Title").finalTimes(TimeableTitle.of(0, 100, 0)).build(); + + TextAnimation.Playback barPlayback = animBar.playActionbar(plugin, player, null); + TextAnimation.Playback titlePlayback = animTitle.playTitle(plugin, player, null); + + assertNotNull(barPlayback); + assertNotNull(titlePlayback); + assertFalse(barPlayback.isCancelled()); + assertFalse(titlePlayback.isCancelled()); + assertEquals(barPlayback, TextAnimation.getActivePlayback(player, TextAnimation.Channel.ACTIONBAR)); + assertEquals(titlePlayback, TextAnimation.getActivePlayback(player, TextAnimation.Channel.TITLE)); + } + + @Test + @DisplayName("Expects handle cancellation to clear animation state, restore HUD, and invoke callback exactly once") + void testHandleCancellationClearsStateAndInvokesCallbackExactlyOnce() { + Plugin plugin = createMockPlugin(); + UUID uuid = UUID.randomUUID(); + Player player = createMockPlayer(uuid, new ArrayList<>()); + ActionbarService service = new ActionbarService(plugin); + + AtomicInteger callbackCalls = new AtomicInteger(0); + TextAnimation anim = TextAnimation.builder().frame("Frame").finalStay(Duration.ofSeconds(10)).build(); + + TextAnimation.Playback playback = anim.playActionbar(plugin, player, callbackCalls::incrementAndGet); + assertNotNull(playback); + assertTrue(service.isAnimating(uuid)); + assertFalse(playback.isCancelled()); + + playback.cancel(); + assertTrue(playback.isCancelled()); + assertFalse(service.isAnimating(uuid), "Animation state must be cleared on cancellation"); + assertEquals(1, callbackCalls.get(), "Callback must be invoked exactly once on cancellation"); + assertNull(TextAnimation.getActivePlayback(player, TextAnimation.Channel.ACTIONBAR)); + + // Subsequent cancellation should be idempotent + playback.cancel(); + assertEquals(1, callbackCalls.get(), "Subsequent cancellation must not re-invoke callback"); + } + + @Test + @DisplayName("Expects returned handle to own chained stages and cancelling it during final stay cleans up properly") + void testHandleOwnsChainedStagesAndCancellingHaltsSequence() { + Plugin plugin = createMockPlugin(); + UUID uuid = UUID.randomUUID(); + Player player = createMockPlayer(uuid, new ArrayList<>()); + ActionbarService service = new ActionbarService(plugin); + + AtomicBoolean stage1Ran = new AtomicBoolean(false); + AtomicBoolean stage2Ran = new AtomicBoolean(false); + AtomicInteger completionCount = new AtomicInteger(0); + + TextAnimation stage1 = TextAnimation.builder().frame("Stage 1") + .onComplete(_ -> stage1Ran.set(true)) + .build(); + TextAnimation stage2 = TextAnimation.builder().frame("Stage 2") + .onComplete(_ -> stage2Ran.set(true)) + .finalStay(Duration.ofSeconds(10)) + .build(); + + TextAnimation chained = stage1.then(stage2); + TextAnimation.Playback handle = chained.playActionbar(plugin, player, completionCount::incrementAndGet); + + assertNotNull(handle); + assertTrue(stage1Ran.get(), "Stage 1 should complete and transition to Stage 2"); + assertTrue(stage2Ran.get(), "Stage 2 should execute"); + assertTrue(service.isAnimating(uuid)); + assertEquals(handle, TextAnimation.getActivePlayback(player, TextAnimation.Channel.ACTIONBAR)); + + handle.cancel(); + + assertTrue(handle.isCancelled()); + assertFalse(service.isAnimating(uuid)); + assertEquals(1, completionCount.get()); + assertNull(TextAnimation.getActivePlayback(player, TextAnimation.Channel.ACTIONBAR)); + } + private Player createOfflineMockPlayer(UUID uuid) { return (Player) Proxy.newProxyInstance( Player.class.getClassLoader(), diff --git a/bedrock/src/test/java/fr/moussax/bedrock/ui/book/BookMenuTest.java b/bedrock/src/test/java/fr/moussax/bedrock/ui/book/BookMenuTest.java index 6a1998c8..83b35f45 100644 --- a/bedrock/src/test/java/fr/moussax/bedrock/ui/book/BookMenuTest.java +++ b/bedrock/src/test/java/fr/moussax/bedrock/ui/book/BookMenuTest.java @@ -289,6 +289,15 @@ void shouldWrapLongTextIntoPages() { assertTrue(pages.getFirst().build().length > 0); } + @Test + @DisplayName("BookPaginator preserves ampersand colors across wrapped lines") + void shouldPreserveAmpersandColorsAcrossWrappedLines() { + List wrappedLines = BookPaginator.wrapText("&a" + "green ".repeat(30)); + + assertTrue(wrappedLines.size() > 1); + assertTrue(wrappedLines.stream().allMatch(line -> line.startsWith("§a"))); + } + @Test @DisplayName("BookMenu builder sets metadata and builds pages") void shouldBuildBookModel() { @@ -378,6 +387,30 @@ void shouldResolveLogicalPageJumpsAcrossSpilledPages() { assertEquals("1", backBtn.getClickEvent().getValue(), "Logical page 1 must resolve to physical page 1"); } + @Test + @DisplayName("BookMenu resolves page links independently for each build") + void shouldResolvePageLinksWithoutMutatingConfiguredComponents() { + BookMenu menu = BookMenu.builder() + .page(page -> { + for (int i = 1; i <= 14; i++) { + page.line("TOC line " + i); + } + page.actionPage("[Go to Chapter 2]", "Chapter 2", 2); + }) + .page(page -> page.title("CHAPTER 2")) + .page(page -> page.title("CHAPTER 3")); + + List firstBuild = menu.buildPhysicalPages(); + List secondBuild = menu.buildPhysicalPages(); + + TextComponent firstLink = findComponentWithText(firstBuild.get(1), "[Go to Chapter 2]"); + TextComponent secondLink = findComponentWithText(secondBuild.get(1), "[Go to Chapter 2]"); + assertNotNull(firstLink); + assertNotNull(secondLink); + assertEquals("3", firstLink.getClickEvent().getValue()); + assertEquals("3", secondLink.getClickEvent().getValue()); + } + @Test @DisplayName("BookMenu resolves named page jumps across spilled pages") void shouldResolveNamedPageJumpsAcrossSpilledPages() { From fe66a5c7ffb4548fdf2c82d53ea32e90ac295990 Mon Sep 17 00:00:00 2001 From: Moussa Date: Thu, 1 Oct 2026 14:16:18 +0200 Subject: [PATCH 23/23] fix(animation): invalidate composer cache and reset cancelled title animations --- .../bedrock/ui/animation/TextAnimation.java | 4 ++ .../bedrock/ui/title/TitleComposer.java | 7 +++ .../bedrock/ui/title/TitleService.java | 23 ++++++++++ .../ui/animation/TextAnimationTest.java | 43 +++++++++++++++++++ 4 files changed, 77 insertions(+) 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 1f263bf3..8895f51d 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 @@ -603,9 +603,13 @@ private void cleanupAndComplete() { service.renderPlayer(player); } } else { + if (cancelled.get()) { + player.resetTitle(); + } TitleService service = TitleService.getInstance(); if (service != null) { service.setAnimating(uuid, false); + service.invalidateCache(uuid); service.renderPlayer(player); } } 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 index c61e7dc8..1753fdd5 100644 --- a/bedrock/src/main/java/fr/moussax/bedrock/ui/title/TitleComposer.java +++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/title/TitleComposer.java @@ -91,6 +91,13 @@ public void clear(@NonNull Player player) { clearAlerts(); clearPersistentTitle(); player.resetTitle(); + invalidateCache(); + } + + /** + * Invalidates cached title, subtitle, and timing states so the next render pass resends them. + */ + public void invalidateCache() { lastSentTitle = null; lastSentSubtitle = null; lastSentTimes = null; 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 index e42c6067..9af2e7cb 100644 --- a/bedrock/src/main/java/fr/moussax/bedrock/ui/title/TitleService.java +++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/title/TitleService.java @@ -12,6 +12,7 @@ import org.bukkit.plugin.Plugin; import org.bukkit.scheduler.BukkitTask; import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; import java.util.Map; import java.util.Objects; @@ -126,6 +127,28 @@ public TitleComposer getOrCreateComposer(@NonNull UUID uuid) { return composers.computeIfAbsent(uuid, _ -> new TitleComposer()); } + /** + * Retrieves the title composer for the specified player UUID if present. + * + * @param uuid player unique ID + * @return player's title composer, or null if absent + */ + public @Nullable TitleComposer getComposer(@NonNull UUID uuid) { + return composers.get(uuid); + } + + /** + * Invalidates the cached title render state for a player, ensuring the next render resends. + * + * @param uuid player unique ID + */ + public void invalidateCache(@NonNull UUID uuid) { + TitleComposer composer = composers.get(uuid); + if (composer != null) { + composer.invalidateCache(); + } + } + /** * Sets a persistent ambient title on a player and immediately renders it. * 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 60b4fb28..491d1016 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 @@ -45,6 +45,10 @@ void tearDown() { } private Player createMockPlayer(UUID uuid, List titleRef) { + return createMockPlayer(uuid, titleRef, new AtomicInteger()); + } + + private Player createMockPlayer(UUID uuid, List titleRef, AtomicInteger resetCount) { return (Player) Proxy.newProxyInstance( Player.class.getClassLoader(), new Class[]{Player.class}, @@ -66,6 +70,10 @@ private Player createMockPlayer(UUID uuid, List titleRef) { case "playSound" -> { return null; } + case "resetTitle" -> { + resetCount.incrementAndGet(); + return null; + } } if ("sendTitle".equals(name) && args != null && args.length > 0) { titleRef.add((String) args[0]); @@ -430,6 +438,41 @@ void testHandleOwnsChainedStagesAndCancellingHaltsSequence() { assertNull(TextAnimation.getActivePlayback(player, TextAnimation.Channel.ACTIONBAR)); } + @Test + @DisplayName("Expects title cancellation to reset client title and invalidate composer cache while normal completion preserves fadeout") + void testTitleCleanupDistinguishesCancellationFromNormalCompletion() { + Plugin plugin = createMockPlugin(); + UUID uuid = UUID.randomUUID(); + AtomicInteger resetCalls = new AtomicInteger(0); + List titlesSent = new ArrayList<>(); + Player player = createMockPlayer(uuid, titlesSent, resetCalls); + TitleService service = new TitleService(plugin); + + service.setPersistent(player, fr.moussax.bedrock.ui.title.PersistentTitle.of("Persistent Title", "Persistent Subtitle")); + titlesSent.clear(); + + TextAnimation anim = TextAnimation.builder().frame("Anim Frame").finalTimes(TimeableTitle.of(0, 100, 0)).build(); + TextAnimation.Playback playback = anim.playTitle(plugin, player, null); + assertNotNull(playback); + assertTrue(service.isAnimating(uuid)); + + playback.cancel(); + assertTrue(playback.isCancelled()); + assertFalse(service.isAnimating(uuid)); + assertEquals(1, resetCalls.get(), "Cancelled title playback must reset client title"); + assertTrue(titlesSent.contains("Persistent Title"), "Invalidated cache must cause renderPlayer to resend persistent title"); + + resetCalls.set(0); + titlesSent.clear(); + TextAnimation normalAnim = TextAnimation.builder().frame("Normal Frame").finalTimes(TimeableTitle.of(0, 0, 0)).build(); + TextAnimation.Playback normalPlayback = normalAnim.playTitle(plugin, player, null); + assertNotNull(normalPlayback); + + assertFalse(normalPlayback.isCancelled()); + assertEquals(0, resetCalls.get(), "Normal completion must NOT reset client title so fade-out is preserved"); + assertTrue(titlesSent.contains("Persistent Title"), "Normal completion must also restore persistent title via invalidated cache"); + } + private Player createOfflineMockPlayer(UUID uuid) { return (Player) Proxy.newProxyInstance( Player.class.getClassLoader(),