feat: Add advanced helmet height rules configuration and logic for dynamic nametag positioning

This commit is contained in:
AlexDev_ committed 2026-03-20 16:36:50 +01:00
1 parent 385f505c99
commit 3e1aa9d39e
9 files changed
+259 -4

No files matched your search

+3
View File
@@ -73,6 +73,9 @@ Enhance your server's customization by tailoring player name tags, integrating d
5. **Configure**
Customize the `settings.yml` file located in the plugin's folder to suit your server’s needs.
6. **Optional `advanced.yml`**
For manual helmet → nametag height rules (e.g. ItemsAdder / custom CMD without relying on resource-pack parsing), copy [`advanced.example.yml`](advanced.example.yml) to `plugins/UnlimitedNameTags/advanced.yml` and adjust. The file is never created automatically; `/unt reload` reloads it when present.
---
## 🛠️ **Commands**
+26
View File
@@ -0,0 +1,26 @@
# Copy to plugins/UnlimitedNameTags/advanced.yml and edit.
# Optional: if this file is missing, the plugin behaves as before.
# Keys use camelCase (ConfigLib default).
helmetHeightRules:
# Example: custom head with a specific CustomModelData
- priority: 10
height: 28
material: PLAYER_HEAD
customModelData: 12345
# Example: any leather helmet with CMD in a range (inclusive)
- priority: 5
height: 20
material: LEATHER_HELMET
customModelDataMin: 1000
customModelDataMax: 1999
# Example: equippable item model (1.21.3+)
- priority: 8
height: 32
equippableModel: "mypack:item/tall_hat"
# Optional filters on any rule:
# worlds: [world, world_nether]
# permission: "myserver.bighelmet"
@@ -186,6 +186,8 @@ public final class UnlimitedNameTags extends JavaPlugin {
}
private void loadHooks() {
hatHooks.add(new AdvancedHatHook(this));
if (Bukkit.getPluginManager().isPluginEnabled("TypeWriter")) {
hooks.put(TypeWriterListener.class, new TypeWriterListener(this));
getLogger().info("TypeWriter found, hooking into it");
@@ -0,0 +1,62 @@
package org.alexdev.unlimitednametags.config;
import de.exlll.configlib.Comment;
import de.exlll.configlib.Configuration;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.ArrayList;
import java.util.List;
@Configuration
@SuppressWarnings({"FieldMayBeFinal", "FieldCanBeLocal"})
@Getter
public class Advanced {
@Comment({
"Optional rules to raise the nametag when the worn helmet matches.",
"Higher priority is evaluated first. The first matching rule wins (same behavior as other hat hooks).",
"Create advanced.yml in the plugin folder manually; it is never auto-generated."
})
private List<HelmetHeightRule> helmetHeightRules = new ArrayList<>();
@Configuration
@Getter
@NoArgsConstructor
public static class HelmetHeightRule {
@Comment("Higher value = checked before lower priority rules.")
private int priority;
@Comment("Vertical offset (same unit as Nexo/Oraxen hat hooks). Must be > 0.")
private double height;
@Comment("Bukkit material name, e.g. LEATHER_HELMET. Omit to ignore material.")
private String material;
@Comment("Exact CustomModelData. Ignored if custom-model-data range below is set.")
private Integer customModelData;
@Comment("Inclusive CMD range; both must be set to use a range.")
private Integer customModelDataMin;
private Integer customModelDataMax;
@Comment("Equippable model key (1.21.3+), e.g. mypack:item/my_hat")
private String equippableModel;
@Comment("If non-empty, the player must be in one of these world names.")
private List<String> worlds = new ArrayList<>();
@Comment("If set, the player must have this permission.")
private String permission;
public boolean definesItemMatch() {
final boolean mat = material != null && !material.isEmpty();
final boolean eq = equippableModel != null && !equippableModel.isEmpty();
final boolean cmdExact = customModelData != null;
final boolean cmdRange = customModelDataMin != null && customModelDataMax != null;
return mat || eq || cmdExact || cmdRange;
}
}
}
@@ -7,13 +7,16 @@ import de.exlll.configlib.YamlConfigurations;
import lombok.Getter;
import org.alexdev.unlimitednametags.UnlimitedNameTags;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.Material;
import org.jetbrains.annotations.NotNull;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.logging.Level;
@Getter
public class ConfigManager {
@@ -27,6 +30,7 @@ public class ConfigManager {
private final UnlimitedNameTags plugin;
private Settings settings;
private Advanced advanced = new Advanced();
private boolean compiled;
public ConfigManager(@NotNull UnlimitedNameTags plugin) {
@@ -55,6 +59,7 @@ public class ConfigManager {
PROPERTIES
);
checkData();
loadAdvancedConfig(false);
return Optional.empty();
} catch (Exception e) {
return Optional.of(e);
@@ -64,6 +69,58 @@ public class ConfigManager {
public void reload() {
settings = YamlConfigurations.load(new File(plugin.getDataFolder(), "settings.yml").toPath(), Settings.class, PROPERTIES);
checkData();
loadAdvancedConfig(true);
}
@NotNull
public Advanced getAdvanced() {
return advanced;
}
private void loadAdvancedConfig(boolean keepPreviousOnFailure) {
final File file = new File(plugin.getDataFolder(), "advanced.yml");
if (!file.exists()) {
advanced = new Advanced();
return;
}
try {
advanced = YamlConfigurations.load(file.toPath(), Advanced.class, PROPERTIES);
validateAdvancedRules();
plugin.getLogger().info("Loaded optional advanced.yml");
} catch (Exception e) {
plugin.getLogger().log(Level.WARNING, "Failed to load advanced.yml", e);
if (!keepPreviousOnFailure) {
advanced = new Advanced();
}
}
}
private void validateAdvancedRules() {
final List<Advanced.HelmetHeightRule> rules = advanced.getHelmetHeightRules();
if (rules == null) {
return;
}
for (int i = 0; i < rules.size(); i++) {
final Advanced.HelmetHeightRule rule = rules.get(i);
if (!rule.definesItemMatch()) {
plugin.getLogger().warning("advanced.yml helmetHeightRules[" + i + "] has no item matcher; it will never apply.");
}
if (rule.getHeight() <= 0) {
plugin.getLogger().warning("advanced.yml helmetHeightRules[" + i + "] height must be > 0; it will never apply.");
}
final boolean minSet = rule.getCustomModelDataMin() != null;
final boolean maxSet = rule.getCustomModelDataMax() != null;
if (minSet != maxSet) {
plugin.getLogger().warning("advanced.yml helmetHeightRules[" + i + "] must set both customModelDataMin and customModelDataMax for a CMD range.");
}
if (minSet && maxSet && rule.getCustomModelData() != null) {
plugin.getLogger().warning("advanced.yml helmetHeightRules[" + i + "] customModelData is ignored when customModelDataMin/Max range is set.");
}
if (rule.getMaterial() != null && !rule.getMaterial().isEmpty()
&& Material.matchMaterial(rule.getMaterial(), false) == null) {
plugin.getLogger().warning("advanced.yml helmetHeightRules[" + i + "] unknown material: " + rule.getMaterial());
}
}
}
private void checkData() {
@@ -0,0 +1,98 @@
package org.alexdev.unlimitednametags.hook;
import org.alexdev.unlimitednametags.UnlimitedNameTags;
import org.alexdev.unlimitednametags.config.Advanced;
import org.alexdev.unlimitednametags.hook.hat.HatHook;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.jetbrains.annotations.NotNull;
import java.util.Comparator;
import java.util.List;
public final class AdvancedHatHook implements HatHook {
private final UnlimitedNameTags plugin;
public AdvancedHatHook(@NotNull UnlimitedNameTags plugin) {
this.plugin = plugin;
}
@Override
public double getHigh(@NotNull Player player) {
final List<Advanced.HelmetHeightRule> rules = plugin.getConfigManager().getAdvanced().getHelmetHeightRules();
if (rules == null || rules.isEmpty()) {
return 0;
}
final ItemStack helmet = player.getInventory().getHelmet();
if (helmet == null || helmet.getType().isAir()) {
return 0;
}
return rules.stream()
.sorted(Comparator.comparingInt(Advanced.HelmetHeightRule::getPriority).reversed())
.filter(rule -> rule.getHeight() > 0)
.filter(Advanced.HelmetHeightRule::definesItemMatch)
.filter(rule -> matches(rule, player, helmet))
.mapToDouble(Advanced.HelmetHeightRule::getHeight)
.findFirst()
.orElse(0);
}
private boolean matches(@NotNull Advanced.HelmetHeightRule rule, @NotNull Player player, @NotNull ItemStack helmet) {
if (rule.getPermission() != null && !rule.getPermission().isEmpty() && !player.hasPermission(rule.getPermission())) {
return false;
}
if (rule.getWorlds() != null && !rule.getWorlds().isEmpty()
&& !rule.getWorlds().contains(player.getWorld().getName())) {
return false;
}
if (rule.getMaterial() != null && !rule.getMaterial().isEmpty()) {
final Material mat = Material.matchMaterial(rule.getMaterial(), false);
if (mat == null || helmet.getType() != mat) {
return false;
}
}
if (rule.getEquippableModel() != null && !rule.getEquippableModel().isEmpty()) {
final ItemMeta meta = helmet.getItemMeta();
if (meta == null || !meta.hasEquippable()) {
return false;
}
final NamespacedKey modelKey = meta.getEquippable().getModel();
if (modelKey == null) {
return false;
}
final NamespacedKey wanted = NamespacedKey.fromString(rule.getEquippableModel());
if (wanted == null || !wanted.equals(modelKey)) {
return false;
}
}
final boolean wantsRange = rule.getCustomModelDataMin() != null && rule.getCustomModelDataMax() != null;
if (wantsRange) {
final ItemMeta meta = helmet.getItemMeta();
if (meta == null || !meta.hasCustomModelData()) {
return false;
}
final int cmd = meta.getCustomModelData();
return cmd >= rule.getCustomModelDataMin() && cmd <= rule.getCustomModelDataMax();
} else if (rule.getCustomModelData() != null) {
final ItemMeta meta = helmet.getItemMeta();
if (meta == null || !meta.hasCustomModelData()) {
return false;
}
return meta.getCustomModelData() == rule.getCustomModelData();
}
return true;
}
}
@@ -160,7 +160,7 @@ public class PlayerListener implements PackSendHandler {
return;
}
plugin.getTaskScheduler().runTaskLaterAsynchronously(() -> plugin.getNametagManager().showToTrackedPlayers(event.getPlayer()), 1);
plugin.getTaskScheduler().runTaskLaterAsynchronously(() -> plugin.getNametagManager().showToTrackedPlayers(event.getPlayer()), 5);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
@@ -122,6 +122,9 @@ public class PacketNameTag {
}
perPlayerEntity.modify(user, e -> {
if (e == null) {
return; // Entity removed (e.g. player disconnected during async refresh)
}
final TextDisplayMeta meta = (TextDisplayMeta) e.getEntityMeta();
consumer.accept(meta);
});
@@ -530,7 +533,10 @@ public class PacketNameTag {
return;
}
perPlayerEntity.getEntityOf(user).refresh();
final WrapperEntity entity = perPlayerEntity.getEntityOf(user);
if (entity != null) {
entity.refresh();
}
}
public void remove() {
@@ -73,6 +73,7 @@ public class PlaceholderManager {
reloadPlaceholdersReplacements();
createDecimalFormat();
reload();
updateFormattedPhaseValues(); // Populate immediately to avoid race: gradient with #-phase-mm-g# fails when map is empty
startIndexTask();
}
@@ -242,10 +243,10 @@ public class PlaceholderManager {
return value;
}
String result = value.replace("#phase-mm-g#", formattedPhaseValues.getOrDefault(PHASE_MM_G_KEY, ""));
String result = value.replace("#phase-mm-g#", formattedPhaseValues.getOrDefault(PHASE_MM_G_KEY, "0"));
if (result.indexOf('#') == -1) return result;
result = result.replace("#-phase-mm-g#", formattedPhaseValues.getOrDefault(NEG_PHASE_MM_G_KEY, ""));
result = result.replace("#-phase-mm-g#", formattedPhaseValues.getOrDefault(NEG_PHASE_MM_G_KEY, "0"));
if (result.indexOf('#') == -1) return result;
result = result.replace("#phase-md#", formattedPhaseValues.getOrDefault(PHASE_MD_KEY, ""));