From cf6d935037049f1724d65c59f440a015e044aad7 Mon Sep 17 00:00:00 2001 From: Moussa Date: Sun, 4 Oct 2026 12:50:24 +0200 Subject: [PATCH 1/3] feat(bossbar): implement Bossbar service and related classes for managing boss bars --- .../fr/moussax/bedrock/sound/SoundCue.java | 29 ++ .../moussax/bedrock/ui/bossbar/Bossbar.java | 415 +++++++++++++++++ .../bedrock/ui/bossbar/BossbarAlert.java | 427 ++++++++++++++++++ .../bedrock/ui/bossbar/BossbarComposer.java | 382 ++++++++++++++++ .../bedrock/ui/bossbar/BossbarSection.java | 304 +++++++++++++ .../bedrock/ui/bossbar/BossbarService.java | 317 +++++++++++++ .../bedrock/ui/bossbar/BossbarTest.java | 204 +++++++++ 7 files changed, 2078 insertions(+) create mode 100644 bedrock/src/main/java/fr/moussax/bedrock/ui/bossbar/Bossbar.java create mode 100644 bedrock/src/main/java/fr/moussax/bedrock/ui/bossbar/BossbarAlert.java create mode 100644 bedrock/src/main/java/fr/moussax/bedrock/ui/bossbar/BossbarComposer.java create mode 100644 bedrock/src/main/java/fr/moussax/bedrock/ui/bossbar/BossbarSection.java create mode 100644 bedrock/src/main/java/fr/moussax/bedrock/ui/bossbar/BossbarService.java create mode 100644 bedrock/src/test/java/fr/moussax/bedrock/ui/bossbar/BossbarTest.java 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 458d920d..0ac8a991 100644 --- a/bedrock/src/main/java/fr/moussax/bedrock/sound/SoundCue.java +++ b/bedrock/src/main/java/fr/moussax/bedrock/sound/SoundCue.java @@ -22,6 +22,35 @@ */ public record SoundCue(Sound sound, float volume, float pitch, long delay) { + /** + * Creates an immediate sound cue without delay. + * + * @param sound Bukkit sound type + * @param volume sound volume + * @param pitch sound pitch + * @return sound cue + */ + @NonNull + public static SoundCue of(@NonNull Sound sound, float volume, float pitch) { + Objects.requireNonNull(sound, "sound cannot be null"); + return new SoundCue(sound, volume, pitch, 0L); + } + + /** + * Creates a sound cue with a configured delay in ticks. + * + * @param sound Bukkit sound type + * @param volume sound volume + * @param pitch sound pitch + * @param delay delay in ticks + * @return sound cue + */ + @NonNull + public static SoundCue of(@NonNull Sound sound, float volume, float pitch, long delay) { + Objects.requireNonNull(sound, "sound cannot be null"); + return new SoundCue(sound, volume, pitch, delay); + } + /** * Plays this sound at the specified location after the configured delay. * diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/bossbar/Bossbar.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/bossbar/Bossbar.java new file mode 100644 index 00000000..333df877 --- /dev/null +++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/bossbar/Bossbar.java @@ -0,0 +1,415 @@ +package fr.moussax.bedrock.ui.bossbar; + +import fr.moussax.bedrock.scheduling.PluginContext; +import fr.moussax.bedrock.sound.SoundCue; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.boss.BarColor; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import java.time.Duration; +import java.util.Collection; +import java.util.Objects; +import java.util.function.IntFunction; +import java.util.function.Predicate; +import java.util.function.Supplier; + +/** + * Static entry point for displaying and managing boss bars. + * + *

Supports timed alerts that drain over their lifespan, automated countdowns, and persistent + * {@linkplain BossbarSection sections} (global or per player) with visibility predicates. Bars stack: + * each alert and each visible section is its own bar.

+ * + *

If {@link #initialize(Plugin, long)} was never called, the first call initializes the service + * from {@link PluginContext}, which must then be bound.

+ */ +public final class Bossbar { + + /** + * Default duration for alerts sent without an explicit one (3 seconds). + */ + public static final Duration DEFAULT_DURATION = Duration.ofSeconds(3); + + private static final long DEFAULT_PERIOD_TICKS = 1L; + + private Bossbar() { + } + + /** + * Initializes or retrieves the shared boss bar service for the given plugin. + * + * @param plugin owning plugin + * @param periodTicks interval in ticks between render passes + * @return the active boss bar service + */ + @NonNull + public static BossbarService initialize(@NonNull Plugin plugin, long periodTicks) { + BossbarService service = BossbarService.getOrCreate(plugin); + service.start(periodTicks); + return service; + } + + /** + * Creates a visibility predicate that is true for players in the same world within a radius + * of a moving point, e.g. a boss. + * + *
{@code
+     * .visibleWhen(Bossbar.within(knight::getLocation, 60))
+     * }
+ * + * @param center supplies the center, or {@code null} when it no longer exists (nobody sees the bar) + * @param radius radius in blocks + * @return visibility predicate + */ + @NonNull + public static Predicate within(@NonNull Supplier<@Nullable Location> center, double radius) { + double radiusSquared = radius * radius; + return player -> { + Location location = center.get(); + return location != null + && location.getWorld() != null + && location.getWorld().equals(player.getWorld()) + && player.getLocation().distanceSquared(location) <= radiusSquared; + }; + } + + /** + * Creates a visibility predicate that is true for players in the same world within a radius + * of an entity, automatically verifying that the entity is valid and alive. + * + * @param entity target entity + * @param radius radius in blocks + * @return visibility predicate + */ + @NonNull + public static Predicate within(@NonNull Entity entity, double radius) { + Objects.requireNonNull(entity, "entity cannot be null"); + double radiusSquared = radius * radius; + return player -> entity.isValid() + && !entity.isDead() + && entity.getWorld() != null + && entity.getWorld().equals(player.getWorld()) + && player.getLocation().distanceSquared(entity.getLocation()) <= radiusSquared; + } + + /** + * Shows a white alert that drains over {@link #DEFAULT_DURATION}. + * + * @param player target player + * @param message title text + */ + public static void sendAlert(@NonNull Player player, @NonNull String message) { + sendAlert(player, message, DEFAULT_DURATION); + } + + /** + * Shows a white alert that drains over the given duration. + * + * @param player target player + * @param message title text + * @param duration lifespan + */ + public static void sendAlert(@NonNull Player player, @NonNull String message, @NonNull Duration duration) { + sendAlert(player, BossbarAlert.of(message, duration)); + } + + /** + * Shows a white alert with the given priority. + * + * @param player target player + * @param message title text + * @param priority precedence (highest first) + * @param duration lifespan + */ + public static void sendAlert(@NonNull Player player, @NonNull String message, int priority, @NonNull Duration duration) { + sendAlert(player, BossbarAlert.of(message, priority, duration)); + } + + /** + * Shows a colored alert with the given priority. + * + * @param player target player + * @param message title text + * @param color bar color + * @param priority precedence (highest first) + * @param duration lifespan + */ + public static void sendAlert( + @NonNull Player player, + @NonNull String message, + @NonNull BarColor color, + int priority, + @NonNull Duration duration + ) { + sendAlert(player, BossbarAlert.of(message, color, priority, duration)); + } + + /** + * Shows a white alert that drains over the given duration and plays a sound cue. + * + * @param player target player + * @param message title text + * @param duration lifespan + * @param soundCue optional audio cue + */ + public static void sendAlert( + @NonNull Player player, + @NonNull String message, + @NonNull Duration duration, + @Nullable SoundCue soundCue + ) { + sendAlert(player, BossbarAlert.of(message, BarColor.WHITE, 0, duration, soundCue)); + } + + /** + * Shows a colored alert with priority and an audio cue. + * + * @param player target player + * @param message title text + * @param color bar color + * @param priority precedence (highest first) + * @param duration lifespan + * @param soundCue optional audio cue + */ + public static void sendAlert( + @NonNull Player player, + @NonNull String message, + @NonNull BarColor color, + int priority, + @NonNull Duration duration, + @Nullable SoundCue soundCue + ) { + sendAlert(player, BossbarAlert.of(message, color, priority, duration, soundCue)); + } + + /** + * Shows a fully configured alert. + * + * @param player target player + * @param alert alert to show + */ + public static void sendAlert(@NonNull Player player, @NonNull BossbarAlert alert) { + service().sendAlert(player, alert); + } + + /** + * Shows a fully configured alert to several players. + * + * @param players target players + * @param alert alert to show + */ + public static void sendAlert(@NonNull Collection players, @NonNull BossbarAlert alert) { + BossbarService service = service(); + for (Player player : players) { + service.sendAlert(player, alert); + } + } + + /** + * Shows a white alert to every online player. + * + * @param message title text + * @param duration lifespan + */ + public static void broadcastAlert(@NonNull String message, @NonNull Duration duration) { + broadcastAlert(BossbarAlert.of(message, duration)); + } + + /** + * Shows a fully configured alert to every online player. + * + * @param alert alert to show + */ + public static void broadcastAlert(@NonNull BossbarAlert alert) { + sendAlert(Bukkit.getOnlinePlayers(), alert); + } + + /** + * Shows a countdown that drains with the time left (e.g. {@code "Rebooting in %ds"}). + * + * @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, null); + } + + /** + * Shows a countdown that drains with the time left and plays a sound cue. + * + * @param player target player + * @param format format string containing {@code %d} + * @param seconds countdown duration in seconds + * @param soundCue optional audio cue + */ + public static void sendCountdown(@NonNull Player player, @NonNull String format, int seconds, @Nullable SoundCue soundCue) { + sendCountdown(player, format, seconds, 0, soundCue); + } + + /** + * Shows a countdown with a custom priority. + * + * @param player target player + * @param format format string containing {@code %d} + * @param seconds countdown duration in seconds + * @param priority precedence (highest first) + */ + public static void sendCountdown(@NonNull Player player, @NonNull String format, int seconds, int priority) { + sendCountdown(player, format, seconds, priority, null); + } + + /** + * Shows a countdown with a custom priority and audio cue. + * + * @param player target player + * @param format format string containing {@code %d} + * @param seconds countdown duration in seconds + * @param priority precedence (highest first) + * @param soundCue optional audio cue + */ + public static void sendCountdown(@NonNull Player player, @NonNull String format, int seconds, int priority, @Nullable SoundCue soundCue) { + sendAlert(player, BossbarAlert.countdown(format, priority, seconds, soundCue)); + } + + /** + * Shows a countdown with a custom second formatter. + * + * @param player target player + * @param formatter function receiving the remaining whole seconds + * @param seconds countdown duration in seconds + */ + public static void sendCountdown(@NonNull Player player, @NonNull IntFunction formatter, int seconds) { + sendCountdown(player, formatter, seconds, 0, null); + } + + /** + * Shows a countdown with a custom second formatter and priority. + * + * @param player target player + * @param formatter function receiving the remaining whole seconds + * @param seconds countdown duration in seconds + * @param priority precedence (highest first) + */ + public static void sendCountdown(@NonNull Player player, @NonNull IntFunction formatter, int seconds, int priority) { + sendCountdown(player, formatter, seconds, priority, null); + } + + /** + * Shows a countdown with a custom second formatter, priority, and audio cue. + * + * @param player target player + * @param formatter function receiving the remaining whole seconds + * @param seconds countdown duration in seconds + * @param priority precedence (highest first) + * @param soundCue optional audio cue + */ + public static void sendCountdown( + @NonNull Player player, + @NonNull IntFunction formatter, + int seconds, + int priority, + @Nullable SoundCue soundCue + ) { + sendAlert(player, BossbarAlert.countdown(formatter, BarColor.WHITE, priority, seconds, soundCue)); + } + + /** + * Registers a global section owned by the service's plugin, shown to every player for whom it is visible. + * + * @param section section to register + */ + public static void register(@NonNull BossbarSection section) { + service().register(section); + } + + /** + * Registers a global section owned by the given plugin. It is removed by + * {@link #unregisterAll(Plugin)} or when that plugin is disabled. + * + * @param plugin owning plugin + * @param section section to register + */ + public static void register(@NonNull Plugin plugin, @NonNull BossbarSection section) { + service().register(plugin, section); + } + + /** + * Registers a section for one player only. + * + * @param player target player + * @param section section to register + */ + public static void register(@NonNull Player player, @NonNull BossbarSection section) { + service().register(player, section); + } + + /** + * Unregisters a global section. + * + * @param sectionId section id + */ + public static void unregister(@NonNull String sectionId) { + service().unregister(sectionId); + } + + /** + * Unregisters a section registered for one player. + * + * @param player target player + * @param sectionId section id + */ + public static void unregister(@NonNull Player player, @NonNull String sectionId) { + service().unregister(player, sectionId); + } + + /** + * Unregisters every global section owned by a plugin. + * + * @param plugin owning plugin + */ + public static void unregisterAll(@NonNull Plugin plugin) { + service().unregisterAll(plugin); + } + + /** + * Forces an immediate render for a player instead of waiting for the next periodic pass. + * + * @param player target player + */ + public static void update(@NonNull Player player) { + service().renderPlayer(player); + } + + /** + * Clears a player's alerts, restoring the sections underneath. + * + * @param player target player + */ + public static void clearAlerts(@NonNull Player player) { + service().clearAlerts(player); + } + + /** + * Clears a player's alerts and player-level sections and removes every bar from their screen. + * Global sections reappear on the next render. + * + * @param player target player + */ + public static void clear(@NonNull Player player) { + service().clear(player); + } + + private static BossbarService service() { + BossbarService service = BossbarService.getInstance(); + if (service != null) { + return service; + } + return initialize(PluginContext.get(), DEFAULT_PERIOD_TICKS); + } +} diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/bossbar/BossbarAlert.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/bossbar/BossbarAlert.java new file mode 100644 index 00000000..03e7e6c9 --- /dev/null +++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/bossbar/BossbarAlert.java @@ -0,0 +1,427 @@ +package fr.moussax.bedrock.ui.bossbar; + +import fr.moussax.bedrock.sound.SoundCue; +import org.bukkit.boss.BarColor; +import org.bukkit.boss.BarStyle; +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.ToDoubleFunction; + +/** + * An immutable specification for a temporary boss bar with a lifespan. + * + *

Alerts stack above persistent {@link BossbarSection}s, highest priority first. An + * {@linkplain #exclusive(String, int, Duration) exclusive} alert hides all persistent sections + * while it is active. When an alert expires, the bars underneath are restored automatically.

+ * + *

Unless a progress function is given, the bar drains from full to empty over the alert's + * lifespan. When queued into a composer, each viewing player's audio cue plays once.

+ */ +public record BossbarAlert(Function titleSupplier, + @Nullable IntFunction<@Nullable String> countdownFormatter, + @Nullable ToDoubleFunction progressSupplier, BarColor color, BarStyle style, + int priority, boolean exclusive, Duration duration, + @Nullable SoundCue soundCue) implements Comparable { + + /** + * Constructs an alert specification with explicit parameters. + * + * @param titleSupplier title function; {@code null} hides the bar for that player + * @param countdownFormatter countdown formatter receiving remaining seconds, or {@code null} + * @param progressSupplier fill ratio function, or {@code null} to drain over the lifespan + * @param color bar color + * @param style bar style + * @param priority precedence (highest first) + * @param exclusive whether persistent sections are hidden while active + * @param duration lifespan duration + * @param soundCue optional sound played once per player on first render + */ + public BossbarAlert( + @NonNull Function titleSupplier, + @Nullable IntFunction<@Nullable String> countdownFormatter, + @Nullable ToDoubleFunction progressSupplier, + @NonNull BarColor color, + @NonNull BarStyle style, + int priority, + boolean exclusive, + @NonNull Duration duration, + @Nullable SoundCue soundCue + ) { + this.titleSupplier = Objects.requireNonNull(titleSupplier, "titleSupplier cannot be null"); + this.countdownFormatter = countdownFormatter; + this.progressSupplier = progressSupplier; + this.color = Objects.requireNonNull(color, "color cannot be null"); + this.style = Objects.requireNonNull(style, "style cannot be null"); + this.priority = priority; + this.exclusive = exclusive; + this.duration = Objects.requireNonNull(duration, "duration cannot be null"); + this.soundCue = soundCue; + } + + /** + * Constructs an alert specification with millisecond duration. + */ + public BossbarAlert( + @NonNull Function titleSupplier, + @Nullable ToDoubleFunction progressSupplier, + @NonNull BarColor color, + @NonNull BarStyle style, + int priority, + boolean exclusive, + long durationMillis, + @Nullable SoundCue soundCue + ) { + this( + titleSupplier, + null, + progressSupplier, + color, + style, + priority, + exclusive, + Duration.ofMillis(Math.max(0L, durationMillis)), + soundCue + ); + } + + /** + * Creates a default-priority, white, draining alert. + * + * @param title title text + * @param duration lifespan + * @return the alert + */ + @NonNull + public static BossbarAlert of(@NonNull String title, @NonNull Duration duration) { + return of(title, BarColor.WHITE, 0, duration, null); + } + + /** + * Creates a default-priority, white, draining alert with an audio cue. + * + * @param title title text + * @param duration lifespan + * @param soundCue optional sound cue + * @return the alert + */ + @NonNull + public static BossbarAlert of(@NonNull String title, @NonNull Duration duration, @Nullable SoundCue soundCue) { + return of(title, BarColor.WHITE, 0, duration, soundCue); + } + + /** + * Creates a white draining alert with the given priority. + * + * @param title title text + * @param priority precedence (highest first) + * @param duration lifespan + * @return the alert + */ + @NonNull + public static BossbarAlert of(@NonNull String title, int priority, @NonNull Duration duration) { + return of(title, BarColor.WHITE, priority, duration, null); + } + + /** + * Creates a draining alert. + * + * @param title title text + * @param color bar color + * @param priority precedence (highest first) + * @param duration lifespan + * @return the alert + */ + @NonNull + public static BossbarAlert of(@NonNull String title, @NonNull BarColor color, int priority, @NonNull Duration duration) { + return of(title, color, priority, duration, null); + } + + /** + * Creates a draining alert with an audio cue. + * + * @param title title text + * @param color bar color + * @param priority precedence (highest first) + * @param duration lifespan + * @param soundCue optional sound cue + * @return the alert + */ + @NonNull + public static BossbarAlert of( + @NonNull String title, + @NonNull BarColor color, + int priority, + @NonNull Duration duration, + @Nullable SoundCue soundCue + ) { + Objects.requireNonNull(title, "title cannot be null"); + return of(_ -> title, color, priority, duration, soundCue); + } + + /** + * Creates a draining alert with a per-player title. + * + * @param titleSupplier title function; {@code null} hides the bar for that player + * @param color bar color + * @param priority precedence (highest first) + * @param duration lifespan + * @return the alert + */ + @NonNull + public static BossbarAlert of( + @NonNull Function titleSupplier, + @NonNull BarColor color, + int priority, + @NonNull Duration duration + ) { + return of(titleSupplier, color, priority, duration, null); + } + + /** + * Creates a draining alert with a per-player title and audio cue. + * + * @param titleSupplier title function; {@code null} hides the bar for that player + * @param color bar color + * @param priority precedence (highest first) + * @param duration lifespan + * @param soundCue optional sound cue + * @return the alert + */ + @NonNull + public static BossbarAlert of( + @NonNull Function titleSupplier, + @NonNull BarColor color, + int priority, + @NonNull Duration duration, + @Nullable SoundCue soundCue + ) { + Objects.requireNonNull(duration, "duration cannot be null"); + return new BossbarAlert(titleSupplier, null, null, color, BarStyle.SOLID, priority, false, duration, soundCue); + } + + /** + * Creates a draining alert that hides all persistent sections while it is active. + * + * @param title title text + * @param duration lifespan + * @return the alert + */ + @NonNull + public static BossbarAlert exclusive(@NonNull String title, @NonNull Duration duration) { + return exclusive(title, 0, duration, null); + } + + /** + * Creates an exclusive draining alert with an audio cue. + * + * @param title title text + * @param duration lifespan + * @param soundCue optional sound cue + * @return the alert + */ + @NonNull + public static BossbarAlert exclusive(@NonNull String title, @NonNull Duration duration, @Nullable SoundCue soundCue) { + return exclusive(title, 0, duration, soundCue); + } + + /** + * Creates a draining alert that hides all persistent sections while it is active. + * + * @param title title text + * @param priority precedence among alerts (highest first) + * @param duration lifespan + * @return the alert + */ + @NonNull + public static BossbarAlert exclusive(@NonNull String title, int priority, @NonNull Duration duration) { + return exclusive(title, priority, duration, null); + } + + /** + * Creates a draining alert that hides all persistent sections with an audio cue. + * + * @param title title text + * @param priority precedence among alerts (highest first) + * @param duration lifespan + * @param soundCue optional sound cue + * @return the alert + */ + @NonNull + public static BossbarAlert exclusive( + @NonNull String title, + int priority, + @NonNull Duration duration, + @Nullable SoundCue soundCue + ) { + Objects.requireNonNull(title, "title cannot be null"); + Objects.requireNonNull(duration, "duration cannot be null"); + return new BossbarAlert(_ -> title, null, null, BarColor.WHITE, BarStyle.SOLID, priority, true, duration, soundCue); + } + + /** + * Creates a countdown alert. The title is derived from remaining wall-clock time and the bar drains alongside it. + * + * @param formatter function receiving the remaining whole seconds and returning the title + * @param color bar color + * @param priority precedence (highest first) + * @param seconds initial countdown duration in seconds + * @return the alert + */ + @NonNull + public static BossbarAlert countdown( + @NonNull IntFunction<@Nullable String> formatter, + @NonNull BarColor color, + int priority, + int seconds + ) { + return countdown(formatter, color, priority, seconds, null); + } + + /** + * Creates a countdown alert with an audio cue. + * + * @param formatter function receiving the remaining whole seconds and returning the title + * @param color bar color + * @param priority precedence (highest first) + * @param seconds initial countdown duration in seconds + * @param soundCue optional sound cue + * @return the alert + */ + @NonNull + public static BossbarAlert countdown( + @NonNull IntFunction<@Nullable String> formatter, + @NonNull BarColor color, + int priority, + int seconds, + @Nullable SoundCue soundCue + ) { + Objects.requireNonNull(formatter, "formatter cannot be null"); + Duration duration = Duration.ofSeconds(Math.max(0, seconds)); + return new BossbarAlert(_ -> null, formatter, null, color, BarStyle.SOLID, priority, false, duration, soundCue); + } + + /** + * Creates a white countdown alert from a format string. + * + * @param format string format containing {@code %d} (e.g. {@code "Rebooting in %ds"}) + * @param priority precedence (highest first) + * @param seconds initial countdown duration in seconds + * @return the alert + */ + @NonNull + public static BossbarAlert countdown(@NonNull String format, int priority, int seconds) { + return countdown(format, priority, seconds, null); + } + + /** + * Creates a white countdown alert from a format string with an audio cue. + * + * @param format string format containing {@code %d} (e.g. {@code "Rebooting in %ds"}) + * @param priority precedence (highest first) + * @param seconds initial countdown duration in seconds + * @param soundCue optional sound cue + * @return the alert + */ + @NonNull + public static BossbarAlert countdown(@NonNull String format, int priority, int seconds, @Nullable SoundCue soundCue) { + Objects.requireNonNull(format, "format cannot be null"); + return countdown(format::formatted, BarColor.WHITE, priority, seconds, soundCue); + } + + /** + * Returns a copy of this alert with the specified audio cue. + * + * @param soundCue sound cue to play on initial render + * @return new alert instance + */ + @NonNull + public BossbarAlert withSound(@Nullable SoundCue soundCue) { + return new BossbarAlert(titleSupplier, countdownFormatter, progressSupplier, color, style, priority, exclusive, duration, soundCue); + } + + /** + * Returns a copy of this alert with the specified bar color. + * + * @param color new bar color + * @return new alert instance + */ + @NonNull + public BossbarAlert withColor(@NonNull BarColor color) { + return new BossbarAlert(titleSupplier, countdownFormatter, progressSupplier, color, style, priority, exclusive, duration, soundCue); + } + + /** + * Returns a copy of this alert with the specified bar style. + * + * @param style new bar style + * @return new alert instance + */ + @NonNull + public BossbarAlert withStyle(@NonNull BarStyle style) { + return new BossbarAlert(titleSupplier, countdownFormatter, progressSupplier, color, style, priority, exclusive, duration, soundCue); + } + + /** + * Returns a copy of this alert with the specified precedence priority. + * + * @param priority precedence priority + * @return new alert instance + */ + @NonNull + public BossbarAlert withPriority(int priority) { + return new BossbarAlert(titleSupplier, countdownFormatter, progressSupplier, color, style, priority, exclusive, duration, soundCue); + } + + /** + * Returns a copy of this alert with the specified exclusivity flag. + * + * @param exclusive whether sections should be hidden + * @return new alert instance + */ + @NonNull + public BossbarAlert exclusive(boolean exclusive) { + return new BossbarAlert(titleSupplier, countdownFormatter, progressSupplier, color, style, priority, exclusive, duration, soundCue); + } + + /** + * Evaluates the title for the given player. + * + * @param player viewing player + * @return title, or {@code null} to hide the bar for that player + */ + public @Nullable String title(@Nullable Player player) { + if (countdownFormatter != null) { + int seconds = (int) Math.ceil(duration.toMillis() / 1000.0); + return countdownFormatter.apply(seconds); + } + return titleSupplier.apply(player); + } + + /** + * Evaluates the fill ratio for the given player without an active session context. + * + * @param player viewing player + * @return progress in {@code [0.0, 1.0]} + */ + public double progress(@Nullable Player player) { + if (progressSupplier != null) { + return progressSupplier.applyAsDouble(player); + } + return 1.0; + } + + public long durationMillis() { + return duration.toMillis(); + } + + @Override + public int compareTo(@NonNull BossbarAlert other) { + return Integer.compare(other.priority, this.priority); + } +} diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/bossbar/BossbarComposer.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/bossbar/BossbarComposer.java new file mode 100644 index 00000000..fbd39957 --- /dev/null +++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/bossbar/BossbarComposer.java @@ -0,0 +1,382 @@ +package fr.moussax.bedrock.ui.bossbar; + +import fr.moussax.bedrock.sound.SoundCue; +import org.bukkit.Bukkit; +import org.bukkit.boss.BarColor; +import org.bukkit.boss.BarFlag; +import org.bukkit.boss.BarStyle; +import org.bukkit.boss.BossBar; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.IntFunction; +import java.util.function.Supplier; + +/** + * Manages one player's boss bars: per-player sections, modal alerts, and the + * global sections shared by every player. + * + *

Each visible section or alert is backed by its own Bukkit {@link BossBar}, because a Bukkit + * bar has a single title and progress for its whole audience while suppliers are per player. + * Bars are only touched when their content changed, and are re-added in order only when the + * visible stack order changes.

+ * + *

{@link #render(Player)}, {@link #clear()} and {@link #detach()} must run on the main thread. + * Registration and alert methods are thread-safe.

+ */ +public final class BossbarComposer { + + /** + * Prevents redundant packet dispatch from floating-point jitter while preserving smooth sub-frame lerping. + */ + private static final double PROGRESS_EPSILON = 1e-5; + + private final Supplier> globalSectionsSupplier; + private final Map playerSections = new ConcurrentHashMap<>(); + private final PriorityQueue modalAlerts = new PriorityQueue<>(); + private final Object alertLock = new Object(); + + /** + * Insertion order mirrors the order bars were added on the client. Keys: section id or active alert instance. + */ + private Map handles = new LinkedHashMap<>(); + + /** + * Constructs a composer with no global sections. + */ + public BossbarComposer() { + this(Map::of); + } + + /** + * Constructs a composer that also renders the given global sections. + * + * @param globalSectionsSupplier supplier of the current global sections by id + */ + public BossbarComposer(@NonNull Supplier> globalSectionsSupplier) { + this.globalSectionsSupplier = Objects.requireNonNull(globalSectionsSupplier, "globalSectionsSupplier cannot be null"); + } + + /** + * Registers a section for this player only, replacing any player section with the same id. + * A player section overrides a global section with the same id. + * + * @param section section to register + */ + public void registerSection(@NonNull BossbarSection section) { + Objects.requireNonNull(section, "section cannot be null"); + playerSections.put(section.id(), section); + } + + /** + * Unregisters this player's section. + * + * @param id section id + */ + public void unregisterSection(@NonNull String id) { + playerSections.remove(id); + } + + /** + * Queues a modal alert. An active session is created with a fresh lifespan. + * + * @param alert alert to queue + */ + public void sendModalAlert(@NonNull BossbarAlert alert) { + Objects.requireNonNull(alert, "alert cannot be null"); + synchronized (alertLock) { + modalAlerts.add(new ActiveAlert(alert)); + } + } + + /** + * Clears all modal alerts. Persistent sections reappear on the next render. + */ + public void clearAlerts() { + synchronized (alertLock) { + modalAlerts.clear(); + } + } + + /** + * Clears alerts and this player's sections, and removes every bar from the player's screen. + */ + public void clear() { + clearAlerts(); + playerSections.clear(); + detach(); + } + + /** + * Removes every bar from the player's screen without discarding registered state. + * The next render re-adds whatever is visible. + */ + public void detach() { + handles.values().forEach(Handle::detach); + handles = new LinkedHashMap<>(); + } + + /** + * Forces the next render to resend title, progress, color and style of every bar. + */ + public void invalidateCache() { + handles.values().forEach(Handle::invalidate); + } + + /** + * Renders the current composite state to the given player. + * + * @param player target player + */ + public void render(@NonNull Player player) { + if (!player.isOnline()) { + return; + } + + if (handles.isEmpty() && modalAlerts.isEmpty() && playerSections.isEmpty() && globalSectionsSupplier.get().isEmpty()) { + return; + } + + List alerts; + synchronized (alertLock) { + modalAlerts.removeIf(ActiveAlert::isExpired); + alerts = new ArrayList<>(modalAlerts); + } + alerts.sort(null); + playerSections.values().removeIf(BossbarSection::isExpired); + + List desired = new ArrayList<>(); + boolean exclusive = false; + + for (ActiveAlert alert : alerts) { + String title = alert.title(player); + if (title == null) { + continue; + } + alert.playSoundIfNeeded(player); + desired.add(new Entry(alert, title, clamp(alert.progress(player)), alert.alert().color(), alert.alert().style(), Set.of())); + exclusive |= alert.alert().exclusive(); + } + + if (!exclusive) { + for (BossbarSection section : orderedSections()) { + if (!section.visibility().test(player)) { + continue; + } + String title = section.titleSupplier().apply(player); + if (title == null) { + continue; + } + desired.add(new Entry( + section.id(), + title, + clamp(section.progressSupplier().applyAsDouble(player)), + section.colorSupplier().apply(player), + section.style(), + section.flags() + )); + } + } + + reconcile(player, desired); + } + + private List orderedSections() { + Map merged = new HashMap<>(globalSectionsSupplier.get()); + merged.putAll(playerSections); + List sections = new ArrayList<>(merged.values()); + sections.sort(Comparator.comparingInt(BossbarSection::priority).thenComparing(BossbarSection::id)); + return sections; + } + + private void reconcile(Player player, List desired) { + Set desiredKeys = new HashSet<>(); + for (Entry entry : desired) { + desiredKeys.add(entry.key()); + } + + handles.entrySet().removeIf(existing -> { + if (desiredKeys.contains(existing.getKey())) { + return false; + } + existing.getValue().detach(); + return true; + }); + + // New bars are appended on the client, so existing bars are only kept in place when they + // already form the head of the desired stack. Otherwise, the stack is rebuilt in order. + boolean appendOnly = true; + int index = 0; + for (Object key : handles.keySet()) { + if (index >= desired.size() || !desired.get(index).key().equals(key)) { + appendOnly = false; + break; + } + index++; + } + if (!appendOnly) { + handles.values().forEach(Handle::detach); + } + + Map ordered = new LinkedHashMap<>(); + for (Entry entry : desired) { + Handle handle = handles.get(entry.key()); + if (handle == null) { + handle = new Handle(entry); + } + handle.apply(entry); + if (!handle.attached) { + handle.bar.addPlayer(player); + handle.attached = true; + } + ordered.put(entry.key(), handle); + } + handles = ordered; + } + + private static double clamp(double progress) { + return Double.isNaN(progress) ? 0.0 : Math.clamp(progress, 0.0, 1.0); + } + + /** + * Active stateful session for a queued alert. + */ + public static final class ActiveAlert implements Comparable { + + private final BossbarAlert alert; + private final long createdAt; + private final long durationMillis; + private final long expiresAt; + private final Set playedPlayers = ConcurrentHashMap.newKeySet(); + + public ActiveAlert(@NonNull BossbarAlert alert) { + this.alert = Objects.requireNonNull(alert, "alert cannot be null"); + this.createdAt = System.currentTimeMillis(); + this.durationMillis = Math.max(0L, alert.durationMillis()); + this.expiresAt = this.createdAt + this.durationMillis; + } + + public BossbarAlert alert() { + return alert; + } + + public boolean isExpired() { + return System.currentTimeMillis() >= expiresAt; + } + + public long remainingMillis() { + return Math.max(0L, expiresAt - System.currentTimeMillis()); + } + + public @Nullable String title(Player player) { + IntFunction<@Nullable String> formatter = alert.countdownFormatter(); + if (formatter != null) { + int remainingSeconds = (int) Math.ceil(remainingMillis() / 1000.0); + return formatter.apply(remainingSeconds); + } + return alert.titleSupplier().apply(player); + } + + public double progress(Player player) { + if (alert.progressSupplier() != null) { + return alert.progressSupplier().applyAsDouble(player); + } + return durationMillis == 0L ? 0.0 : (double) remainingMillis() / durationMillis; + } + + public void playSoundIfNeeded(@NonNull Player player) { + SoundCue cue = alert.soundCue(); + if (cue != null && playedPlayers.add(player.getUniqueId())) { + cue.play(player); + } + } + + @Override + public int compareTo(@NonNull ActiveAlert other) { + int precedence = Integer.compare(other.alert.priority(), this.alert.priority()); + if (precedence != 0) { + return precedence; + } + return Long.compare(other.createdAt, this.createdAt); + } + } + + private record Entry( + Object key, + String title, + double progress, + BarColor color, + BarStyle style, + Set flags + ) { + } + + private static final class Handle { + + private final BossBar bar; + private boolean attached; + private String title; + private double progress = Double.NaN; + private BarColor color; + private BarStyle style; + + private Handle(Entry entry) { + this.bar = Bukkit.createBossBar(entry.title(), entry.color(), entry.style()); + } + + private void apply(Entry entry) { + if (!entry.title().equals(title)) { + bar.setTitle(entry.title()); + title = entry.title(); + } + if (progressChanged(progress, entry.progress())) { + bar.setProgress(entry.progress()); + progress = entry.progress(); + } + if (entry.color() != color) { + bar.setColor(entry.color()); + color = entry.color(); + } + if (entry.style() != style) { + bar.setStyle(entry.style()); + style = entry.style(); + } + for (BarFlag flag : BarFlag.values()) { + boolean wanted = entry.flags().contains(flag); + if (bar.hasFlag(flag) == wanted) { + continue; + } + if (wanted) { + bar.addFlag(flag); + } else { + bar.removeFlag(flag); + } + } + } + + private static boolean progressChanged(double last, double next) { + if (Double.isNaN(last)) { + return true; + } + if (next == last) { + return false; + } + return Math.abs(next - last) >= PROGRESS_EPSILON || next == 0.0 || next == 1.0; + } + + private void detach() { + bar.removeAll(); + attached = false; + } + + private void invalidate() { + title = null; + progress = Double.NaN; + color = null; + style = null; + } + } +} diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/bossbar/BossbarSection.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/bossbar/BossbarSection.java new file mode 100644 index 00000000..7576ebab --- /dev/null +++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/bossbar/BossbarSection.java @@ -0,0 +1,304 @@ +package fr.moussax.bedrock.ui.bossbar; + +import org.bukkit.boss.BarColor; +import org.bukkit.boss.BarFlag; +import org.bukkit.boss.BarStyle; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import java.util.Collections; +import java.util.EnumSet; +import java.util.Objects; +import java.util.Set; +import java.util.function.BooleanSupplier; +import java.util.function.DoubleSupplier; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.function.ToDoubleFunction; + +/** + * A persistent boss bar registered under a unique id, rendered as its own bar for every player + * for whom it is visible. + * + *

Unlike action bar sections, boss bar sections are not merged into one string: each visible + * section is a separate bar, stacked by {@code priority} (lowest first, i.e. topmost).

+ * + *

All suppliers and predicates are evaluated on the main thread once per render pass, so they + * must be cheap and must not block. A {@code null} title hides the bar for that player.

+ * + *
{@code
+ * Bossbar.register(plugin, BossbarSection.builder("ancient-knight")
+ *         .title(_ -> "§f§lThe Ancient Knight")
+ *         .progress(_ -> knight.getHealth() / maxHealth)
+ *         .color(BarColor.RED)
+ *         .visibleWhen(Bossbar.within(knight::getLocation, 60))
+ *         .expireWhen(() -> !knight.isValid())
+ *         .build());
+ * }
+ * + * @param id unique section identifier + * @param priority vertical order among sections (lowest first) + * @param titleSupplier title for a player, or {@code null} to hide the bar + * @param progressSupplier fill ratio in {@code [0.0, 1.0]} for a player + * @param colorSupplier bar color for a player + * @param style segmentation style + * @param flags client effects (darken sky, boss music, fog) + * @param visibility whether the bar is shown to a player + * @param expiry when it returns {@code true} the section is unregistered automatically + */ +public record BossbarSection( + @NonNull String id, + int priority, + @NonNull Function titleSupplier, + @NonNull ToDoubleFunction progressSupplier, + @NonNull Function colorSupplier, + @NonNull BarStyle style, + @NonNull Set flags, + @NonNull Predicate visibility, + @NonNull BooleanSupplier expiry +) { + + /** + * Color used when none is specified. + */ + public static final BarColor DEFAULT_COLOR = BarColor.WHITE; + + /** + * Style used when none is specified. + */ + public static final BarStyle DEFAULT_STYLE = BarStyle.SOLID; + + public BossbarSection { + Objects.requireNonNull(id, "id cannot be null"); + Objects.requireNonNull(titleSupplier, "titleSupplier cannot be null"); + Objects.requireNonNull(progressSupplier, "progressSupplier cannot be null"); + Objects.requireNonNull(colorSupplier, "colorSupplier cannot be null"); + Objects.requireNonNull(style, "style cannot be null"); + Objects.requireNonNull(visibility, "visibility cannot be null"); + Objects.requireNonNull(expiry, "expiry cannot be null"); + flags = Set.copyOf(flags); + } + + /** + * Creates an always-visible, white, solid section. + * + * @param id unique section identifier + * @param priority vertical order (lowest first) + * @param titleSupplier title for a player, or {@code null} to hide the bar + * @param progressSupplier fill ratio in {@code [0.0, 1.0]} + * @return the section + */ + @NonNull + public static BossbarSection of( + @NonNull String id, + int priority, + @NonNull Function titleSupplier, + @NonNull ToDoubleFunction progressSupplier + ) { + return builder(id).priority(priority).title(titleSupplier).progress(progressSupplier).build(); + } + + /** + * Starts building a section with custom properties. + * + * @param id unique section identifier + * @return new builder + */ + @NonNull + public static Builder builder(@NonNull String id) { + return new Builder(id); + } + + /** + * Evaluates {@link #expiry()}. + * + * @return {@code true} if the section should be unregistered + */ + public boolean isExpired() { + return expiry.getAsBoolean(); + } + + /** + * Fluent builder for {@link BossbarSection}. + */ + public static final class Builder { + + private final String id; + private int priority = 0; + private Function titleSupplier; + private ToDoubleFunction progressSupplier = _ -> 1.0; + private Function colorSupplier = _ -> DEFAULT_COLOR; + private BarStyle style = DEFAULT_STYLE; + private final Set flags = EnumSet.noneOf(BarFlag.class); + private Predicate visibility = _ -> true; + private BooleanSupplier expiry = () -> false; + + private Builder(@NonNull String id) { + this.id = Objects.requireNonNull(id, "id cannot be null"); + } + + /** + * Sets the vertical order among sections (lowest first). + * + * @param priority order weight + * @return this builder + */ + public Builder priority(int priority) { + this.priority = priority; + return this; + } + + /** + * Sets a static title. + * + * @param title title text + * @return this builder + */ + public Builder title(@NonNull String title) { + Objects.requireNonNull(title, "title cannot be null"); + this.titleSupplier = _ -> title; + return this; + } + + /** + * Sets a per-player title; returning {@code null} hides the bar for that player. + * + * @param titleSupplier title function + * @return this builder + */ + public Builder title(@NonNull Function titleSupplier) { + this.titleSupplier = Objects.requireNonNull(titleSupplier, "titleSupplier cannot be null"); + return this; + } + + /** + * Sets a constant fill ratio clamped to {@code [0.0, 1.0]}. + * + * @param progress static fill ratio + * @return this builder + */ + public Builder progress(double progress) { + double clamped = Math.clamp(progress, 0.0, 1.0); + this.progressSupplier = _ -> clamped; + return this; + } + + /** + * Sets a progress supplier not dependent on the viewing player. Values are clamped to {@code [0.0, 1.0]}. + * + * @param progressSupplier progress supplier + * @return this builder + */ + public Builder progress(@NonNull DoubleSupplier progressSupplier) { + Objects.requireNonNull(progressSupplier, "progressSupplier cannot be null"); + this.progressSupplier = _ -> progressSupplier.getAsDouble(); + return this; + } + + /** + * Sets the per-player fill ratio. Values are clamped to {@code [0.0, 1.0]}. + * + * @param progressSupplier progress function + * @return this builder + */ + public Builder progress(@NonNull ToDoubleFunction progressSupplier) { + this.progressSupplier = Objects.requireNonNull(progressSupplier, "progressSupplier cannot be null"); + return this; + } + + /** + * Sets a static color. + * + * @param color bar color + * @return this builder + */ + public Builder color(@NonNull BarColor color) { + Objects.requireNonNull(color, "color cannot be null"); + this.colorSupplier = _ -> color; + return this; + } + + /** + * Sets a per-player color, re-evaluated every render pass (e.g. red when a boss is enraged). + * + * @param colorSupplier color function + * @return this builder + */ + public Builder color(@NonNull Function colorSupplier) { + this.colorSupplier = Objects.requireNonNull(colorSupplier, "colorSupplier cannot be null"); + return this; + } + + /** + * Sets the segmentation style. + * + * @param style bar style + * @return this builder + */ + public Builder style(@NonNull BarStyle style) { + this.style = Objects.requireNonNull(style, "style cannot be null"); + return this; + } + + /** + * Adds client effects shown while this bar is visible. + * + * @param barFlags flags to add + * @return this builder + */ + public Builder flags(@NonNull BarFlag... barFlags) { + Collections.addAll(flags, barFlags); + return this; + } + + /** + * Adds a client effect shown while this bar is visible. + * + * @param barFlag flag to add + * @return this builder + */ + public Builder flag(@NonNull BarFlag barFlag) { + Objects.requireNonNull(barFlag, "barFlag cannot be null"); + this.flags.add(barFlag); + return this; + } + + /** + * Restricts which players see the bar. Evaluated every render pass. + * + * @param visibility visibility predicate + * @return this builder + */ + public Builder visibleWhen(@NonNull Predicate visibility) { + this.visibility = Objects.requireNonNull(visibility, "visibility cannot be null"); + return this; + } + + /** + * Unregisters the section automatically once the condition becomes {@code true}. + * Evaluated every render pass; use it to tie a bar to the lifetime of an entity or an event. + * + * @param expiry expiry condition + * @return this builder + */ + public Builder expireWhen(@NonNull BooleanSupplier expiry) { + this.expiry = Objects.requireNonNull(expiry, "expiry cannot be null"); + return this; + } + + /** + * Builds the section. + * + * @return immutable section + * @throws IllegalStateException if no title was set + */ + @NonNull + public BossbarSection build() { + if (titleSupplier == null) { + throw new IllegalStateException("A title is required for boss bar section '" + id + "'"); + } + return new BossbarSection(id, priority, titleSupplier, progressSupplier, colorSupplier, style, flags, visibility, expiry); + } + } +} diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/bossbar/BossbarService.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/bossbar/BossbarService.java new file mode 100644 index 00000000..9c3fc705 --- /dev/null +++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/bossbar/BossbarService.java @@ -0,0 +1,317 @@ +package fr.moussax.bedrock.ui.bossbar; + +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.event.server.PluginDisableEvent; +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; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Manages boss bar lifecycles: global sections, per-player composers, and the periodic render pass. + * + *

Unlike the title and action bar services, stopping this service also removes every bar from + * every player's screen, since a bar stays visible on the client until it is explicitly removed.

+ */ +public final class BossbarService implements Listener { + + @Getter + @Setter + private static volatile BossbarService instance; + + @Getter + private final Plugin plugin; + private final Map composers = new ConcurrentHashMap<>(); + private final Map globalSections = new ConcurrentHashMap<>(); + private final Map globalOwners = new ConcurrentHashMap<>(); + private BukkitTask tickerTask; + private volatile boolean running = false; + private long currentPeriodTicks = 1L; + + /** + * Constructs a boss bar service and registers quit and plugin-disable listeners with Bukkit. + * + * @param plugin owning plugin + */ + public BossbarService(@NonNull Plugin plugin) { + this.plugin = Objects.requireNonNull(plugin, "plugin cannot be null"); + if (instance == null) { + instance = this; + } + if (Bukkit.getServer() != null) { + Bukkit.getPluginManager().registerEvents(this, plugin); + } + } + + /** + * Obtains the shared service, or constructs one if not yet initialized. + * + * @param plugin owning plugin + * @return active boss bar service + */ + @NonNull + public static BossbarService getOrCreate(@NonNull Plugin plugin) { + if (instance == null) { + synchronized (BossbarService.class) { + if (instance == null) { + new BossbarService(plugin); + } + } + } + return instance; + } + + /** + * Starts or updates the periodic render task. A shorter period replaces a longer running one. + * + * @param periodTicks interval between render passes 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 render task, removes every bar from every player, and discards all state. + */ + public void stop() { + running = false; + if (tickerTask != null) { + tickerTask.cancel(); + tickerTask = null; + } + composers.values().forEach(BossbarComposer::detach); + composers.clear(); + globalSections.clear(); + globalOwners.clear(); + HandlerList.unregisterAll(this); + synchronized (BossbarService.class) { + if (instance == this) { + instance = null; + } + } + } + + /** + * Retrieves or creates the composer for a player. + * + * @param uuid player unique id + * @return the player's composer + */ + @NonNull + public BossbarComposer getOrCreateComposer(@NonNull UUID uuid) { + return composers.computeIfAbsent(uuid, _ -> new BossbarComposer(() -> globalSections)); + } + + /** + * Retrieves the composer for a player if present. + * + * @param uuid player unique id + * @return the composer, or {@code null} if absent + */ + public @Nullable BossbarComposer getComposer(@NonNull UUID uuid) { + return composers.get(uuid); + } + + /** + * Registers a global section owned by this service's plugin. + * + * @param section section to register + */ + public void register(@NonNull BossbarSection section) { + register(plugin, section); + } + + /** + * Registers a global section shown to every player for whom it is visible, replacing any global + * section with the same id. It is removed by {@link #unregisterAll(Plugin)} or when its owner is disabled. + * + * @param owner plugin that owns the section + * @param section section to register + */ + public void register(@NonNull Plugin owner, @NonNull BossbarSection section) { + Objects.requireNonNull(owner, "owner cannot be null"); + Objects.requireNonNull(section, "section cannot be null"); + globalSections.put(section.id(), section); + globalOwners.put(section.id(), owner); + renderAll(); + } + + /** + * Registers a section for one player only. + * + * @param player target player + * @param section section to register + */ + public void register(@NonNull Player player, @NonNull BossbarSection section) { + getOrCreateComposer(player.getUniqueId()).registerSection(section); + renderPlayer(player); + } + + /** + * Unregisters a global section. + * + * @param id section id + */ + public void unregister(@NonNull String id) { + globalSections.remove(id); + globalOwners.remove(id); + renderAll(); + } + + /** + * Unregisters a section registered for one player. + * + * @param player target player + * @param id section id + */ + public void unregister(@NonNull Player player, @NonNull String id) { + BossbarComposer composer = composers.get(player.getUniqueId()); + if (composer != null) { + composer.unregisterSection(id); + renderPlayer(player); + } + } + + /** + * Unregisters every global section owned by a plugin. + * + * @param owner owning plugin + */ + public void unregisterAll(@NonNull Plugin owner) { + Objects.requireNonNull(owner, "owner cannot be null"); + boolean removed = globalOwners.entrySet().removeIf(entry -> { + if (!entry.getValue().equals(owner)) { + return false; + } + globalSections.remove(entry.getKey()); + return true; + }); + if (removed) { + renderAll(); + } + } + + /** + * Queues a modal alert and immediately renders. + * + * @param player target player + * @param alert alert to queue + */ + public void sendAlert(@NonNull Player player, @NonNull BossbarAlert alert) { + getOrCreateComposer(player.getUniqueId()).sendModalAlert(alert); + renderPlayer(player); + } + + /** + * Clears a player's alerts and restores the sections underneath. + * + * @param player target player + */ + public void clearAlerts(@NonNull Player player) { + BossbarComposer composer = composers.get(player.getUniqueId()); + if (composer != null) { + composer.clearAlerts(); + renderPlayer(player); + } + } + + /** + * Clears a player's alerts and player-level sections and removes every bar from their screen. + * Global sections reappear on the next render. + * + * @param player target player + */ + public void clear(@NonNull Player player) { + BossbarComposer composer = composers.get(player.getUniqueId()); + if (composer != null) { + composer.clear(); + } + } + + /** + * Immediately evaluates and renders bars for a player. + * + * @param player target player + */ + public void renderPlayer(@NonNull Player player) { + if (!player.isOnline()) { + return; + } + getOrCreateComposer(player.getUniqueId()).render(player); + } + + @EventHandler + public void onQuit(PlayerQuitEvent event) { + BossbarComposer composer = composers.remove(event.getPlayer().getUniqueId()); + if (composer != null) { + composer.detach(); + } + } + + @EventHandler + public void onPluginDisable(PluginDisableEvent event) { + if (event.getPlugin().equals(plugin)) { + stop(); + return; + } + unregisterAll(event.getPlugin()); + } + + private void renderAll() { + for (Player player : Bukkit.getOnlinePlayers()) { + try { + renderPlayer(player); + } catch (Exception exception) { + Log.warn("BossbarService", "Failed to render boss bars for " + player.getName() + ": " + exception.getMessage()); + } + } + } + + private void tickAll() { + globalSections.forEach((id, section) -> { + if (isExpired(section)) { + globalSections.remove(id, section); + globalOwners.remove(id); + } + }); + renderAll(); + } + + private static boolean isExpired(BossbarSection section) { + try { + return section.isExpired(); + } catch (Exception exception) { + Log.warn("BossbarService", "Dropping section '" + section.id() + "', expireWhen threw: " + exception.getMessage()); + return true; + } + } +} diff --git a/bedrock/src/test/java/fr/moussax/bedrock/ui/bossbar/BossbarTest.java b/bedrock/src/test/java/fr/moussax/bedrock/ui/bossbar/BossbarTest.java new file mode 100644 index 00000000..b3f09f65 --- /dev/null +++ b/bedrock/src/test/java/fr/moussax/bedrock/ui/bossbar/BossbarTest.java @@ -0,0 +1,204 @@ +package fr.moussax.bedrock.ui.bossbar; + +import fr.moussax.bedrock.sound.SoundCue; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.boss.BarColor; +import org.bukkit.boss.BarFlag; +import org.bukkit.boss.BarStyle; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Proxy; +import java.time.Duration; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Predicate; + +import static org.junit.jupiter.api.Assertions.*; + +class BossbarTest { + + private Player createMockPlayer(UUID uuid, World world, Location location) { + return createMockPlayer(uuid, world, location, new AtomicInteger()); + } + + private Player createMockPlayer(UUID uuid, World world, Location location, AtomicInteger soundCount) { + return (Player) Proxy.newProxyInstance( + Player.class.getClassLoader(), + new Class[]{Player.class}, + (proxy, method, args) -> switch (method.getName()) { + case "getUniqueId" -> uuid; + case "getWorld" -> world; + case "getLocation" -> location; + case "isOnline" -> true; + case "isValid" -> true; + case "playSound" -> { + soundCount.incrementAndGet(); + yield null; + } + default -> null; + } + ); + } + + private Entity createMockEntity(World world, Location location, boolean valid, boolean dead) { + return (Entity) Proxy.newProxyInstance( + Entity.class.getClassLoader(), + new Class[]{Entity.class}, + (proxy, method, args) -> switch (method.getName()) { + case "getWorld" -> world; + case "getLocation" -> location; + case "isValid" -> valid; + case "isDead" -> dead; + default -> null; + } + ); + } + + private World createMockWorld(String name) { + return (World) Proxy.newProxyInstance( + World.class.getClassLoader(), + new Class[]{World.class}, + (proxy, method, args) -> switch (method.getName()) { + case "getName" -> name; + case "equals" -> args != null && args.length > 0 && args[0] == proxy; + default -> null; + } + ); + } + + @Test + @DisplayName("BossbarAlert maintains immutability and with* methods create distinct copies") + void testBossbarAlertImmutability() { + BossbarAlert alert = BossbarAlert.of("Alert 1", BarColor.WHITE, 5, Duration.ofSeconds(3)); + BossbarAlert withRed = alert.withColor(BarColor.RED); + BossbarAlert withPriority = alert.withPriority(20); + BossbarAlert exclusive = alert.exclusive(true); + BossbarAlert withSound = alert.withSound(new SoundCue(null, 1f, 1f, 0L)); + + assertEquals(BarColor.WHITE, alert.color()); + assertEquals(5, alert.priority()); + assertFalse(alert.exclusive()); + assertNull(alert.soundCue()); + + assertEquals(BarColor.RED, withRed.color()); + assertEquals(20, withPriority.priority()); + assertTrue(exclusive.exclusive()); + assertNotNull(withSound.soundCue()); + } + + @Test + @DisplayName("ActiveAlert session calculates progress and expiration independently of alert instantiation") + void testActiveAlertSessionLifecycle() throws InterruptedException { + BossbarAlert constantAlert = BossbarAlert.of("Constant Alert", Duration.ofMillis(200)); + + // Sleep to ensure instantiation time is in the past + Thread.sleep(50); + + // When a session is created from the constant alert, its timer begins now + BossbarComposer.ActiveAlert session = new BossbarComposer.ActiveAlert(constantAlert); + assertFalse(session.isExpired(), "Session should not be expired immediately upon creation"); + assertTrue(session.progress(null) > 0.0, "Progress should be positive"); + + Thread.sleep(220); + assertTrue(session.isExpired(), "Session should be expired after its duration elapses"); + assertEquals(0.0, session.progress(null), "Progress should be 0 on expiry"); + } + + @Test + @DisplayName("ActiveAlert plays sound cue only once per player across multiple render passes") + void testActiveAlertAudioDeduplication() { + AtomicInteger playCount = new AtomicInteger(0); + World world = createMockWorld("world"); + Player player = createMockPlayer(UUID.randomUUID(), world, new Location(world, 0, 0, 0), playCount); + + SoundCue cue = new SoundCue(null, 1.0f, 1.0f, 0L); + BossbarAlert alert = BossbarAlert.of("Sound Alert", Duration.ofSeconds(5), cue); + BossbarComposer.ActiveAlert session = new BossbarComposer.ActiveAlert(alert); + + session.playSoundIfNeeded(player); + session.playSoundIfNeeded(player); + session.playSoundIfNeeded(player); + + assertEquals(1, playCount.get(), "Sound cue must only play once per player session"); + } + + @Test + @DisplayName("Countdown alert formats decrementing remaining seconds correctly in active session") + void testCountdownAlertSession() { + BossbarAlert alert = BossbarAlert.countdown("Starting in %ds", 10, 5); + BossbarComposer.ActiveAlert session = new BossbarComposer.ActiveAlert(alert); + + String title = session.title(null); + assertNotNull(title); + assertTrue(title.contains("5s") || title.contains("4s")); + } + + @Test + @DisplayName("BossbarSection.Builder supports static progress, double suppliers, and single flags") + void testBossbarSectionBuilderOverloads() { + BossbarSection section = BossbarSection.builder("test-section") + .title("Section Title") + .color(BarColor.BLUE) + .style(BarStyle.SEGMENTED_6) + .progress(0.75) + .flag(BarFlag.DARKEN_SKY) + .build(); + + assertEquals("test-section", section.id()); + assertEquals(BarStyle.SEGMENTED_6, section.style()); + assertEquals(0.75, section.progressSupplier().applyAsDouble(null), 0.001); + assertTrue(section.flags().contains(BarFlag.DARKEN_SKY)); + + BossbarSection dynamicProgressSection = BossbarSection.builder("dyn-section") + .title("Dynamic") + .progress(() -> 0.42) + .build(); + assertEquals(0.42, dynamicProgressSection.progressSupplier().applyAsDouble(null), 0.001); + } + + @Test + @DisplayName("Bossbar.within(Entity, radius) validates world, distance, and entity alive status") + void testBossbarWithinEntityPredicate() { + World world = createMockWorld("world"); + World otherWorld = createMockWorld("nether"); + + Location entityLoc = new Location(world, 100, 64, 100); + Location nearbyPlayerLoc = new Location(world, 110, 64, 100); // dist 10 + Location farPlayerLoc = new Location(world, 200, 64, 100); // dist 100 + Location diffWorldPlayerLoc = new Location(otherWorld, 110, 64, 100); + + Player nearPlayer = createMockPlayer(UUID.randomUUID(), world, nearbyPlayerLoc); + Player farPlayer = createMockPlayer(UUID.randomUUID(), world, farPlayerLoc); + Player diffWorldPlayer = createMockPlayer(UUID.randomUUID(), otherWorld, diffWorldPlayerLoc); + + Entity livingBoss = createMockEntity(world, entityLoc, true, false); + Predicate within20 = Bossbar.within(livingBoss, 20.0); + + assertTrue(within20.test(nearPlayer), "Near player in same world must be visible"); + assertFalse(within20.test(farPlayer), "Far player must not be visible"); + assertFalse(within20.test(diffWorldPlayer), "Player in different world must not be visible"); + + Entity deadBoss = createMockEntity(world, entityLoc, true, true); + Predicate withinDead = Bossbar.within(deadBoss, 20.0); + assertFalse(withinDead.test(nearPlayer), "Dead entity must hide boss bar from viewers"); + + Entity invalidBoss = createMockEntity(world, entityLoc, false, false); + Predicate withinInvalid = Bossbar.within(invalidBoss, 20.0); + assertFalse(withinInvalid.test(nearPlayer), "Invalid entity must hide boss bar from viewers"); + } + + @Test + @DisplayName("BossbarComposer idle fast path executes safely without initializing handle map") + void testBossbarComposerIdleFastPath() { + World world = createMockWorld("world"); + Player player = createMockPlayer(UUID.randomUUID(), world, new Location(world, 0, 0, 0)); + + BossbarComposer composer = new BossbarComposer(); + // Should return immediately via fast-path without throwing or allocating + assertDoesNotThrow(() -> composer.render(player)); + } +} From f626e94d0b976276ef07b438b78f6cf89230d17a Mon Sep 17 00:00:00 2001 From: Moussa Date: Sun, 4 Oct 2026 13:02:47 +0200 Subject: [PATCH 2/3] fix(bossbar): ensure renderPlayer is called on the main thread --- .../java/fr/moussax/bedrock/ui/bossbar/BossbarService.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/bossbar/BossbarService.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/bossbar/BossbarService.java index 9c3fc705..ba094232 100644 --- a/bedrock/src/main/java/fr/moussax/bedrock/ui/bossbar/BossbarService.java +++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/bossbar/BossbarService.java @@ -263,6 +263,11 @@ public void clear(@NonNull Player player) { * @param player target player */ public void renderPlayer(@NonNull Player player) { + if(!Bukkit.isPrimaryThread()) { + Bukkit.getScheduler().runTask(plugin, () -> renderPlayer(player)); + return; + } + if (!player.isOnline()) { return; } From 24c96467354e95f6421271fd65afcd63bd42b596 Mon Sep 17 00:00:00 2001 From: Moussa Date: Sun, 4 Oct 2026 13:05:39 +0200 Subject: [PATCH 3/3] fix(bossbar): handle exceptions when checking section visibility and expiration --- .../bedrock/ui/bossbar/BossbarComposer.java | 45 ++++++++++++------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/bossbar/BossbarComposer.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/bossbar/BossbarComposer.java index fbd39957..b9092623 100644 --- a/bedrock/src/main/java/fr/moussax/bedrock/ui/bossbar/BossbarComposer.java +++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/bossbar/BossbarComposer.java @@ -146,11 +146,19 @@ public void render(@NonNull Player player) { alerts = new ArrayList<>(modalAlerts); } alerts.sort(null); - playerSections.values().removeIf(BossbarSection::isExpired); + playerSections.values().removeIf(section -> { + try { + return section.isExpired(); + } catch (Exception _) { + // Treat as expired to avoid repeated failures + return true; + } + }); List desired = new ArrayList<>(); boolean exclusive = false; + // Process modal alerts first for (ActiveAlert alert : alerts) { String title = alert.title(player); if (title == null) { @@ -161,26 +169,31 @@ public void render(@NonNull Player player) { exclusive |= alert.alert().exclusive(); } + // Render player sections only when no exclusive alert is active if (!exclusive) { for (BossbarSection section : orderedSections()) { - if (!section.visibility().test(player)) { - continue; + try { + if (!section.visibility().test(player)) { + continue; + } + String title = section.titleSupplier().apply(player); + if (title == null) { + continue; + } + double prog = clamp(section.progressSupplier().applyAsDouble(player)); + BarColor color = section.colorSupplier().apply(player); + desired.add(new Entry( + section.id(), + title, + prog, + color, + section.style(), + section.flags() + )); + } catch (Exception _) { } - String title = section.titleSupplier().apply(player); - if (title == null) { - continue; - } - desired.add(new Entry( - section.id(), - title, - clamp(section.progressSupplier().applyAsDouble(player)), - section.colorSupplier().apply(player), - section.style(), - section.flags() - )); } } - reconcile(player, desired); }