diff --git a/README.md b/README.md index a1b9ebd..335e9d4 100644 --- a/README.md +++ b/README.md @@ -5,22 +5,25 @@ A quality-of-life SMP plugin for Paper/Folia. ## Features - **Join messages** — a "Welcome to the SMP" title on every join, friendly welcome messages (including a first-join message that lists the server's plugins and a warning that `/nopvp` abuse is bannable and that combat logging is a bad idea), and a special announcement when the owner logs in. -- **`/nopvp`** — toggle no-PvP mode. While active you cannot damage other players and other players cannot damage you (direct hits, projectiles, pets, lingering potions, and player-deployed explosives such as TNT, TNT minecarts, end crystals and bed bombs). While enabled a floating **"Pacifist"** label appears above you. Team members glow in their team's colour whenever the team glow is on (independent of no-PvP). To stop the immunity being used offensively, protected players (and anyone within **25 blocks** of one) are also blocked from placing, priming or detonating PvP explosives — including redstone-primed TNT whose placer is remembered, and dispensers near a protected player firing TNT/lava/fire charges. Flint & steel is restricted by proximity too: a protected player can't ignite anything within 25 blocks of another player, and a non-protected player can't ignite anything within 25 blocks of a protected player (lighting portals while alone still works). No-PvP also can't be switched on while you're in combat. Turning no-PvP *off* starts a **5-minute cooldown** before you can enable it again, and the state persists across restarts so relogging can't bypass it. +- **`/nopvp`** — toggle no-PvP mode. **New players start with no-PvP on.** While active you cannot damage other players and other players cannot damage you (direct hits, projectiles, pets, lingering potions, and player-deployed explosives such as TNT, TNT minecarts, end crystals and bed bombs). A floating **totem of undying** (glowing green) appears above your head while no-PvP is on; with no-PvP off you instead show your personal symbol (glowing red) — a **wither skeleton skull** (shown 2× larger) or a **netherite axe** (axe by default) — chosen with `/team symbol` (no team required) and remembered per player. Team members glow in their team's colour whenever the team glow is on (independent of no-PvP). To stop the immunity being used offensively, protected players (and anyone within **25 blocks** of one) are also blocked from placing, priming or detonating PvP explosives — including redstone-primed TNT whose placer is remembered, and dispensers near a protected player firing TNT/lava/fire charges. Flint & steel is restricted by proximity too: a protected player can't ignite anything within 25 blocks of another player, and a non-protected player can't ignite anything within 25 blocks of a protected player (lighting portals while alone still works). No-PvP also can't be switched on while you're in combat. Turning no-PvP *off* starts a **5-minute cooldown** before you can enable it again, and the state persists across restarts so relogging can't bypass it. - **Anti-combat-logging** — whenever you hit another player or get hit by one, you're tagged as being in combat for **15 seconds** with a chat warning. Logging off while tagged kills you on the spot and announces it to the server. -- **Cosmetic teams** — `/team create/delete/invite/leave/join/color/admin/glow/chat`. Teams are purely cosmetic (friendly fire stays on). The owner runs the show and can promote **admins** who may also invite players and change the colour; membership is request-based rather than open. A team's colour is applied as the glow colour of all its members whenever their personal glow is on — each member can toggle their own with `/team glow` (on by default and remembered across restarts, independent of no-PvP). Members show a `[Team]` tag next to their name in the tab list and above their head, and in normal chat, all coloured with the team's colour; `/team chat` gives the team a private chat channel. Stored in `plugins/qol-tweaks/teams.yml`. +- **TNT punching** — punch primed TNT to knock it around: each hit shoves it away from you (with a little lift) and adds **1 second** to its fuse, up to a **10-second** cap, so you can juggle it. Players with no-PvP on can't knock TNT around. +- **Cosmetic teams** — `/team create/delete/invite/leave/join/color/admin/glow/chat/symbol`. Teams are purely cosmetic (friendly fire stays on). A team name is its tag and is limited to **4 characters**. The owner runs the show and can promote **admins** who may also invite players and change the colour; membership is request-based rather than open — `/team invite` sends a clickable **"JOIN THEIR TEAM"** button and a join request sends admins a clickable **"ALLOW <player> INTO THE TEAM"** button, so joining takes one click. A team's colour drives the glowing outline of members whose personal glow is on (each member toggles their own with `/team glow`, **off by default**, remembered across restarts). Each member (or anyone, even without a team) also picks their own head symbol with `/team symbol `. Members show a coloured `[TAG]` **after** their name in chat, the tab list and above their head (the name itself stays white); `/team chat` gives the team a private chat channel. Stored in `plugins/qol-tweaks/teams.yml`. Coloured glow is delivered through scoreboard-team packets, since Folia blocks the normal scoreboard API at runtime. +- **Tip broadcasts** — a rotating helpful tip is broadcast to everyone every two hours (e.g. how to turn `/nopvp` off, how team glow works, `/team chat`, and switching symbols). - **Woodcutting** — wood works in the stonecutter. A log/wood block (including stripped and stem/hyphae variants) cuts into **4 planks**; planks cut into stairs, slabs, fences, fence gates, buttons, signs and hanging signs; and — because a stonecutter can only ever consume one item — the pricier blocks are cut from a whole log instead: 1 log → 2 doors or 2 pressure plates (2 planks each), 1 trapdoor (~4 planks) and 1 boat (bamboo gives a raft; crimson/warped have none). The cutter also strips bark one-for-one: log/wood/stem/hyphae (and bamboo block) → its stripped form. Covers every wood family. No resource pack needed. ## Commands | Command | Description | Permission | |--------------------------------|----------------------------------------------------------------------|-------------------| -| `/nopvp` | Toggle no-PvP mode ("Pacifist" label + optional team-coloured glow). | `qoltweaks.nopvp` | -| `/team create ` | Create a team (you become its owner). | `qoltweaks.team` | +| `/nopvp` | Toggle no-PvP mode (totem above head while on + optional team-coloured glow). | `qoltweaks.nopvp` | +| `/team create ` | Create a team; the name (max 4 chars) is the tag. | `qoltweaks.team` | | `/team invite

` | Invite an online player, or accept their join request (owner/admin). | `qoltweaks.team` | | `/team join ` | Accept an invite, or send a join request to the team. | `qoltweaks.team` | | `/team color ` | Set the team glow colour, or `none` to clear (owner/admin). | `qoltweaks.team` | | `/team admin

`| Promote/demote a member to admin (owner only). | `qoltweaks.team` | -| `/team glow` | Toggle your **own** no-PvP glow (default on, remembered). | `qoltweaks.team` | +| `/team glow` | Toggle your **own** team glow (default off, remembered). | `qoltweaks.team` | +| `/team symbol ` | Pick **your own** head symbol shown while no-PvP is off (default axe). | `qoltweaks.team` | | `/team chat [message]` | Send a team-only message, or toggle team chat mode. | `qoltweaks.team` | | `/team leave` | Leave your team (owners must delete instead). | `qoltweaks.team` | | `/team delete` | Disband the whole team (owner only). | `qoltweaks.team` | @@ -29,7 +32,7 @@ All permissions default to `true`. Note `/team` takes over the vanilla scoreboar ## Data -Player no-PvP state and cooldown timestamps are stored in `plugins/qol-tweaks/nopvp.yml` so they survive restarts and reloads. Teams, ownership, admins, members, colours and each player's glow preference are stored in `plugins/qol-tweaks/teams.yml`. +Player no-PvP state and cooldown timestamps are stored in `plugins/qol-tweaks/nopvp.yml` so they survive restarts and reloads. Teams, ownership, admins, members, colours, each player's glow preference and each player's chosen head symbol are stored in `plugins/qol-tweaks/teams.yml`. ## Compatibility diff --git a/src/main/java/com/dvorakmortonxd/qoltweaks/ChatTips.java b/src/main/java/com/dvorakmortonxd/qoltweaks/ChatTips.java new file mode 100644 index 0000000..5f61fa3 --- /dev/null +++ b/src/main/java/com/dvorakmortonxd/qoltweaks/ChatTips.java @@ -0,0 +1,46 @@ +package com.dvorakmortonxd.qoltweaks; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.plugin.java.JavaPlugin; + +import java.util.List; + +/** + * Broadcasts a rotating set of helpful tips to everyone at a fixed interval + * (every two hours by default). Folia-safe broadcasts run on the global region + * scheduler. + */ +public final class ChatTips { + + /** One tip per two hours is what the server owner asked for. */ + private static final long INTERVAL_TICKS = 2L * 60L * 60L * 20L; + /** Small delay before the first tip so it doesn't fire during startup. */ + private static final long INITIAL_DELAY_TICKS = 60L * 20L; + + private static final List TIPS = List.of( + "§eIf you're feeling some hunger for blood, switch that §c/nopvp §eoff!", + "§eFun fact: you can §dglow §ewhen you join a team and choose your own colour of glowing!", + "§eDid you know that you can privately chat with your team through §a/team chat§e?", + "§eIs the §c/nopvp §eaxe symbol getting boring? Switch to the wither head with §a/team symbol wither§e!"); + + private final JavaPlugin plugin; + private int next; + + public ChatTips(JavaPlugin plugin) { + this.plugin = plugin; + plugin.getServer().getGlobalRegionScheduler() + .runAtFixedRate(plugin, task -> broadcast(), INITIAL_DELAY_TICKS, INTERVAL_TICKS); + } + + private void broadcast() { + if (Bukkit.getOnlinePlayers().isEmpty()) { + return; + } + String tip = TIPS.get(next); + next = (next + 1) % TIPS.size(); + for (Player player : Bukkit.getOnlinePlayers()) { + player.sendMessage(tip); + } + } +} diff --git a/src/main/java/com/dvorakmortonxd/qoltweaks/QoLtweaks.java b/src/main/java/com/dvorakmortonxd/qoltweaks/QoLtweaks.java index 9999bb8..d6821af 100644 --- a/src/main/java/com/dvorakmortonxd/qoltweaks/QoLtweaks.java +++ b/src/main/java/com/dvorakmortonxd/qoltweaks/QoLtweaks.java @@ -3,6 +3,7 @@ package com.dvorakmortonxd.qoltweaks; import com.dvorakmortonxd.qoltweaks.combatlog.CombatLogListener; import com.dvorakmortonxd.qoltweaks.combatlog.CombatLogManager; import com.dvorakmortonxd.qoltweaks.listeners.JoinListener; +import com.dvorakmortonxd.qoltweaks.listeners.TNTKnockbackListener; import com.dvorakmortonxd.qoltweaks.nopvp.NoPvPCommand; import com.dvorakmortonxd.qoltweaks.nopvp.NoPvPListener; import com.dvorakmortonxd.qoltweaks.nopvp.NoPvPManager; @@ -28,6 +29,7 @@ public final class QoLtweaks extends JavaPlugin { combatLogManager = new CombatLogManager(this); getServer().getPluginManager().registerEvents(new JoinListener(), this); + getServer().getPluginManager().registerEvents(new TNTKnockbackListener(noPvPManager), this); getServer().getPluginManager().registerEvents(new TeamListener(teamManager), this); getServer().getPluginManager().registerEvents(new NoPvPListener(noPvPManager), this); getServer().getPluginManager().registerEvents(new NoPvPProtectionListener(noPvPManager), this); @@ -35,6 +37,8 @@ public final class QoLtweaks extends JavaPlugin { WoodcutterRecipes.register(this); + new ChatTips(this); + PluginCommand noPvPCommand = getCommand("nopvp"); if (noPvPCommand != null) { noPvPCommand.setExecutor(new NoPvPCommand(noPvPManager, combatLogManager)); diff --git a/src/main/java/com/dvorakmortonxd/qoltweaks/listeners/TNTKnockbackListener.java b/src/main/java/com/dvorakmortonxd/qoltweaks/listeners/TNTKnockbackListener.java new file mode 100644 index 0000000..3341022 --- /dev/null +++ b/src/main/java/com/dvorakmortonxd/qoltweaks/listeners/TNTKnockbackListener.java @@ -0,0 +1,59 @@ +package com.dvorakmortonxd.qoltweaks.listeners; + +import com.dvorakmortonxd.qoltweaks.nopvp.NoPvPManager; +import com.dvorakmortonxd.qoltweaks.tnt.TNTLaunch; +import io.papermc.paper.event.player.PrePlayerAttackEntityEvent; +import org.bukkit.entity.Player; +import org.bukkit.entity.TNTPrimed; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; + +/** + * Lets players punch primed TNT around. + *

+ * Vanilla nudges primed TNT a little when it is hit, but the fuse keeps ticking, + * so it can't really be played with. Here every hit instead adds a second to the + * fuse (up to a cap) and shoves the TNT away from the attacker with a small + * upward arc, so it can be moved about a bit. A short per-player cooldown stops + * a held attack button from stacking the fuse every tick. + *

+ * Players with no-PvP on can't knock TNT around, matching the rule that they + * can't place or prime it either. + */ +public final class TNTKnockbackListener implements Listener { + + /** Minimum gap between accepted hits from the same player, in milliseconds. */ + private static final long HIT_COOLDOWN_MILLIS = 250; + + private final NoPvPManager noPvPManager; + private final java.util.Map lastHit = new java.util.concurrent.ConcurrentHashMap<>(); + + public TNTKnockbackListener(NoPvPManager noPvPManager) { + this.noPvPManager = noPvPManager; + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onPreAttack(PrePlayerAttackEntityEvent event) { + if (!(event.getAttacked() instanceof TNTPrimed tnt) || !event.willAttack()) { + return; + } + Player attacker = event.getPlayer(); + + // Protected players can't move TNT around at all. + if (noPvPManager.isEnabled(attacker)) { + event.setCancelled(true); + return; + } + + // Throttle so a held attack button doesn't extend the fuse every tick. + long now = System.currentTimeMillis(); + Long previous = lastHit.get(attacker.getUniqueId()); + if (previous != null && now - previous < HIT_COOLDOWN_MILLIS) { + return; + } + lastHit.put(attacker.getUniqueId(), now); + + TNTLaunch.shoveAway(tnt, attacker.getLocation(), attacker.getLocation().getDirection()); + } +} diff --git a/src/main/java/com/dvorakmortonxd/qoltweaks/nopvp/NoPvPListener.java b/src/main/java/com/dvorakmortonxd/qoltweaks/nopvp/NoPvPListener.java index 2ff51f6..9ce3f1d 100644 --- a/src/main/java/com/dvorakmortonxd/qoltweaks/nopvp/NoPvPListener.java +++ b/src/main/java/com/dvorakmortonxd/qoltweaks/nopvp/NoPvPListener.java @@ -32,6 +32,8 @@ public final class NoPvPListener implements Listener { @EventHandler(priority = EventPriority.MONITOR) public void onPlayerJoin(PlayerJoinEvent event) { + // A first-time player starts with no-PvP on by default. + manager.handleJoin(event.getPlayer()); manager.applyState(event.getPlayer()); } diff --git a/src/main/java/com/dvorakmortonxd/qoltweaks/nopvp/NoPvPManager.java b/src/main/java/com/dvorakmortonxd/qoltweaks/nopvp/NoPvPManager.java index 439a22e..d7e575b 100644 --- a/src/main/java/com/dvorakmortonxd/qoltweaks/nopvp/NoPvPManager.java +++ b/src/main/java/com/dvorakmortonxd/qoltweaks/nopvp/NoPvPManager.java @@ -11,6 +11,7 @@ import org.bukkit.plugin.java.JavaPlugin; import java.io.File; import java.io.IOException; import java.util.Map; +import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; @@ -24,15 +25,17 @@ public final class NoPvPManager { private final JavaPlugin plugin; private final TeamManager teamManager; private final File dataFile; - private final PacifistHologram hologram; + private final PlayerSymbol symbol; private final Map enabled = new ConcurrentHashMap<>(); private final Map cooldownUntil = new ConcurrentHashMap<>(); + /** Players we've seen before, so first-timers can be opted into no-PvP by default. */ + private final Set known = ConcurrentHashMap.newKeySet(); public NoPvPManager(JavaPlugin plugin, TeamManager teamManager) { this.plugin = plugin; this.teamManager = teamManager; this.dataFile = new File(plugin.getDataFolder(), "nopvp.yml"); - this.hologram = new PacifistHologram(plugin); + this.symbol = new PlayerSymbol(plugin, teamManager); load(); } @@ -40,6 +43,22 @@ public final class NoPvPManager { return enabled.getOrDefault(player.getUniqueId(), false); } + /** + * Records a joining player. A player we have never seen before starts with + * no-PvP on by default; returning players keep whatever they last chose. + * + * @return {@code true} if no-PvP was switched on by the default + */ + public boolean handleJoin(Player player) { + UUID uuid = player.getUniqueId(); + if (known.add(uuid)) { + enabled.putIfAbsent(uuid, true); + scheduleSave(); + return isEnabled(player); + } + return false; + } + /** * Whether any player with no-PvP enabled is within {@code radius} blocks of * the given location. Used to keep PvP gear away from protected players. @@ -87,16 +106,13 @@ public final class NoPvPManager { } /** - * Applies the no-PvP visuals for a player: the Pacifist hologram while - * no-PvP is on, plus the team-coloured glow whenever they are in a team and - * haven't turned their own glow off (independent of no-PvP). + * Applies the player's symbol: a totem of undying while no-PvP is on, + * otherwise their chosen symbol (netherite axe by default), plus the + * team-coloured glow whenever they are in a team and haven't turned their + * own glow off (independent of no-PvP). */ public void applyState(Player player) { - if (isEnabled(player)) { - hologram.show(player); - } else { - hologram.hide(player); - } + symbol.apply(player, isEnabled(player)); player.setGlowing(teamManager != null && teamManager.shouldGlow(player)); } @@ -110,9 +126,9 @@ public final class NoPvPManager { } } - /** Removes the per-player visuals, e.g. on quit or death. */ + /** Removes the per-player symbol, e.g. on quit or death. */ public void clearVisuals(Player player) { - hologram.hide(player); + symbol.hide(player); player.setGlowing(false); } @@ -122,7 +138,7 @@ public final class NoPvPManager { } public void removeAllVisuals() { - hologram.removeAll(); + symbol.removeAll(); } private void load() { @@ -143,6 +159,7 @@ public final class NoPvPManager { } enabled.put(uuid, section.getBoolean(key + ".enabled", false)); cooldownUntil.put(uuid, section.getLong(key + ".cooldown-until", 0L)); + known.add(uuid); } } diff --git a/src/main/java/com/dvorakmortonxd/qoltweaks/nopvp/PacifistHologram.java b/src/main/java/com/dvorakmortonxd/qoltweaks/nopvp/PacifistHologram.java deleted file mode 100644 index 3e84cbc..0000000 --- a/src/main/java/com/dvorakmortonxd/qoltweaks/nopvp/PacifistHologram.java +++ /dev/null @@ -1,118 +0,0 @@ -package com.dvorakmortonxd.qoltweaks.nopvp; - -import io.papermc.paper.threadedregions.scheduler.ScheduledTask; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; -import org.bukkit.Bukkit; -import org.bukkit.Color; -import org.bukkit.Location; -import org.bukkit.entity.Display; -import org.bukkit.entity.Player; -import org.bukkit.entity.TextDisplay; -import org.bukkit.plugin.java.JavaPlugin; - -import java.util.Map; -import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; - -/** - * Shows a floating "Pacifist" label above a no-PvP player. - *

- * The label is a plain (non-persistent) {@link TextDisplay} that is teleported - * to an absolute height above the player by a global follow task, with the - * actual teleport dispatched on the region that owns the label. Using an - * absolute height (rather than a passenger mount) keeps the label clear of the - * player's head and name tag. - */ -public final class PacifistHologram { - - private static final String TAG = "qol_pacifist"; - /** Height of the label's centre, in blocks above the player's feet. */ - private static final double TARGET_HEIGHT = 2.5; - - private final JavaPlugin plugin; - private final Map displays = new ConcurrentHashMap<>(); - private final ScheduledTask followTask; - - public PacifistHologram(JavaPlugin plugin) { - this.plugin = plugin; - this.followTask = plugin.getServer().getGlobalRegionScheduler() - .runAtFixedRate(plugin, task -> followAll(), 1L, 2L); - } - - public void show(Player player) { - UUID uuid = player.getUniqueId(); - TextDisplay current = displays.get(uuid); - if (current != null && current.isValid() && current.getWorld().equals(player.getWorld())) { - current.teleport(targetOf(player)); - return; - } - hide(player); - spawn(player); - } - - public void hide(Player player) { - removeEntity(displays.remove(player.getUniqueId())); - } - - public void removeAll() { - if (followTask != null) { - followTask.cancel(); - } - for (UUID uuid : displays.keySet().toArray(new UUID[0])) { - removeEntity(displays.remove(uuid)); - } - } - - private void spawn(Player player) { - TextDisplay display = player.getWorld().spawn(targetOf(player), TextDisplay.class, entity -> { - entity.text(Component.text("Pacifist", NamedTextColor.GOLD)); - entity.setBillboard(Display.Billboard.VERTICAL); - entity.setSeeThrough(false); - entity.setShadowed(true); - entity.setDefaultBackground(false); - entity.setBackgroundColor(Color.fromARGB(0, 0, 0, 0)); - entity.setTeleportDuration(2); - entity.setPersistent(false); - entity.addScoreboardTag(TAG); - }); - displays.put(player.getUniqueId(), display); - } - - private void followAll() { - if (displays.isEmpty()) { - return; - } - for (Map.Entry entry : displays.entrySet()) { - Player player = Bukkit.getPlayer(entry.getKey()); - TextDisplay display = entry.getValue(); - if (player == null || !player.isOnline() || !display.isValid()) { - continue; - } - if (!display.getWorld().equals(player.getWorld())) { - continue; - } - Location target = targetOf(player); - Bukkit.getRegionScheduler().run(plugin, target, task -> { - if (display.isValid()) { - display.teleport(target); - } - }); - } - } - - private Location targetOf(Player player) { - return player.getLocation().add(0, TARGET_HEIGHT, 0); - } - - private void removeEntity(TextDisplay display) { - if (display == null || !display.isValid()) { - return; - } - try { - display.getScheduler().run(plugin, task -> display.remove(), null); - } catch (RuntimeException ignored) { - // Plugin shutting down / scheduler unavailable; the entity is non-persistent. - } - } -} diff --git a/src/main/java/com/dvorakmortonxd/qoltweaks/nopvp/PlayerSymbol.java b/src/main/java/com/dvorakmortonxd/qoltweaks/nopvp/PlayerSymbol.java new file mode 100644 index 0000000..005cad3 --- /dev/null +++ b/src/main/java/com/dvorakmortonxd/qoltweaks/nopvp/PlayerSymbol.java @@ -0,0 +1,240 @@ +package com.dvorakmortonxd.qoltweaks.nopvp; + +import com.dvorakmortonxd.qoltweaks.teams.TeamManager; +import io.papermc.paper.threadedregions.scheduler.ScheduledTask; +import org.bukkit.Bukkit; +import org.bukkit.Color; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.entity.Display; +import org.bukkit.entity.ItemDisplay; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.util.Transformation; +import org.joml.Quaternionf; +import org.joml.Vector3f; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Shows a floating symbol above a player's head. + *

+ * A player with no-PvP enabled always shows a totem of undying. A player + * with no-PvP disabled shows the symbol they picked with + * {@code /team symbol } — a wither skeleton skull or a + * netherite axe — defaulting to the axe for players who haven't chosen + * one. The totem glows green; the wither skull and axe glow red. + *

+ * Each symbol is one or two non-persistent {@link ItemDisplay} entities that are + * moved above the player by a global follow task. Because the server runs Folia, + * the move is always performed with {@link ItemDisplay#teleportAsync(Location)} + * (a plain {@code teleport} throws while region threading) and only when the + * player has actually moved, so the symbol tracks the player without spamming + * an identical move every tick. + */ +public final class PlayerSymbol { + + /** Height of the symbol's centre, in blocks above the player's feet. */ + private static final double TARGET_HEIGHT = 3.05; + /** Item scale of a single symbol icon. */ + private static final float AXE_SCALE = 0.9f; + /** The totem is shown at the standard icon size. */ + private static final float TOTEM_SCALE = 0.62f; + /** The wither skull is shown twice as large as the standard icon. */ + private static final float WITHER_SCALE = TOTEM_SCALE * 2.0f; + /** A display whose view range is a touch under one block disappears very fast. */ + private static final float VIEW_RANGE = 6.0f; + /** Glow colour of the totem shown while no-PvP is on. */ + private static final Color TOTEM_GLOW = Color.fromRGB(0x55FF55); // green + /** Glow colour of the symbols shown while no-PvP is off. */ + private static final Color SYMBOL_GLOW = Color.fromRGB(0xFF5555); // red + + private final JavaPlugin plugin; + private final TeamManager teamManager; + private final Map active = new ConcurrentHashMap<>(); + private final ScheduledTask followTask; + + public PlayerSymbol(JavaPlugin plugin, TeamManager teamManager) { + this.plugin = plugin; + this.teamManager = teamManager; + this.followTask = plugin.getServer().getGlobalRegionScheduler() + .runAtFixedRate(plugin, task -> followAll(), 1L, 1L); + } + + /** + * Applies the correct symbol for a player based on their no-PvP state: + * a totem while no-PvP is on, otherwise their chosen symbol (axe by + * default). Rebuilds the displays only when the desired symbol changed. + */ + public void apply(Player player, boolean noPvPEnabled) { + Symbol desired = noPvPEnabled ? Symbol.TOTEM : teamManager.symbolOf(player); + UUID uuid = player.getUniqueId(); + SymbolEntities current = active.get(uuid); + + if (current != null && current.kind == desired) { + current.snapTo(player, plugin); + return; + } + + remove(uuid); + spawn(player, desired, uuid); + } + + /** Removes a player's symbol. */ + public void hide(Player player) { + remove(player.getUniqueId()); + } + + /** Removes every symbol and stops the follow task (plugin shutdown). */ + public void removeAll() { + if (followTask != null) { + followTask.cancel(); + } + for (UUID uuid : active.keySet().toArray(new UUID[0])) { + remove(uuid); + } + } + + private void spawn(Player player, Symbol symbol, UUID uuid) { + Location base = baseOf(player.getLocation()); + SymbolEntities entities = new SymbolEntities(symbol, base); + switch (symbol) { + case TOTEM -> entities.add(spawnIcon(player, base, Material.TOTEM_OF_UNDYING, TOTEM_SCALE, TOTEM_GLOW)); + case WITHER -> entities.add(spawnIcon(player, base, Material.WITHER_SKELETON_SKULL, WITHER_SCALE, SYMBOL_GLOW)); + case AXE -> entities.add(spawnIcon(player, base, Material.NETHERITE_AXE, AXE_SCALE, SYMBOL_GLOW)); + } + entities.lastTarget = base.clone(); + active.put(uuid, entities); + } + + /** + * Spawns one symbol icon. + * + * @param scale uniform scale applied to the item + * @param glow colour the icon glows in + */ + private ItemDisplay spawnIcon(Player player, Location location, Material material, + float scale, Color glow) { + return player.getWorld().spawn(location, ItemDisplay.class, entity -> { + entity.setItemStack(new ItemStack(material)); + entity.setBillboard(Display.Billboard.CENTER); + entity.setItemDisplayTransform(ItemDisplay.ItemDisplayTransform.FIXED); + entity.setViewRange(VIEW_RANGE); + entity.setTeleportDuration(1); + entity.setInterpolationDuration(1); + entity.setShadowRadius(0.0f); + entity.setShadowStrength(0.0f); + entity.setPersistent(false); + entity.addScoreboardTag("qol_symbol"); + entity.setGlowing(true); + entity.setGlowColorOverride(glow); + entity.setTransformation(new Transformation( + new Vector3f(0.0f, 0.0f, 0.0f), + new Quaternionf(), + new Vector3f(scale, scale, scale), + new Quaternionf())); + }); + } + + private void followAll() { + if (active.isEmpty()) { + return; + } + for (Map.Entry entry : active.entrySet()) { + Player player = Bukkit.getPlayer(entry.getKey()); + if (player == null || !player.isOnline()) { + continue; + } + entry.getValue().snapTo(player, plugin); + } + } + + /** Base location (the player's feet plus the target height). */ + private static Location baseOf(Location playerLocation) { + return playerLocation.clone().add(0, TARGET_HEIGHT, 0); + } + + private void remove(UUID uuid) { + SymbolEntities entities = active.remove(uuid); + if (entities == null) { + return; + } + for (ItemDisplay display : entities.displays) { + removeEntity(display); + } + } + + private void removeEntity(ItemDisplay display) { + if (display == null || !display.isValid()) { + return; + } + try { + display.getScheduler().run(plugin, task -> display.remove(), null); + } catch (RuntimeException ignored) { + // Plugin shutting down / scheduler unavailable; the entity is non-persistent. + } + } + + /** The per-player symbol choices a player can pick from. */ + public enum Symbol { + TOTEM, + WITHER, + AXE + } + + /** The display entities making up one player's symbol. */ + private static final class SymbolEntities { + private final Symbol kind; + private final List displays = new ArrayList<>(2); + /** The last base location the symbol was moved to. */ + private volatile Location lastTarget; + + private SymbolEntities(Symbol kind, Location base) { + this.kind = kind; + this.lastTarget = base; + } + + private void add(ItemDisplay display) { + displays.add(display); + } + + /** + * Moves every display to sit above the player. Uses {@code teleportAsync} + * (required under Folia region threading) and is skipped while the player + * hasn't moved. + */ + private void snapTo(Player player, JavaPlugin plugin) { + Location base = baseOf(player.getLocation()); + Location previous = lastTarget; + if (previous != null && samePosition(previous, base)) { + return; + } + lastTarget = base.clone(); + + for (ItemDisplay display : displays) { + if (!display.isValid()) { + continue; + } + try { + display.teleportAsync(base); + } catch (RuntimeException ignored) { + // Plugin shutting down / entity removed. + } + } + } + + private static boolean samePosition(Location a, Location b) { + if (a.getWorld() == null || !a.getWorld().equals(b.getWorld())) { + return false; + } + // Sub-millimetre tolerance: rely on the player's own movement to drive + // updates, and treat a stationary player as unchanged. + return a.distanceSquared(b) < 1.0e-6; + } + } +} diff --git a/src/main/java/com/dvorakmortonxd/qoltweaks/teams/Team.java b/src/main/java/com/dvorakmortonxd/qoltweaks/teams/Team.java index 74e56f1..672c93d 100644 --- a/src/main/java/com/dvorakmortonxd/qoltweaks/teams/Team.java +++ b/src/main/java/com/dvorakmortonxd/qoltweaks/teams/Team.java @@ -8,9 +8,9 @@ import java.util.Set; import java.util.UUID; /** - * A purely cosmetic player team. Membership is tracked by UUID and the team's - * colour is applied to each member's no-PvP glow (which each member can toggle - * off for themselves). + * A purely cosmetic player team. Membership is tracked by UUID, the team's + * colour drives each member's glow, and an optional short tag is shown after + * members' names. Glow is toggled per member. */ public final class Team { @@ -20,6 +20,8 @@ public final class Team { private final Set members = new LinkedHashSet<>(); private final Set admins = new LinkedHashSet<>(); private NamedTextColor color; + /** Optional tag (max 4 chars) shown after member names; null = no tag. */ + private String tag; public Team(UUID id, String name, UUID owner) { this.id = id; @@ -40,6 +42,15 @@ public final class Team { this.name = name; } + /** The optional name tag, or {@code null} when the team has none. */ + public String getTag() { + return tag; + } + + public void setTag(String tag) { + this.tag = tag; + } + public UUID getOwner() { return owner; } diff --git a/src/main/java/com/dvorakmortonxd/qoltweaks/teams/TeamCommand.java b/src/main/java/com/dvorakmortonxd/qoltweaks/teams/TeamCommand.java index 357460a..ed7ae6a 100644 --- a/src/main/java/com/dvorakmortonxd/qoltweaks/teams/TeamCommand.java +++ b/src/main/java/com/dvorakmortonxd/qoltweaks/teams/TeamCommand.java @@ -1,7 +1,10 @@ package com.dvorakmortonxd.qoltweaks.teams; import com.dvorakmortonxd.qoltweaks.nopvp.NoPvPManager; +import com.dvorakmortonxd.qoltweaks.nopvp.PlayerSymbol; import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.event.HoverEvent; import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Bukkit; import org.bukkit.command.Command; @@ -18,7 +21,7 @@ import java.util.UUID; public final class TeamCommand implements CommandExecutor, TabCompleter { private static final List SUBCOMMANDS = List.of( - "create", "delete", "invite", "leave", "join", "color", "admin", "glow", "chat"); + "create", "delete", "invite", "leave", "join", "color", "admin", "glow", "chat", "symbol", "tag"); private final TeamManager manager; private final NoPvPManager noPvPManager; @@ -53,6 +56,8 @@ public final class TeamCommand implements CommandExecutor, TabCompleter { case "admin" -> admin(player, args); case "glow" -> glow(player); case "chat" -> chat(player, args); + case "symbol" -> symbol(player, args); + case "tag" -> tag(player, args); default -> sendUsage(player); } return true; @@ -144,8 +149,13 @@ public final class TeamCommand implements CommandExecutor, TabCompleter { manager.addInvite(team, target.getUniqueId()); player.sendMessage("§aInvited §e" + target.getName() + "§a to the team."); - target.sendMessage("§e" + player.getName() + "§a invited you to team §e" + team.getName() - + "§a. Use §e/team join " + team.getName() + "§a to accept."); + target.sendMessage(Component.text() + .append(Component.text(player.getName(), NamedTextColor.YELLOW)) + .append(Component.text(" invited you to team ", NamedTextColor.GREEN)) + .append(Component.text(team.getName(), NamedTextColor.YELLOW)) + .append(Component.text(".", NamedTextColor.GREEN)) + .build()); + target.sendMessage(joinButton(team.getName())); } private void leave(Player player) { @@ -196,8 +206,11 @@ public final class TeamCommand implements CommandExecutor, TabCompleter { manager.addJoinRequest(team, player.getUniqueId()); player.sendMessage("§aJoin request sent to team §e" + team.getName() + "§a."); - notifyAdmins(team, "§e" + player.getName() + "§a wants to join your team. Use §e/team invite " - + player.getName() + "§a to accept."); + notifyAdmins(team, Component.text() + .append(Component.text(player.getName(), NamedTextColor.YELLOW)) + .append(Component.text(" wants to join your team.", NamedTextColor.GREEN)) + .build()); + notifyAdmins(team, acceptButton(player.getName())); } private void color(Player player, String[] args) { @@ -219,6 +232,7 @@ public final class TeamCommand implements CommandExecutor, TabCompleter { String input = args[1].toLowerCase(Locale.ROOT); if (input.equals("none") || input.equals("reset") || input.equals("clear")) { manager.setColor(team, null); + refreshMembers(team); player.sendMessage("§aTeam colour cleared."); return; } @@ -229,9 +243,23 @@ public final class TeamCommand implements CommandExecutor, TabCompleter { return; } manager.setColor(team, color); + refreshMembers(team); player.sendMessage("§aTeam colour set to §e" + color); } + /** + * Re-applies each online member's glow after a colour change so the new + * scoreboard-team colour is reflected on their (glowing) outline. + */ + private void refreshMembers(Team team) { + for (UUID member : new ArrayList<>(team.getMembers())) { + Player online = Bukkit.getPlayer(member); + if (online != null) { + noPvPManager.refreshLater(online); + } + } + } + private void admin(Player player, String[] args) { if (args.length < 2) { player.sendMessage("§cUsage: /team admin "); @@ -293,6 +321,72 @@ public final class TeamCommand implements CommandExecutor, TabCompleter { : "§cYour no-PvP glow was turned off."); } + /** + * Picks the symbol shown above the player's head while they have no-PvP + * off. The choice is personal (it does not change the whole team) and can + * be made whether or not the player is in a team. + */ + private void symbol(Player player, String[] args) { + if (args.length < 2) { + player.sendMessage("§cUsage: /team symbol "); + player.sendMessage("§7Current: §e" + currentSymbolName(player)); + return; + } + PlayerSymbol.Symbol chosen; + switch (args[1].toLowerCase(Locale.ROOT)) { + case "wither" -> chosen = PlayerSymbol.Symbol.WITHER; + case "axe" -> chosen = PlayerSymbol.Symbol.AXE; + default -> { + player.sendMessage("§cUnknown symbol. Options: §ewither§7, §eaxe"); + return; + } + } + manager.setSymbol(player.getUniqueId(), chosen); + noPvPManager.refreshLater(player); + player.sendMessage("§aYour no-PvP symbol was set to §e" + chosen.name().toLowerCase(Locale.ROOT) + + "§a. It shows while no-PvP is off."); + } + + private String currentSymbolName(Player player) { + return manager.symbolOf(player).name().toLowerCase(Locale.ROOT); + } + + /** + * Sets the optional team tag shown after members' names (max 4 characters), + * or clears it with {@code none}. + */ + private void tag(Player player, String[] args) { + Team team = manager.teamOf(player); + if (team == null) { + player.sendMessage("§cYou're not in a team."); + return; + } + if (!team.isAdmin(player.getUniqueId())) { + player.sendMessage("§cOnly the team owner or an admin can change the tag."); + return; + } + if (args.length < 2) { + String current = team.getTag(); + player.sendMessage("§cUsage: /team tag "); + player.sendMessage("§7Current tag: §e" + (current == null ? "§7(none)" : current)); + return; + } + String input = args[1]; + if (input.equalsIgnoreCase("none") || input.equalsIgnoreCase("clear") + || input.equalsIgnoreCase("reset")) { + manager.setTag(team, null); + notifyMembers(team, null, "§eThe team tag was cleared."); + return; + } + if (!TeamManager.isValidTag(input)) { + player.sendMessage("§cTags must be 2-" + TeamManager.MAX_TAG_LENGTH + + " letters, numbers or underscores (or §enone§c)."); + return; + } + manager.setTag(team, input); + notifyMembers(team, null, "§aThe team tag is now §e[" + input + "]§a."); + } + private void chat(Player player, String[] args) { Team team = manager.teamOf(player); if (team == null) { @@ -344,6 +438,46 @@ public final class TeamCommand implements CommandExecutor, TabCompleter { } } + private void notifyAdmins(Team team, Component message) { + for (UUID admin : new ArrayList<>(team.getAdmins())) { + Player online = Bukkit.getPlayer(admin); + if (online != null) { + online.sendMessage(message); + } + } + Player owner = Bukkit.getPlayer(team.getOwner()); + if (owner != null && !team.getAdmins().contains(team.getOwner())) { + owner.sendMessage(message); + } + } + + /** + * A clickable "JOIN THEIR TEAM" button. Clicking it accepts the invitation + * by running {@code /team join }. + */ + private Component joinButton(String teamName) { + return Component.text("[", NamedTextColor.DARK_GRAY) + .append(Component.text("JOIN THEIR TEAM", NamedTextColor.GREEN) + .decorate(net.kyori.adventure.text.format.TextDecoration.BOLD)) + .append(Component.text("]", NamedTextColor.DARK_GRAY)) + .clickEvent(ClickEvent.runCommand("/team join " + teamName)) + .hoverEvent(HoverEvent.showText(Component.text("Click to join " + teamName, NamedTextColor.GRAY))); + } + + /** + * A clickable "ALLOW PLAYER INTO THE TEAM" button. Clicking it accepts the + * join request by running {@code /team invite }. + */ + private Component acceptButton(String playerName) { + return Component.text("[", NamedTextColor.DARK_GRAY) + .append(Component.text("ALLOW " + playerName.toUpperCase(Locale.ROOT) + " INTO THE TEAM", + NamedTextColor.GREEN) + .decorate(net.kyori.adventure.text.format.TextDecoration.BOLD)) + .append(Component.text("]", NamedTextColor.DARK_GRAY)) + .clickEvent(ClickEvent.runCommand("/team invite " + playerName)) + .hoverEvent(HoverEvent.showText(Component.text("Click to accept " + playerName, NamedTextColor.GRAY))); + } + private void sendUsage(Player player) { player.sendMessage("§6§lTeam commands"); player.sendMessage("§e/team create §7- make a team"); @@ -352,6 +486,8 @@ public final class TeamCommand implements CommandExecutor, TabCompleter { player.sendMessage("§e/team color §7- set the team's glow colour"); player.sendMessage("§e/team admin §7- manage admins"); player.sendMessage("§e/team glow §7- toggle your own glow"); + player.sendMessage("§e/team symbol §7- the symbol above your head when no-PvP is off"); + player.sendMessage("§e/team tag §7- the tag after your name (max 4 chars)"); player.sendMessage("§e/team chat [message] §7- team chat / toggle it"); player.sendMessage("§e/team leave §7- leave your team"); player.sendMessage("§e/team delete §7- disband your team (owner)"); @@ -396,6 +532,12 @@ public final class TeamCommand implements CommandExecutor, TabCompleter { case "admin" -> { return filter(List.of("add", "remove"), args[1]); } + case "symbol" -> { + return filter(List.of("wither", "axe"), args[1]); + } + case "tag" -> { + return filter(List.of("none"), args[1]); + } default -> { return List.of(); } diff --git a/src/main/java/com/dvorakmortonxd/qoltweaks/teams/TeamGlowPackets.java b/src/main/java/com/dvorakmortonxd/qoltweaks/teams/TeamGlowPackets.java new file mode 100644 index 0000000..2f6b547 --- /dev/null +++ b/src/main/java/com/dvorakmortonxd/qoltweaks/teams/TeamGlowPackets.java @@ -0,0 +1,216 @@ +package com.dvorakmortonxd.qoltweaks.teams; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.util.Collection; +import java.util.List; + +/** + * Sends vanilla scoreboard-team packets to clients directly. + *

+ * Folia forbids every runtime change to the server scoreboard (the Bukkit + * {@code Scoreboard} API throws {@code UnsupportedOperationException} on any + * thread but the main one, which no longer runs once the server has started). + * Colouring a player's glowing outline, however, is only possible through a + * scoreboard team. This class sidesteps Folia's restriction by building the + * team and its packets ourselves and pushing them straight to each player's + * connection, without ever touching the server scoreboard. + *

+ * The team is configured so the player's name stays white ({@code prefix}) and + * the coloured {@code [TAG]} is appended after the name ({@code suffix}), while + * the team colour still drives the glow outline. + *

+ * Everything here is reflective because the {@code net.minecraft} server + * classes are not on the plugin's compile classpath. Failures are reported once + * and the plugin keeps running (teams simply show no tag/glow colour). + */ +public final class TeamGlowPackets { + + private static final String TEAM_PACKET = "net.minecraft.network.protocol.game.ClientboundSetPlayerTeamPacket"; + private static final String PLAYER_TEAM = "net.minecraft.world.scores.PlayerTeam"; + private static final String SCOREBOARD = "net.minecraft.world.scores.Scoreboard"; + private static final String CHAT_FORMATTING = "net.minecraft.ChatFormatting"; + private static final String VISIBILITY = "net.minecraft.world.scores.Team$Visibility"; + private static final String CRAFT_CHAT = "org.bukkit.craftbukkit.util.CraftChatMessage"; + + private boolean broken; + + /** + * Creates or refreshes a team's packet representation and pushes it to every + * online player. Passing an empty {@code members} list still sends the team + * definition (so people can see the tag when the team is created). + * + * @param teamName vanilla team name (must be unique) + * @param colorId {@code ChatFormatting} enum name, e.g. {@code RED}, or + * {@code null} for no colour + * @param names online member names to add to the team + * @param suffix the legacy-formatted tag to show after the name, e.g. + * {@code §a[GODS]} (may be empty) + */ + public void apply(String teamName, String colorId, List names, String suffix) { + if (broken) { + return; + } + try { + Object scoreboard = newInstance(SCOREBOARD); + Object team = invoke(scoreboard, "addPlayerTeam", new Class[]{String.class}, teamName); + if (team == null) { + return; + } + + // White name, coloured tag after it. + Object whitePrefix = legacyComponent("§f"); + invoke(team, "setPlayerPrefix", new Class[]{componentClass()}, whitePrefix); + invoke(team, "setPlayerSuffix", new Class[]{componentClass()}, legacyComponent(suffix)); + + if (colorId != null) { + Object color = enumConstant(CHAT_FORMATTING, colorId); + if (color != null) { + invoke(team, "setColor", new Class[]{color.getClass()}, color); + } + } + Object always = enumConstant(VISIBILITY, "ALWAYS"); + if (always != null) { + invoke(team, "setNameTagVisibility", new Class[]{always.getClass()}, always); + invoke(team, "setDeathMessageVisibility", new Class[]{always.getClass()}, always); + } + + // Team definition packet. + Object teamPacket = staticInvoke(TEAM_PACKET, "createAddOrModifyPacket", + new Class[]{classFor(PLAYER_TEAM), boolean.class}, team, true); + broadcast(teamPacket); + + // Membership packets. + if (names != null && !names.isEmpty()) { + Object addAction = addAction(); + if (addAction != null) { + Object playersPacket = staticInvoke(TEAM_PACKET, "createMultiplePlayerPacket", + new Class[]{classFor(PLAYER_TEAM), Collection.class, addAction.getClass()}, + team, names, addAction); + broadcast(playersPacket); + } + } + } catch (Throwable t) { + broken = true; + Bukkit.getLogger().warning("[qol-tweaks] Could not send team packets; team tags/glow colours disabled: " + t); + } + } + + /** Removes a team from every client. */ + public void remove(String teamName) { + if (broken) { + return; + } + try { + Object scoreboard = newInstance(SCOREBOARD); + Object team = invoke(scoreboard, "addPlayerTeam", new Class[]{String.class}, teamName); + if (team == null) { + return; + } + Object packet = staticInvoke(TEAM_PACKET, "createRemovePacket", + new Class[]{classFor(PLAYER_TEAM)}, team); + broadcast(packet); + } catch (Throwable t) { + broken = true; + Bukkit.getLogger().warning("[qol-tweaks] Could not remove team packets: " + t); + } + } + + private void broadcast(Object packet) { + for (Player player : Bukkit.getOnlinePlayers()) { + send(player, packet); + } + } + + private void send(Player player, Object packet) { + try { + Object handle = player.getClass().getMethod("getHandle").invoke(player); + Object connection = handle.getClass().getField("connection").get(handle); + connection.getClass().getMethod("send", classFor("net.minecraft.network.protocol.Packet")) + .invoke(connection, packet); + } catch (Throwable t) { + // A single failed send shouldn't disable the whole feature. + Bukkit.getLogger().fine("[qol-tweaks] team packet send failed for " + player.getName() + ": " + t); + } + } + + private Object addAction() { + return enumConstant(TEAM_PACKET + "$Action", "ADD"); + } + + private Object legacyComponent(String legacy) { + try { + return staticInvoke(CRAFT_CHAT, "fromStringOrEmpty", new Class[]{String.class}, legacy); + } catch (Exception e) { + throw new IllegalStateException("legacy component", e); + } + } + + private Class componentClass() { + return classFor("net.minecraft.network.chat.Component"); + } + + // --- tiny reflection helpers ------------------------------------------- + + private static Class classFor(String name) { + try { + return Class.forName(name); + } catch (ClassNotFoundException e) { + throw new IllegalStateException(name, e); + } + } + + private static Object newInstance(String className) throws Exception { + Constructor constructor = classFor(className).getDeclaredConstructor(); + constructor.setAccessible(true); + return constructor.newInstance(); + } + + private static Object invoke(Object target, String method, Class[] types, Object... args) throws Exception { + Method m = findMethod(target.getClass(), method, types); + m.setAccessible(true); + return m.invoke(target, args); + } + + private static Object staticInvoke(String className, String method, Class[] types, Object... args) throws Exception { + Class cls = classFor(className); + Method m = findMethod(cls, method, types); + m.setAccessible(true); + return m.invoke(null, args); + } + + private static Method findMethod(Class cls, String name, Class[] paramTypes) throws NoSuchMethodException { + // Exact match first. + try { + return cls.getMethod(name, paramTypes); + } catch (NoSuchMethodException ignored) { + // fall back to a name/arity scan (handles enum subtype params etc.) + } + for (Method m : cls.getMethods()) { + if (m.getName().equals(name) && m.getParameterCount() == paramTypes.length) { + return m; + } + } + throw new NoSuchMethodException(cls.getName() + "#" + name); + } + + private static Object enumConstant(String className, String constant) { + try { + Class cls = classFor(className); + for (Object value : cls.getEnumConstants()) { + // Enum#name() is the canonical name; toString() may be localised + // or return a display character (e.g. ChatFormatting). + String name = ((Enum) value).name(); + if (name.equalsIgnoreCase(constant)) { + return value; + } + } + } catch (Throwable ignored) { + // fall through + } + return null; + } +} diff --git a/src/main/java/com/dvorakmortonxd/qoltweaks/teams/TeamListener.java b/src/main/java/com/dvorakmortonxd/qoltweaks/teams/TeamListener.java index a079d1b..49ee416 100644 --- a/src/main/java/com/dvorakmortonxd/qoltweaks/teams/TeamListener.java +++ b/src/main/java/com/dvorakmortonxd/qoltweaks/teams/TeamListener.java @@ -24,13 +24,14 @@ public final class TeamListener implements Listener { } /** - * Runs at NORMAL so the scoreboard team (and thus glow colour) is applied - * before {@code NoPvPListener} turns the glow on at MONITOR. + * Runs at NORMAL so the team tag/glow team is applied before + * {@code NoPvPListener} turns the glow on at MONITOR. */ @EventHandler(priority = EventPriority.NORMAL) public void onPlayerJoin(PlayerJoinEvent event) { Player player = event.getPlayer(); manager.syncPlayer(player); + manager.applyTabName(player); Team team = manager.teamOf(player); if (team != null && team.isAdmin(player.getUniqueId())) { @@ -44,7 +45,7 @@ public final class TeamListener implements Listener { /** * Redirects chat into the team channel when a player has team chat toggled - * on, and otherwise adds the team's tag in front of normal chat messages. + * on, and otherwise shows the team's {@code [TAG]} after their name. */ @EventHandler(priority = EventPriority.LOWEST) public void onChat(AsyncChatEvent event) { @@ -64,12 +65,15 @@ public final class TeamListener implements Listener { return; } - NamedTextColor color = team.getColor() != null ? team.getColor() : NamedTextColor.AQUA; - Component tag = TeamManager.tag(team.getName(), color); - Component name = Component.text(player.getName(), color); + Component suffix = TeamManager.suffix(team); + if (suffix == null) { + return; + } + Component name = Component.text(player.getName(), NamedTextColor.WHITE); event.renderer((source, sourceDisplayName, message, viewer) -> - tag.append(Component.text("<", NamedTextColor.WHITE)) + Component.text("<", NamedTextColor.WHITE) .append(name) + .append(suffix) .append(Component.text("> ", NamedTextColor.WHITE)) .append(message.colorIfAbsent(NamedTextColor.WHITE))); } @@ -89,7 +93,8 @@ public final class TeamListener implements Listener { Component.text("[", NamedTextColor.DARK_GRAY) .append(Component.text(team.getName(), color)) .append(Component.text("] ", NamedTextColor.DARK_GRAY)) - .append(Component.text(source.getName() + ": ", NamedTextColor.WHITE)) + .append(Component.text(source.getName(), NamedTextColor.WHITE)) + .append(Component.text(": ", NamedTextColor.WHITE)) .append(message.colorIfAbsent(NamedTextColor.WHITE))); } } diff --git a/src/main/java/com/dvorakmortonxd/qoltweaks/teams/TeamManager.java b/src/main/java/com/dvorakmortonxd/qoltweaks/teams/TeamManager.java index 1bfc158..adff6f6 100644 --- a/src/main/java/com/dvorakmortonxd/qoltweaks/teams/TeamManager.java +++ b/src/main/java/com/dvorakmortonxd/qoltweaks/teams/TeamManager.java @@ -1,5 +1,6 @@ package com.dvorakmortonxd.qoltweaks.teams; +import com.dvorakmortonxd.qoltweaks.nopvp.PlayerSymbol; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Bukkit; @@ -7,7 +8,6 @@ import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.entity.Player; import org.bukkit.plugin.java.JavaPlugin; -import org.bukkit.scoreboard.Scoreboard; import java.io.File; import java.io.IOException; @@ -22,11 +22,15 @@ import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; /** - * Stores cosmetic teams, their members and colours, and mirrors them onto the - * main scoreboard so the no-PvP glow picks up the team colour. + * Stores cosmetic teams, their members, colours and each member's glow + * preference. Tags and (optionally) coloured glowing outlines are shown to + * clients via scoreboard-team packets, because Folia forbids runtime scoreboard + * changes through the normal Bukkit API (see {@link TeamGlowPackets}). */ public final class TeamManager { + /** Maximum length of a team's tag as shown after a player's name. */ + public static final int MAX_TAG_LENGTH = 4; private static final List COLORS = List.of( NamedTextColor.BLACK, NamedTextColor.DARK_BLUE, NamedTextColor.DARK_GREEN, NamedTextColor.DARK_AQUA, NamedTextColor.DARK_RED, NamedTextColor.DARK_PURPLE, @@ -37,7 +41,7 @@ public final class TeamManager { private final JavaPlugin plugin; private final File dataFile; - private final Scoreboard scoreboard; + private final TeamGlowPackets packets = new TeamGlowPackets(); private final Map teams = new LinkedHashMap<>(); // team id -> team private final Map teamByMember = new LinkedHashMap<>(); // member -> team id @@ -45,12 +49,12 @@ public final class TeamManager { private final Map joinRequests = new ConcurrentHashMap<>(); // player -> team id private final Map> invites = new ConcurrentHashMap<>(); // team id -> invited players private final Set teamChat = ConcurrentHashMap.newKeySet(); // players with team chat toggled on - private final Set glowOff = ConcurrentHashMap.newKeySet(); // players who turned their own glow off + private final Set glowOn = ConcurrentHashMap.newKeySet(); // players who turned their own glow on + private final Map symbols = new ConcurrentHashMap<>(); // player -> chosen non-no-PvP symbol public TeamManager(JavaPlugin plugin) { this.plugin = plugin; this.dataFile = new File(plugin.getDataFolder(), "teams.yml"); - this.scoreboard = Bukkit.getScoreboardManager().getMainScoreboard(); synchronized (this) { load(); } @@ -61,6 +65,11 @@ public final class TeamManager { return name != null && name.matches("[A-Za-z0-9_]{2,16}"); } + /** Tags are optional and, if set, 2-4 characters. */ + public static boolean isValidTag(String tag) { + return tag != null && tag.matches("[A-Za-z0-9_]{2," + MAX_TAG_LENGTH + "}"); + } + public static List colors() { return COLORS; } @@ -79,12 +88,19 @@ public final class TeamManager { return null; } - /** The {@code [Team]} tag shown in chat and next to the name in the tab list. */ - public static Component tag(String teamName, NamedTextColor color) { - NamedTextColor tagColor = color != null ? color : NamedTextColor.AQUA; - return Component.text("[", NamedTextColor.DARK_GRAY) - .append(Component.text(teamName, tagColor)) - .append(Component.text("] ", NamedTextColor.DARK_GRAY)); + /** + * The coloured {@code [TAG]} shown after a player's name in chat, tab list + * and above their head, or {@code null} when the team has no tag. The label + * is capped at {@link #MAX_TAG_LENGTH} characters. + */ + public static Component suffix(Team team) { + String tag = team.getTag(); + if (tag == null || tag.isEmpty()) { + return null; + } + NamedTextColor color = team.getColor() != null ? team.getColor() : NamedTextColor.AQUA; + return Component.text(" ", NamedTextColor.WHITE) + .append(Component.text("[" + tag + "]", color)); } public synchronized Team teamOf(Player player) { @@ -113,6 +129,7 @@ public final class TeamManager { scheduleSave(); } refreshScoreboard(team.getId()); + refreshTabNames(team.getId()); return team; } @@ -129,12 +146,7 @@ public final class TeamManager { joinRequests.entrySet().removeIf(entry -> entry.getValue().equals(team.getId())); scheduleSave(); } - plugin.getServer().getGlobalRegionScheduler().run(plugin, task -> { - org.bukkit.scoreboard.Team board = scoreboard.getTeam(scoreboardTeamName(team.getId())); - if (board != null) { - board.unregister(); - } - }); + packets.remove(scoreboardTeamName(team.getId())); } public void addMember(Team team, Player player) { @@ -149,6 +161,7 @@ public final class TeamManager { scheduleSave(); } refreshScoreboard(team.getId()); + refreshTabNames(team.getId()); } public void removeMember(Team team, Player player) { @@ -159,6 +172,7 @@ public final class TeamManager { scheduleSave(); } refreshScoreboard(team.getId()); + refreshTabNames(team.getId()); } public void setColor(Team team, NamedTextColor color) { @@ -167,6 +181,16 @@ public final class TeamManager { scheduleSave(); } refreshScoreboard(team.getId()); + refreshTabNames(team.getId()); + } + + public void setTag(Team team, String tag) { + synchronized (this) { + team.setTag(tag); + scheduleSave(); + } + refreshScoreboard(team.getId()); + refreshTabNames(team.getId()); } public void addAdmin(Team team, UUID player) { @@ -189,20 +213,36 @@ public final class TeamManager { */ public boolean toggleGlow(UUID player) { synchronized (this) { - if (glowOff.remove(player)) { + if (glowOn.remove(player)) { scheduleSave(); - return true; + return false; } - glowOff.add(player); + glowOn.add(player); scheduleSave(); - return false; + return true; } } - /** Whether the player should glow: they must be in a team and not have opted out. */ + /** Whether the player should glow: they must be in a team and have opted in. */ public synchronized boolean shouldGlow(Player player) { UUID uuid = player.getUniqueId(); - return teamByMember.containsKey(uuid) && !glowOff.contains(uuid); + return teamByMember.containsKey(uuid) && glowOn.contains(uuid); + } + + /** + * The symbol a player shows while no-PvP is off: their choice via + * {@code /team symbol}, or a netherite axe by default. + */ + public PlayerSymbol.Symbol symbolOf(Player player) { + return symbols.getOrDefault(player.getUniqueId(), PlayerSymbol.Symbol.AXE); + } + + /** Sets (and remembers) a player's chosen non-no-PvP symbol. */ + public void setSymbol(UUID player, PlayerSymbol.Symbol symbol) { + synchronized (this) { + symbols.put(player, symbol); + scheduleSave(); + } } public boolean isTeamChat(UUID player) { @@ -263,16 +303,57 @@ public final class TeamManager { } } + /** + * Updates a player's tab-list name to {@code Name [TAG]} with the tag in the + * team colour. Must run on the player's own thread. + */ + public void applyTabName(Player player) { + Team team; + synchronized (this) { + UUID teamId = teamByMember.get(player.getUniqueId()); + team = teamId == null ? null : teams.get(teamId); + } + if (team == null) { + player.playerListName(Component.text(player.getName(), NamedTextColor.WHITE)); + return; + } + Component name = Component.text(player.getName(), NamedTextColor.WHITE); + Component suffix = suffix(team); + player.playerListName(suffix == null ? name : name.append(suffix)); + } + + /** Re-applies the tab-list name for every online member of a team. */ + public void refreshTabNames(UUID teamId) { + List members = new ArrayList<>(); + synchronized (this) { + Team team = teams.get(teamId); + if (team == null) { + return; + } + for (UUID member : team.getMembers()) { + Player online = Bukkit.getPlayer(member); + if (online != null) { + members.add(online); + } + } + } + for (Player player : members) { + player.getScheduler().run(plugin, task -> { + if (player.isOnline()) { + applyTabName(player); + } + }, null); + } + } + public void refreshAllScoreboards() { List ids; synchronized (this) { ids = new ArrayList<>(teams.keySet()); } - plugin.getServer().getGlobalRegionScheduler().run(plugin, task -> { - for (UUID id : ids) { - refreshScoreboardNow(id); - } - }); + for (UUID id : ids) { + refreshScoreboard(id); + } } private void index(Team team) { @@ -283,13 +364,40 @@ public final class TeamManager { } } + /** + * Rebuilds a team's scoreboard representation. Folia forbids touching the + * main scoreboard from the global region thread, so the update is run on the + * entity scheduler of one of the team's online members (they share the same + * region in practice). If no member is online the team has nobody to render + * for, so the update is deferred until someone joins. + */ private void refreshScoreboard(UUID teamId) { - plugin.getServer().getGlobalRegionScheduler().run(plugin, task -> refreshScoreboardNow(teamId)); + Player carrier = firstOnlineMember(teamId); + if (carrier == null) { + return; + } + carrier.getScheduler().run(plugin, task -> refreshScoreboardNow(teamId), null); + } + + private Player firstOnlineMember(UUID teamId) { + synchronized (this) { + Team team = teams.get(teamId); + if (team == null) { + return null; + } + for (UUID member : team.getMembers()) { + Player online = Bukkit.getPlayer(member); + if (online != null) { + return online; + } + } + } + return null; } private void refreshScoreboardNow(UUID teamId) { NamedTextColor color; - String teamName; + String tag; List names = new ArrayList<>(); synchronized (this) { Team team = teams.get(teamId); @@ -297,7 +405,7 @@ public final class TeamManager { return; } color = team.getColor(); - teamName = team.getName(); + tag = team.getTag(); for (UUID member : team.getMembers()) { Player online = Bukkit.getPlayer(member); if (online != null) { @@ -307,43 +415,86 @@ public final class TeamManager { } String boardName = scoreboardTeamName(teamId); - org.bukkit.scoreboard.Team board = scoreboard.getTeam(boardName); - if (board == null) { - board = scoreboard.registerNewTeam(boardName); - } - board.setAllowFriendlyFire(true); - board.setCanSeeFriendlyInvisibles(true); - board.color(color); - // The prefix is what the client shows next to the name in the tab list - // (and above the player's head). - board.prefix(tag(teamName, color)); + String colorId = color != null ? chatFormattingName(color) : null; + // The server-side glow outline is taken from the team colour. The name + // itself is forced white by the packet helper's prefix, and the tag (if + // the team has one) is appended as a coloured suffix. + packets.apply(boardName, colorId, names, suffixTag(tag, color)); + } - Set wanted = Set.copyOf(names); - for (String entry : new ArrayList<>(board.getEntries())) { - if (!wanted.contains(entry)) { - board.removeEntry(entry); - } - } - for (String name : names) { - if (!board.hasEntry(name)) { - board.addEntry(name); - } + /** + * Legacy-formatted {@code §x[TAG]} shown after a member's name, or an empty + * string when the team has no tag. + */ + public static String suffixTag(String tag, NamedTextColor color) { + if (tag == null || tag.isEmpty()) { + return ""; } + char code = legacyCode(color != null ? color : NamedTextColor.AQUA); + return " §" + code + "[" + tag + "]"; } private String scoreboardTeamName(UUID teamId) { return "qt_" + teamId.toString().replace("-", "").substring(0, 13); } + /** Maps an Adventure colour to its {@code ChatFormatting} enum name. */ + private static String chatFormattingName(NamedTextColor color) { + return color.toString().toUpperCase(Locale.ROOT); + } + + /** Maps an Adventure colour to its legacy section-sign colour code. */ + public static char legacyCode(NamedTextColor color) { + String name = color.toString().toUpperCase(Locale.ROOT); + return switch (name) { + case "BLACK" -> '0'; + case "DARK_BLUE" -> '1'; + case "DARK_GREEN" -> '2'; + case "DARK_AQUA" -> '3'; + case "DARK_RED" -> '4'; + case "DARK_PURPLE" -> '5'; + case "GOLD" -> '6'; + case "GRAY" -> '7'; + case "DARK_GRAY" -> '8'; + case "BLUE" -> '9'; + case "GREEN" -> 'a'; + case "AQUA" -> 'b'; + case "RED" -> 'c'; + case "LIGHT_PURPLE" -> 'd'; + case "YELLOW" -> 'e'; + case "WHITE" -> 'f'; + default -> 'b'; + }; + } + private void load() { if (!dataFile.exists()) { return; } YamlConfiguration config = YamlConfiguration.loadConfiguration(dataFile); + for (String entry : config.getStringList("glow-on")) { + UUID uuid = parseUuid(entry); + if (uuid != null) { + glowOn.add(uuid); + } + } + // Legacy format stored players who had opted OUT (glow was on by default). for (String entry : config.getStringList("glow-off")) { UUID uuid = parseUuid(entry); if (uuid != null) { - glowOff.add(uuid); + glowOn.remove(uuid); + } + } + for (String entry : config.getStringList("symbols")) { + // Entries are ":". + int split = entry.indexOf(':'); + if (split <= 0) { + continue; + } + UUID uuid = parseUuid(entry.substring(0, split)); + PlayerSymbol.Symbol symbol = parseSymbol(entry.substring(split + 1)); + if (uuid != null && symbol != null) { + symbols.put(uuid, symbol); } } ConfigurationSection root = config.getConfigurationSection("teams"); @@ -379,6 +530,10 @@ public final class TeamManager { if (color != null) { team.setColor(color); } + String tag = section.getString("tag"); + if (tag != null && !tag.isEmpty() && isValidTag(tag)) { + team.setTag(tag); + } index(team); } } @@ -399,6 +554,9 @@ public final class TeamManager { String path = team.getId().toString(); root.set(path + ".name", team.getName()); root.set(path + ".owner", team.getOwner().toString()); + if (team.getTag() != null) { + root.set(path + ".tag", team.getTag()); + } if (team.getColor() != null) { root.set(path + ".color", team.getColor().toString()); } @@ -413,11 +571,16 @@ public final class TeamManager { } root.set(path + ".admins", admins); } - List glowOffList = new ArrayList<>(); - for (UUID uuid : glowOff) { - glowOffList.add(uuid.toString()); + List glowOnList = new ArrayList<>(); + for (UUID uuid : glowOn) { + glowOnList.add(uuid.toString()); } - config.set("glow-off", glowOffList); + config.set("glow-on", glowOnList); + List symbolsList = new ArrayList<>(); + for (Map.Entry entry : symbols.entrySet()) { + symbolsList.add(entry.getKey() + ":" + entry.getValue().name().toLowerCase(Locale.ROOT)); + } + config.set("symbols", symbolsList); return config; } @@ -443,4 +606,21 @@ public final class TeamManager { return null; } } + + /** Parses a stored symbol name, ignoring unknown/removed values. */ + private PlayerSymbol.Symbol parseSymbol(String value) { + if (value == null) { + return null; + } + String name = value.trim().toUpperCase(Locale.ROOT); + // "swords" was the old crossed-swords symbol, replaced by the axe. + if (name.equals("SWORDS")) { + return PlayerSymbol.Symbol.AXE; + } + try { + return PlayerSymbol.Symbol.valueOf(name); + } catch (IllegalArgumentException e) { + return null; + } + } } diff --git a/src/main/java/com/dvorakmortonxd/qoltweaks/tnt/TNTLaunch.java b/src/main/java/com/dvorakmortonxd/qoltweaks/tnt/TNTLaunch.java new file mode 100644 index 0000000..1c5c959 --- /dev/null +++ b/src/main/java/com/dvorakmortonxd/qoltweaks/tnt/TNTLaunch.java @@ -0,0 +1,51 @@ +package com.dvorakmortonxd.qoltweaks.tnt; + +import org.bukkit.Location; +import org.bukkit.entity.TNTPrimed; +import org.bukkit.util.Vector; + +/** + * The maths behind knocking primed TNT around when a player punches it. + */ +public final class TNTLaunch { + + /** Velocity applied per shove, horizontally away from the source. */ + public static final double PUSH_STRENGTH = 0.42; + /** Upward component added to each shove so the TNT arcs nicely. */ + public static final double PUSH_LIFT = 0.18; + /** Extra fuse ticks added per shove (1 second). */ + public static final int FUSE_BONUS_TICKS = 20; + /** The fuse will never be extended beyond this (10 seconds). */ + public static final int MAX_FUSE_TICKS = 200; + /** Hard cap so a single shove can't send TNT absurdly fast. */ + public static final double MAX_VELOCITY = 1.4; + + private TNTLaunch() { + } + + /** + * Shoves the TNT away from {@code source} (horizontally, with a small lift) + * and adds a second to its fuse, within the cap. If the source is standing + * inside the TNT the source's facing direction is used instead. + */ + public static void shoveAway(TNTPrimed tnt, Location source, Vector facing) { + Vector away = tnt.getLocation().toVector().subtract(source.toVector()); + away.setY(0); + if (away.lengthSquared() < 1.0e-6) { + away = facing.clone().setY(0); + } + if (away.lengthSquared() < 1.0e-6) { + // Facing straight up/down; just nudge forward on +X. + away = new Vector(1, 0, 0); + } + away = away.normalize().multiply(PUSH_STRENGTH).setY(PUSH_LIFT); + + Vector velocity = tnt.getVelocity().add(away); + if (velocity.length() > MAX_VELOCITY) { + velocity = velocity.normalize().multiply(MAX_VELOCITY); + } + tnt.setVelocity(velocity); + + tnt.setFuseTicks(Math.min(tnt.getFuseTicks() + FUSE_BONUS_TICKS, MAX_FUSE_TICKS)); + } +} diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index b6c31a5..8f7f735 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -16,7 +16,7 @@ commands: usage: /nopvp team: description: Create and manage a cosmetic team whose colour is used for the no-PvP glow. - usage: /team + usage: /team permissions: qoltweaks.nopvp: