rework
This commit is contained in:
parent
e2b7f31caf
commit
21e9bc47ad
15 changed files with 1070 additions and 212 deletions
15
README.md
15
README.md
|
|
@ -5,22 +5,25 @@ A quality-of-life SMP plugin for Paper/Folia.
|
||||||
## Features
|
## 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.
|
- **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.
|
- **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 <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.
|
- **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
|
## Commands
|
||||||
|
|
||||||
| Command | Description | Permission |
|
| Command | Description | Permission |
|
||||||
|--------------------------------|----------------------------------------------------------------------|-------------------|
|
|--------------------------------|----------------------------------------------------------------------|-------------------|
|
||||||
| `/nopvp` | Toggle no-PvP mode ("Pacifist" label + optional team-coloured glow). | `qoltweaks.nopvp` |
|
| `/nopvp` | Toggle no-PvP mode (totem above head while on + optional team-coloured glow). | `qoltweaks.nopvp` |
|
||||||
| `/team create <name>` | Create a team (you become its owner). | `qoltweaks.team` |
|
| `/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 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 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 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 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 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 leave` | Leave your team (owners must delete instead). | `qoltweaks.team` |
|
||||||
| `/team delete` | Disband the whole team (owner only). | `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
|
## 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
|
## Compatibility
|
||||||
|
|
||||||
|
|
|
||||||
46
src/main/java/com/dvorakmortonxd/qoltweaks/ChatTips.java
Normal file
46
src/main/java/com/dvorakmortonxd/qoltweaks/ChatTips.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ package com.dvorakmortonxd.qoltweaks;
|
||||||
import com.dvorakmortonxd.qoltweaks.combatlog.CombatLogListener;
|
import com.dvorakmortonxd.qoltweaks.combatlog.CombatLogListener;
|
||||||
import com.dvorakmortonxd.qoltweaks.combatlog.CombatLogManager;
|
import com.dvorakmortonxd.qoltweaks.combatlog.CombatLogManager;
|
||||||
import com.dvorakmortonxd.qoltweaks.listeners.JoinListener;
|
import com.dvorakmortonxd.qoltweaks.listeners.JoinListener;
|
||||||
|
import com.dvorakmortonxd.qoltweaks.listeners.TNTKnockbackListener;
|
||||||
import com.dvorakmortonxd.qoltweaks.nopvp.NoPvPCommand;
|
import com.dvorakmortonxd.qoltweaks.nopvp.NoPvPCommand;
|
||||||
import com.dvorakmortonxd.qoltweaks.nopvp.NoPvPListener;
|
import com.dvorakmortonxd.qoltweaks.nopvp.NoPvPListener;
|
||||||
import com.dvorakmortonxd.qoltweaks.nopvp.NoPvPManager;
|
import com.dvorakmortonxd.qoltweaks.nopvp.NoPvPManager;
|
||||||
|
|
@ -28,6 +29,7 @@ public final class QoLtweaks extends JavaPlugin {
|
||||||
combatLogManager = new CombatLogManager(this);
|
combatLogManager = new CombatLogManager(this);
|
||||||
|
|
||||||
getServer().getPluginManager().registerEvents(new JoinListener(), 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 TeamListener(teamManager), this);
|
||||||
getServer().getPluginManager().registerEvents(new NoPvPListener(noPvPManager), this);
|
getServer().getPluginManager().registerEvents(new NoPvPListener(noPvPManager), this);
|
||||||
getServer().getPluginManager().registerEvents(new NoPvPProtectionListener(noPvPManager), this);
|
getServer().getPluginManager().registerEvents(new NoPvPProtectionListener(noPvPManager), this);
|
||||||
|
|
@ -35,6 +37,8 @@ public final class QoLtweaks extends JavaPlugin {
|
||||||
|
|
||||||
WoodcutterRecipes.register(this);
|
WoodcutterRecipes.register(this);
|
||||||
|
|
||||||
|
new ChatTips(this);
|
||||||
|
|
||||||
PluginCommand noPvPCommand = getCommand("nopvp");
|
PluginCommand noPvPCommand = getCommand("nopvp");
|
||||||
if (noPvPCommand != null) {
|
if (noPvPCommand != null) {
|
||||||
noPvPCommand.setExecutor(new NoPvPCommand(noPvPManager, combatLogManager));
|
noPvPCommand.setExecutor(new NoPvPCommand(noPvPManager, combatLogManager));
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -32,6 +32,8 @@ public final class NoPvPListener implements Listener {
|
||||||
|
|
||||||
@EventHandler(priority = EventPriority.MONITOR)
|
@EventHandler(priority = EventPriority.MONITOR)
|
||||||
public void onPlayerJoin(PlayerJoinEvent event) {
|
public void onPlayerJoin(PlayerJoinEvent event) {
|
||||||
|
// A first-time player starts with no-PvP on by default.
|
||||||
|
manager.handleJoin(event.getPlayer());
|
||||||
manager.applyState(event.getPlayer());
|
manager.applyState(event.getPlayer());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import org.bukkit.plugin.java.JavaPlugin;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
|
@ -24,15 +25,17 @@ public final class NoPvPManager {
|
||||||
private final JavaPlugin plugin;
|
private final JavaPlugin plugin;
|
||||||
private final TeamManager teamManager;
|
private final TeamManager teamManager;
|
||||||
private final File dataFile;
|
private final File dataFile;
|
||||||
private final PacifistHologram hologram;
|
private final PlayerSymbol symbol;
|
||||||
private final Map<UUID, Boolean> enabled = new ConcurrentHashMap<>();
|
private final Map<UUID, Boolean> enabled = new ConcurrentHashMap<>();
|
||||||
private final Map<UUID, Long> cooldownUntil = 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) {
|
public NoPvPManager(JavaPlugin plugin, TeamManager teamManager) {
|
||||||
this.plugin = plugin;
|
this.plugin = plugin;
|
||||||
this.teamManager = teamManager;
|
this.teamManager = teamManager;
|
||||||
this.dataFile = new File(plugin.getDataFolder(), "nopvp.yml");
|
this.dataFile = new File(plugin.getDataFolder(), "nopvp.yml");
|
||||||
this.hologram = new PacifistHologram(plugin);
|
this.symbol = new PlayerSymbol(plugin, teamManager);
|
||||||
load();
|
load();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -40,6 +43,22 @@ public final class NoPvPManager {
|
||||||
return enabled.getOrDefault(player.getUniqueId(), false);
|
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
|
* 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.
|
* 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
|
* Applies the player's symbol: a totem of undying while no-PvP is on,
|
||||||
* no-PvP is on, plus the team-coloured glow whenever they are in a team and
|
* otherwise their chosen symbol (netherite axe by default), plus the
|
||||||
* haven't turned their own glow off (independent of no-PvP).
|
* 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) {
|
public void applyState(Player player) {
|
||||||
if (isEnabled(player)) {
|
symbol.apply(player, isEnabled(player));
|
||||||
hologram.show(player);
|
|
||||||
} else {
|
|
||||||
hologram.hide(player);
|
|
||||||
}
|
|
||||||
player.setGlowing(teamManager != null && teamManager.shouldGlow(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) {
|
public void clearVisuals(Player player) {
|
||||||
hologram.hide(player);
|
symbol.hide(player);
|
||||||
player.setGlowing(false);
|
player.setGlowing(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -122,7 +138,7 @@ public final class NoPvPManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
public void removeAllVisuals() {
|
public void removeAllVisuals() {
|
||||||
hologram.removeAll();
|
symbol.removeAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void load() {
|
private void load() {
|
||||||
|
|
@ -143,6 +159,7 @@ public final class NoPvPManager {
|
||||||
}
|
}
|
||||||
enabled.put(uuid, section.getBoolean(key + ".enabled", false));
|
enabled.put(uuid, section.getBoolean(key + ".enabled", false));
|
||||||
cooldownUntil.put(uuid, section.getLong(key + ".cooldown-until", 0L));
|
cooldownUntil.put(uuid, section.getLong(key + ".cooldown-until", 0L));
|
||||||
|
known.add(uuid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,9 +8,9 @@ import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A purely cosmetic player team. Membership is tracked by UUID and the team's
|
* A purely cosmetic player team. Membership is tracked by UUID, the team's
|
||||||
* colour is applied to each member's no-PvP glow (which each member can toggle
|
* colour drives each member's glow, and an optional short tag is shown after
|
||||||
* off for themselves).
|
* members' names. Glow is toggled per member.
|
||||||
*/
|
*/
|
||||||
public final class Team {
|
public final class Team {
|
||||||
|
|
||||||
|
|
@ -20,6 +20,8 @@ public final class Team {
|
||||||
private final Set<UUID> members = new LinkedHashSet<>();
|
private final Set<UUID> members = new LinkedHashSet<>();
|
||||||
private final Set<UUID> admins = new LinkedHashSet<>();
|
private final Set<UUID> admins = new LinkedHashSet<>();
|
||||||
private NamedTextColor color;
|
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) {
|
public Team(UUID id, String name, UUID owner) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
|
|
@ -40,6 +42,15 @@ public final class Team {
|
||||||
this.name = name;
|
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() {
|
public UUID getOwner() {
|
||||||
return owner;
|
return owner;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
package com.dvorakmortonxd.qoltweaks.teams;
|
package com.dvorakmortonxd.qoltweaks.teams;
|
||||||
|
|
||||||
import com.dvorakmortonxd.qoltweaks.nopvp.NoPvPManager;
|
import com.dvorakmortonxd.qoltweaks.nopvp.NoPvPManager;
|
||||||
|
import com.dvorakmortonxd.qoltweaks.nopvp.PlayerSymbol;
|
||||||
import net.kyori.adventure.text.Component;
|
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 net.kyori.adventure.text.format.NamedTextColor;
|
||||||
import org.bukkit.Bukkit;
|
import org.bukkit.Bukkit;
|
||||||
import org.bukkit.command.Command;
|
import org.bukkit.command.Command;
|
||||||
|
|
@ -18,7 +21,7 @@ import java.util.UUID;
|
||||||
public final class TeamCommand implements CommandExecutor, TabCompleter {
|
public final class TeamCommand implements CommandExecutor, TabCompleter {
|
||||||
|
|
||||||
private static final List<String> SUBCOMMANDS = List.of(
|
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 TeamManager manager;
|
||||||
private final NoPvPManager noPvPManager;
|
private final NoPvPManager noPvPManager;
|
||||||
|
|
@ -53,6 +56,8 @@ public final class TeamCommand implements CommandExecutor, TabCompleter {
|
||||||
case "admin" -> admin(player, args);
|
case "admin" -> admin(player, args);
|
||||||
case "glow" -> glow(player);
|
case "glow" -> glow(player);
|
||||||
case "chat" -> chat(player, args);
|
case "chat" -> chat(player, args);
|
||||||
|
case "symbol" -> symbol(player, args);
|
||||||
|
case "tag" -> tag(player, args);
|
||||||
default -> sendUsage(player);
|
default -> sendUsage(player);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -144,8 +149,13 @@ public final class TeamCommand implements CommandExecutor, TabCompleter {
|
||||||
|
|
||||||
manager.addInvite(team, target.getUniqueId());
|
manager.addInvite(team, target.getUniqueId());
|
||||||
player.sendMessage("§aInvited §e" + target.getName() + "§a to the team.");
|
player.sendMessage("§aInvited §e" + target.getName() + "§a to the team.");
|
||||||
target.sendMessage("§e" + player.getName() + "§a invited you to team §e" + team.getName()
|
target.sendMessage(Component.text()
|
||||||
+ "§a. Use §e/team join " + team.getName() + "§a to accept.");
|
.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) {
|
private void leave(Player player) {
|
||||||
|
|
@ -196,8 +206,11 @@ public final class TeamCommand implements CommandExecutor, TabCompleter {
|
||||||
|
|
||||||
manager.addJoinRequest(team, player.getUniqueId());
|
manager.addJoinRequest(team, player.getUniqueId());
|
||||||
player.sendMessage("§aJoin request sent to team §e" + team.getName() + "§a.");
|
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 "
|
notifyAdmins(team, Component.text()
|
||||||
+ player.getName() + "§a to accept.");
|
.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) {
|
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);
|
String input = args[1].toLowerCase(Locale.ROOT);
|
||||||
if (input.equals("none") || input.equals("reset") || input.equals("clear")) {
|
if (input.equals("none") || input.equals("reset") || input.equals("clear")) {
|
||||||
manager.setColor(team, null);
|
manager.setColor(team, null);
|
||||||
|
refreshMembers(team);
|
||||||
player.sendMessage("§aTeam colour cleared.");
|
player.sendMessage("§aTeam colour cleared.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -229,9 +243,23 @@ public final class TeamCommand implements CommandExecutor, TabCompleter {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
manager.setColor(team, color);
|
manager.setColor(team, color);
|
||||||
|
refreshMembers(team);
|
||||||
player.sendMessage("§aTeam colour set to §e" + color);
|
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) {
|
private void admin(Player player, String[] args) {
|
||||||
if (args.length < 2) {
|
if (args.length < 2) {
|
||||||
player.sendMessage("§cUsage: /team admin <add|remove> <player>");
|
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.");
|
: "§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) {
|
private void chat(Player player, String[] args) {
|
||||||
Team team = manager.teamOf(player);
|
Team team = manager.teamOf(player);
|
||||||
if (team == null) {
|
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) {
|
private void sendUsage(Player player) {
|
||||||
player.sendMessage("§6§lTeam commands");
|
player.sendMessage("§6§lTeam commands");
|
||||||
player.sendMessage("§e/team create <name> §7- make a team");
|
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 color <color> §7- set the team's glow colour");
|
||||||
player.sendMessage("§e/team admin <add|remove> <player> §7- manage admins");
|
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 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 chat [message] §7- team chat / toggle it");
|
||||||
player.sendMessage("§e/team leave §7- leave your team");
|
player.sendMessage("§e/team leave §7- leave your team");
|
||||||
player.sendMessage("§e/team delete §7- disband your team (owner)");
|
player.sendMessage("§e/team delete §7- disband your team (owner)");
|
||||||
|
|
@ -396,6 +532,12 @@ public final class TeamCommand implements CommandExecutor, TabCompleter {
|
||||||
case "admin" -> {
|
case "admin" -> {
|
||||||
return filter(List.of("add", "remove"), args[1]);
|
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 -> {
|
default -> {
|
||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -24,13 +24,14 @@ public final class TeamListener implements Listener {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs at NORMAL so the scoreboard team (and thus glow colour) is applied
|
* Runs at NORMAL so the team tag/glow team is applied before
|
||||||
* before {@code NoPvPListener} turns the glow on at MONITOR.
|
* {@code NoPvPListener} turns the glow on at MONITOR.
|
||||||
*/
|
*/
|
||||||
@EventHandler(priority = EventPriority.NORMAL)
|
@EventHandler(priority = EventPriority.NORMAL)
|
||||||
public void onPlayerJoin(PlayerJoinEvent event) {
|
public void onPlayerJoin(PlayerJoinEvent event) {
|
||||||
Player player = event.getPlayer();
|
Player player = event.getPlayer();
|
||||||
manager.syncPlayer(player);
|
manager.syncPlayer(player);
|
||||||
|
manager.applyTabName(player);
|
||||||
|
|
||||||
Team team = manager.teamOf(player);
|
Team team = manager.teamOf(player);
|
||||||
if (team != null && team.isAdmin(player.getUniqueId())) {
|
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
|
* 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)
|
@EventHandler(priority = EventPriority.LOWEST)
|
||||||
public void onChat(AsyncChatEvent event) {
|
public void onChat(AsyncChatEvent event) {
|
||||||
|
|
@ -64,12 +65,15 @@ public final class TeamListener implements Listener {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
NamedTextColor color = team.getColor() != null ? team.getColor() : NamedTextColor.AQUA;
|
Component suffix = TeamManager.suffix(team);
|
||||||
Component tag = TeamManager.tag(team.getName(), color);
|
if (suffix == null) {
|
||||||
Component name = Component.text(player.getName(), color);
|
return;
|
||||||
|
}
|
||||||
|
Component name = Component.text(player.getName(), NamedTextColor.WHITE);
|
||||||
event.renderer((source, sourceDisplayName, message, viewer) ->
|
event.renderer((source, sourceDisplayName, message, viewer) ->
|
||||||
tag.append(Component.text("<", NamedTextColor.WHITE))
|
Component.text("<", NamedTextColor.WHITE)
|
||||||
.append(name)
|
.append(name)
|
||||||
|
.append(suffix)
|
||||||
.append(Component.text("> ", NamedTextColor.WHITE))
|
.append(Component.text("> ", NamedTextColor.WHITE))
|
||||||
.append(message.colorIfAbsent(NamedTextColor.WHITE)));
|
.append(message.colorIfAbsent(NamedTextColor.WHITE)));
|
||||||
}
|
}
|
||||||
|
|
@ -89,7 +93,8 @@ public final class TeamListener implements Listener {
|
||||||
Component.text("[", NamedTextColor.DARK_GRAY)
|
Component.text("[", NamedTextColor.DARK_GRAY)
|
||||||
.append(Component.text(team.getName(), color))
|
.append(Component.text(team.getName(), color))
|
||||||
.append(Component.text("] ", NamedTextColor.DARK_GRAY))
|
.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)));
|
.append(message.colorIfAbsent(NamedTextColor.WHITE)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.dvorakmortonxd.qoltweaks.teams;
|
package com.dvorakmortonxd.qoltweaks.teams;
|
||||||
|
|
||||||
|
import com.dvorakmortonxd.qoltweaks.nopvp.PlayerSymbol;
|
||||||
import net.kyori.adventure.text.Component;
|
import net.kyori.adventure.text.Component;
|
||||||
import net.kyori.adventure.text.format.NamedTextColor;
|
import net.kyori.adventure.text.format.NamedTextColor;
|
||||||
import org.bukkit.Bukkit;
|
import org.bukkit.Bukkit;
|
||||||
|
|
@ -7,7 +8,6 @@ import org.bukkit.configuration.ConfigurationSection;
|
||||||
import org.bukkit.configuration.file.YamlConfiguration;
|
import org.bukkit.configuration.file.YamlConfiguration;
|
||||||
import org.bukkit.entity.Player;
|
import org.bukkit.entity.Player;
|
||||||
import org.bukkit.plugin.java.JavaPlugin;
|
import org.bukkit.plugin.java.JavaPlugin;
|
||||||
import org.bukkit.scoreboard.Scoreboard;
|
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
|
@ -22,11 +22,15 @@ import java.util.UUID;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stores cosmetic teams, their members and colours, and mirrors them onto the
|
* Stores cosmetic teams, their members, colours and each member's glow
|
||||||
* main scoreboard so the no-PvP glow picks up the team colour.
|
* 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 {
|
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(
|
private static final List<NamedTextColor> COLORS = List.of(
|
||||||
NamedTextColor.BLACK, NamedTextColor.DARK_BLUE, NamedTextColor.DARK_GREEN,
|
NamedTextColor.BLACK, NamedTextColor.DARK_BLUE, NamedTextColor.DARK_GREEN,
|
||||||
NamedTextColor.DARK_AQUA, NamedTextColor.DARK_RED, NamedTextColor.DARK_PURPLE,
|
NamedTextColor.DARK_AQUA, NamedTextColor.DARK_RED, NamedTextColor.DARK_PURPLE,
|
||||||
|
|
@ -37,7 +41,7 @@ public final class TeamManager {
|
||||||
|
|
||||||
private final JavaPlugin plugin;
|
private final JavaPlugin plugin;
|
||||||
private final File dataFile;
|
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, Team> teams = new LinkedHashMap<>(); // team id -> team
|
||||||
private final Map<UUID, UUID> teamByMember = new LinkedHashMap<>(); // member -> team id
|
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, UUID> joinRequests = new ConcurrentHashMap<>(); // player -> team id
|
||||||
private final Map<UUID, Set<UUID>> invites = new ConcurrentHashMap<>(); // team id -> invited players
|
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> 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) {
|
public TeamManager(JavaPlugin plugin) {
|
||||||
this.plugin = plugin;
|
this.plugin = plugin;
|
||||||
this.dataFile = new File(plugin.getDataFolder(), "teams.yml");
|
this.dataFile = new File(plugin.getDataFolder(), "teams.yml");
|
||||||
this.scoreboard = Bukkit.getScoreboardManager().getMainScoreboard();
|
|
||||||
synchronized (this) {
|
synchronized (this) {
|
||||||
load();
|
load();
|
||||||
}
|
}
|
||||||
|
|
@ -61,6 +65,11 @@ public final class TeamManager {
|
||||||
return name != null && name.matches("[A-Za-z0-9_]{2,16}");
|
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() {
|
public static List<NamedTextColor> colors() {
|
||||||
return COLORS;
|
return COLORS;
|
||||||
}
|
}
|
||||||
|
|
@ -79,12 +88,19 @@ public final class TeamManager {
|
||||||
return null;
|
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) {
|
* The coloured {@code [TAG]} shown after a player's name in chat, tab list
|
||||||
NamedTextColor tagColor = color != null ? color : NamedTextColor.AQUA;
|
* and above their head, or {@code null} when the team has no tag. The label
|
||||||
return Component.text("[", NamedTextColor.DARK_GRAY)
|
* is capped at {@link #MAX_TAG_LENGTH} characters.
|
||||||
.append(Component.text(teamName, tagColor))
|
*/
|
||||||
.append(Component.text("] ", NamedTextColor.DARK_GRAY));
|
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) {
|
public synchronized Team teamOf(Player player) {
|
||||||
|
|
@ -113,6 +129,7 @@ public final class TeamManager {
|
||||||
scheduleSave();
|
scheduleSave();
|
||||||
}
|
}
|
||||||
refreshScoreboard(team.getId());
|
refreshScoreboard(team.getId());
|
||||||
|
refreshTabNames(team.getId());
|
||||||
return team;
|
return team;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -129,12 +146,7 @@ public final class TeamManager {
|
||||||
joinRequests.entrySet().removeIf(entry -> entry.getValue().equals(team.getId()));
|
joinRequests.entrySet().removeIf(entry -> entry.getValue().equals(team.getId()));
|
||||||
scheduleSave();
|
scheduleSave();
|
||||||
}
|
}
|
||||||
plugin.getServer().getGlobalRegionScheduler().run(plugin, task -> {
|
packets.remove(scoreboardTeamName(team.getId()));
|
||||||
org.bukkit.scoreboard.Team board = scoreboard.getTeam(scoreboardTeamName(team.getId()));
|
|
||||||
if (board != null) {
|
|
||||||
board.unregister();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void addMember(Team team, Player player) {
|
public void addMember(Team team, Player player) {
|
||||||
|
|
@ -149,6 +161,7 @@ public final class TeamManager {
|
||||||
scheduleSave();
|
scheduleSave();
|
||||||
}
|
}
|
||||||
refreshScoreboard(team.getId());
|
refreshScoreboard(team.getId());
|
||||||
|
refreshTabNames(team.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
public void removeMember(Team team, Player player) {
|
public void removeMember(Team team, Player player) {
|
||||||
|
|
@ -159,6 +172,7 @@ public final class TeamManager {
|
||||||
scheduleSave();
|
scheduleSave();
|
||||||
}
|
}
|
||||||
refreshScoreboard(team.getId());
|
refreshScoreboard(team.getId());
|
||||||
|
refreshTabNames(team.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setColor(Team team, NamedTextColor color) {
|
public void setColor(Team team, NamedTextColor color) {
|
||||||
|
|
@ -167,6 +181,16 @@ public final class TeamManager {
|
||||||
scheduleSave();
|
scheduleSave();
|
||||||
}
|
}
|
||||||
refreshScoreboard(team.getId());
|
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) {
|
public void addAdmin(Team team, UUID player) {
|
||||||
|
|
@ -189,20 +213,36 @@ public final class TeamManager {
|
||||||
*/
|
*/
|
||||||
public boolean toggleGlow(UUID player) {
|
public boolean toggleGlow(UUID player) {
|
||||||
synchronized (this) {
|
synchronized (this) {
|
||||||
if (glowOff.remove(player)) {
|
if (glowOn.remove(player)) {
|
||||||
scheduleSave();
|
scheduleSave();
|
||||||
return true;
|
return false;
|
||||||
}
|
}
|
||||||
glowOff.add(player);
|
glowOn.add(player);
|
||||||
scheduleSave();
|
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) {
|
public synchronized boolean shouldGlow(Player player) {
|
||||||
UUID uuid = player.getUniqueId();
|
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) {
|
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() {
|
public void refreshAllScoreboards() {
|
||||||
List<UUID> ids;
|
List<UUID> ids;
|
||||||
synchronized (this) {
|
synchronized (this) {
|
||||||
ids = new ArrayList<>(teams.keySet());
|
ids = new ArrayList<>(teams.keySet());
|
||||||
}
|
}
|
||||||
plugin.getServer().getGlobalRegionScheduler().run(plugin, task -> {
|
for (UUID id : ids) {
|
||||||
for (UUID id : ids) {
|
refreshScoreboard(id);
|
||||||
refreshScoreboardNow(id);
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void index(Team team) {
|
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) {
|
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) {
|
private void refreshScoreboardNow(UUID teamId) {
|
||||||
NamedTextColor color;
|
NamedTextColor color;
|
||||||
String teamName;
|
String tag;
|
||||||
List<String> names = new ArrayList<>();
|
List<String> names = new ArrayList<>();
|
||||||
synchronized (this) {
|
synchronized (this) {
|
||||||
Team team = teams.get(teamId);
|
Team team = teams.get(teamId);
|
||||||
|
|
@ -297,7 +405,7 @@ public final class TeamManager {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
color = team.getColor();
|
color = team.getColor();
|
||||||
teamName = team.getName();
|
tag = team.getTag();
|
||||||
for (UUID member : team.getMembers()) {
|
for (UUID member : team.getMembers()) {
|
||||||
Player online = Bukkit.getPlayer(member);
|
Player online = Bukkit.getPlayer(member);
|
||||||
if (online != null) {
|
if (online != null) {
|
||||||
|
|
@ -307,43 +415,86 @@ public final class TeamManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
String boardName = scoreboardTeamName(teamId);
|
String boardName = scoreboardTeamName(teamId);
|
||||||
org.bukkit.scoreboard.Team board = scoreboard.getTeam(boardName);
|
String colorId = color != null ? chatFormattingName(color) : null;
|
||||||
if (board == null) {
|
// The server-side glow outline is taken from the team colour. The name
|
||||||
board = scoreboard.registerNewTeam(boardName);
|
// itself is forced white by the packet helper's prefix, and the tag (if
|
||||||
}
|
// the team has one) is appended as a coloured suffix.
|
||||||
board.setAllowFriendlyFire(true);
|
packets.apply(boardName, colorId, names, suffixTag(tag, color));
|
||||||
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));
|
|
||||||
|
|
||||||
Set<String> wanted = Set.copyOf(names);
|
/**
|
||||||
for (String entry : new ArrayList<>(board.getEntries())) {
|
* Legacy-formatted {@code §x[TAG]} shown after a member's name, or an empty
|
||||||
if (!wanted.contains(entry)) {
|
* string when the team has no tag.
|
||||||
board.removeEntry(entry);
|
*/
|
||||||
}
|
public static String suffixTag(String tag, NamedTextColor color) {
|
||||||
}
|
if (tag == null || tag.isEmpty()) {
|
||||||
for (String name : names) {
|
return "";
|
||||||
if (!board.hasEntry(name)) {
|
|
||||||
board.addEntry(name);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
char code = legacyCode(color != null ? color : NamedTextColor.AQUA);
|
||||||
|
return " §" + code + "[" + tag + "]";
|
||||||
}
|
}
|
||||||
|
|
||||||
private String scoreboardTeamName(UUID teamId) {
|
private String scoreboardTeamName(UUID teamId) {
|
||||||
return "qt_" + teamId.toString().replace("-", "").substring(0, 13);
|
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() {
|
private void load() {
|
||||||
if (!dataFile.exists()) {
|
if (!dataFile.exists()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
YamlConfiguration config = YamlConfiguration.loadConfiguration(dataFile);
|
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")) {
|
for (String entry : config.getStringList("glow-off")) {
|
||||||
UUID uuid = parseUuid(entry);
|
UUID uuid = parseUuid(entry);
|
||||||
if (uuid != null) {
|
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");
|
ConfigurationSection root = config.getConfigurationSection("teams");
|
||||||
|
|
@ -379,6 +530,10 @@ public final class TeamManager {
|
||||||
if (color != null) {
|
if (color != null) {
|
||||||
team.setColor(color);
|
team.setColor(color);
|
||||||
}
|
}
|
||||||
|
String tag = section.getString("tag");
|
||||||
|
if (tag != null && !tag.isEmpty() && isValidTag(tag)) {
|
||||||
|
team.setTag(tag);
|
||||||
|
}
|
||||||
index(team);
|
index(team);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -399,6 +554,9 @@ public final class TeamManager {
|
||||||
String path = team.getId().toString();
|
String path = team.getId().toString();
|
||||||
root.set(path + ".name", team.getName());
|
root.set(path + ".name", team.getName());
|
||||||
root.set(path + ".owner", team.getOwner().toString());
|
root.set(path + ".owner", team.getOwner().toString());
|
||||||
|
if (team.getTag() != null) {
|
||||||
|
root.set(path + ".tag", team.getTag());
|
||||||
|
}
|
||||||
if (team.getColor() != null) {
|
if (team.getColor() != null) {
|
||||||
root.set(path + ".color", team.getColor().toString());
|
root.set(path + ".color", team.getColor().toString());
|
||||||
}
|
}
|
||||||
|
|
@ -413,11 +571,16 @@ public final class TeamManager {
|
||||||
}
|
}
|
||||||
root.set(path + ".admins", admins);
|
root.set(path + ".admins", admins);
|
||||||
}
|
}
|
||||||
List<String> glowOffList = new ArrayList<>();
|
List<String> glowOnList = new ArrayList<>();
|
||||||
for (UUID uuid : glowOff) {
|
for (UUID uuid : glowOn) {
|
||||||
glowOffList.add(uuid.toString());
|
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;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -443,4 +606,21 @@ public final class TeamManager {
|
||||||
return null;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,7 +16,7 @@ commands:
|
||||||
usage: /nopvp
|
usage: /nopvp
|
||||||
team:
|
team:
|
||||||
description: Create and manage a cosmetic team whose colour is used for the no-PvP glow.
|
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:
|
permissions:
|
||||||
qoltweaks.nopvp:
|
qoltweaks.nopvp:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue