This commit is contained in:
Martin Dvořák 2026-10-04 18:42:18 +02:00
commit 21e9bc47ad
15 changed files with 1070 additions and 212 deletions

View file

@ -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 &lt;player&gt; 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 <wither|axe>`. 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 <name>` | 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 <name>` | Create a team; the name (max 4 chars) is the tag. | `qoltweaks.team` |
| `/team invite <p>` | Invite an online player, or accept their join request (owner/admin). | `qoltweaks.team` |
| `/team join <team>` | Accept an invite, or send a join request to the team. | `qoltweaks.team` |
| `/team color <clr>` | Set the team glow colour, or `none` to clear (owner/admin). | `qoltweaks.team` |
| `/team admin <add\|remove> <p>`| 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 <wither\|axe>` | 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

View file

@ -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<String> 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);
}
}
}

View file

@ -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));

View file

@ -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.
* <p>
* 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.
* <p>
* 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<java.util.UUID, Long> 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());
}
}

View file

@ -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());
}

View file

@ -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<UUID, Boolean> enabled = new ConcurrentHashMap<>();
private final Map<UUID, Long> cooldownUntil = new ConcurrentHashMap<>();
/** Players we've seen before, so first-timers can be opted into no-PvP by default. */
private final Set<UUID> 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);
}
}

View file

@ -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.
* <p>
* 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<UUID, TextDisplay> 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<UUID, TextDisplay> 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.
}
}
}

View file

@ -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.
* <p>
* A player with no-PvP enabled always shows a <b>totem of undying</b>. A player
* with no-PvP disabled shows the symbol they picked with
* {@code /team symbol <wither|axe>} — a <b>wither skeleton skull</b> or a
* <b>netherite axe</b> — defaulting to the axe for players who haven't chosen
* one. The totem glows green; the wither skull and axe glow red.
* <p>
* 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<UUID, SymbolEntities> 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<UUID, SymbolEntities> 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<ItemDisplay> 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;
}
}
}

View file

@ -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<UUID> members = new LinkedHashSet<>();
private final Set<UUID> 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;
}

View file

@ -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<String> 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 <add|remove> <player>");
@ -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 <wither|axe>");
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 <tag|none>");
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 <team>}.
*/
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 <player>}.
*/
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 <name> §7- make a team");
@ -352,6 +486,8 @@ public final class TeamCommand implements CommandExecutor, TabCompleter {
player.sendMessage("§e/team color <color> §7- set the team's glow colour");
player.sendMessage("§e/team admin <add|remove> <player> §7- manage admins");
player.sendMessage("§e/team glow §7- toggle your own glow");
player.sendMessage("§e/team symbol <wither|axe> §7- the symbol above your head when no-PvP is off");
player.sendMessage("§e/team tag <tag|none> §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();
}

View file

@ -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.
* <p>
* 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.
* <p>
* 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.
* <p>
* 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<String> 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;
}
}

View file

@ -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)));
}
}

View file

@ -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<NamedTextColor> 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<UUID, Team> teams = new LinkedHashMap<>(); // team id -> team
private final Map<UUID, UUID> teamByMember = new LinkedHashMap<>(); // member -> team id
@ -45,12 +49,12 @@ public final class TeamManager {
private final Map<UUID, UUID> joinRequests = new ConcurrentHashMap<>(); // player -> team id
private final Map<UUID, Set<UUID>> invites = new ConcurrentHashMap<>(); // team id -> invited players
private final Set<UUID> teamChat = ConcurrentHashMap.newKeySet(); // players with team chat toggled on
private final Set<UUID> glowOff = ConcurrentHashMap.newKeySet(); // players who turned their own glow off
private final Set<UUID> glowOn = ConcurrentHashMap.newKeySet(); // players who turned their own glow on
private final Map<UUID, PlayerSymbol.Symbol> 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<NamedTextColor> 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<Player> 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<UUID> 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<String> 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<String> 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 "<uuid>:<symbol>".
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<String> glowOffList = new ArrayList<>();
for (UUID uuid : glowOff) {
glowOffList.add(uuid.toString());
List<String> glowOnList = new ArrayList<>();
for (UUID uuid : glowOn) {
glowOnList.add(uuid.toString());
}
config.set("glow-off", glowOffList);
config.set("glow-on", glowOnList);
List<String> symbolsList = new ArrayList<>();
for (Map.Entry<UUID, PlayerSymbol.Symbol> 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;
}
}
}

View file

@ -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));
}
}

View file

@ -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 <create|delete|invite|leave|join|color>
usage: /team <create|delete|invite|leave|join|color|admin|glow|chat|symbol|tag>
permissions:
qoltweaks.nopvp: