Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion dependency-reduced-pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@
<dependency>
<groupId>io.papermc.paper</groupId>
<artifactId>paper-api</artifactId>
<version>1.21.8-R0.1-SNAPSHOT</version>
<version>1.21.4-R0.1-SNAPSHOT</version>
<scope>provided</scope>
</dependency>
<dependency>
Expand Down
4 changes: 3 additions & 1 deletion src/main/java/net/greenfieldmc/core/GreenfieldCore.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import net.greenfieldmc.core.paintingswitch.PaintingSwitchModule;
import net.greenfieldmc.core.powershovel.PowerShovelModule;
import net.greenfieldmc.core.redblock.RedblockModule;
import net.greenfieldmc.core.signmanager.SignManagerModule;
import net.greenfieldmc.core.templates.TemplatesModule;
import net.greenfieldmc.core.testresult.TestResultModule;
import net.greenfieldmc.core.utilities.UtilitiesModule;
Expand Down Expand Up @@ -40,7 +41,8 @@ public void onEnable() {
new AdvancedBuildModule(this, ModuleConfig::isAdvancedBuildModeEnabled),
new RedblockModule(this, ModuleConfig::isRedblockEnabled),
new ChatFormatModule(this, ModuleConfig::isChatFormatEnabled),
new TemplatesModule(this, ModuleConfig::isTemplatesEnabled)
new TemplatesModule(this, ModuleConfig::isTemplatesEnabled),
new SignManagerModule(this, ModuleConfig::isSignManagerEnabled)
));

MODULES.forEach(Module::enable);
Expand Down
7 changes: 7 additions & 0 deletions src/main/java/net/greenfieldmc/core/ModuleConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public class ModuleConfig extends Configuration {
private final boolean utilities;
private final boolean authHub;
private final boolean templates;
private final boolean signManager;

public ModuleConfig(Plugin plugin) {
super(plugin, ConfigType.YML, "moduleConfig");
Expand All @@ -37,6 +38,7 @@ public ModuleConfig(Plugin plugin) {
addEntry("modules.utilities", true);
addEntry("modules.authHub", true);
addEntry("modules.templates", true);
addEntry("modules.signManager", true);

save();

Expand All @@ -53,6 +55,7 @@ public ModuleConfig(Plugin plugin) {
utilities = getBoolean("modules.utilities");
authHub = getBoolean("modules.authHub");
templates = getBoolean("modules.templates");
signManager = getBoolean("modules.signManager");

}

Expand Down Expand Up @@ -108,4 +111,8 @@ public boolean isTemplatesEnabled() {
return templates;
}

public boolean isSignManagerEnabled() {
return signManager;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package net.greenfieldmc.core.signmanager;

import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;

import java.util.HashMap;
import java.util.Map;

/**
* Utility for calculating Minecraft text pixel widths and centering text.
* Minecraft uses a variable-width bitmap font where each character has a
* specific pixel width (plus 1px gap between characters).
* Signs center-align text within a 90-pixel-wide area.
*/
public class MinecraftFontWidths {

// Sign text rendering width in pixels
private static final int SIGN_WIDTH_PIXELS = 90;

// Space character effective width (glyph 4px + 1px gap)
private static final int SPACE_EFFECTIVE_WIDTH = 5;

// Default character width if not in the map (6px is the most common glyph width)
private static final int DEFAULT_CHAR_WIDTH = 6;

// Map of character -> glyph pixel width (not including the 1px inter-char gap)
private static final Map<Character, Integer> CHAR_WIDTHS = new HashMap<>();

static {
// 2px glyphs
for (char c : "!,.:;|i".toCharArray()) CHAR_WIDTHS.put(c, 2);
CHAR_WIDTHS.put('\'', 2);
CHAR_WIDTHS.put('`', 2);

// 3px glyphs
CHAR_WIDTHS.put('l', 3);

// 4px glyphs
for (char c : "\"()*I[]{}t".toCharArray()) CHAR_WIDTHS.put(c, 4);

// 5px glyphs
for (char c : "<>fk".toCharArray()) CHAR_WIDTHS.put(c, 5);
CHAR_WIDTHS.put(' ', 4); // space glyph is 4px

// 7px glyphs
for (char c : "@~".toCharArray()) CHAR_WIDTHS.put(c, 7);
}

/**
* Gets the pixel width of a single character glyph in Minecraft's default font.
*/
public static int getCharWidth(char c) {
return CHAR_WIDTHS.getOrDefault(c, DEFAULT_CHAR_WIDTH);
}

/**
* Calculates the total pixel width of a string in Minecraft's default font.
* Each character contributes its glyph width + 1px gap (except the last character).
*/
public static int getTextWidth(String text) {
if (text == null || text.isEmpty()) return 0;
int width = 0;
for (int i = 0; i < text.length(); i++) {
width += getCharWidth(text.charAt(i));
if (i < text.length() - 1) width += 1; // 1px gap between characters
}
return width;
}

/**
* Calculates the pixel width of a Component's plain text content.
*/
public static int getComponentWidth(Component component) {
var plainText = PlainTextComponentSerializer.plainText().serialize(component);
return getTextWidth(plainText);
}

/**
* Creates a center-padded version of the given component,
* mimicking how signs render center-aligned text.
*
* @param component The component to center.
* @param targetWidth The target pixel width to center within (typically SIGN_WIDTH_PIXELS).
* @return A new component with leading spaces to approximate center alignment.
*/
public static Component centerText(Component component, int targetWidth) {
int textWidth = getComponentWidth(component);
if (textWidth >= targetWidth) return component;

int totalPadding = targetWidth - textWidth;
int leftPadding = totalPadding / 2;

// Each space is SPACE_EFFECTIVE_WIDTH pixels wide (4px glyph + 1px gap)
int spaceCount = leftPadding / SPACE_EFFECTIVE_WIDTH;
if (spaceCount <= 0) return component;

return Component.text(" ".repeat(spaceCount)).append(component);
}

/**
* Centers a component using the standard sign width.
*/
public static Component centerText(Component component) {
return centerText(component, SIGN_WIDTH_PIXELS);
}
}

197 changes: 197 additions & 0 deletions src/main/java/net/greenfieldmc/core/signmanager/SavedSign.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
package net.greenfieldmc.core.signmanager;

import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextDecoration;
import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import org.bukkit.Material;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.BlockStateMeta;
import org.bukkit.block.Sign;
import org.bukkit.block.sign.Side;
import org.jetbrains.annotations.Nullable;

import java.util.ArrayList;
import java.util.List;

public class SavedSign {

private String name;
private final Material signMaterial;
private final List<String> frontLines; // GsonComponentSerializer JSON strings
private final List<String> backLines; // GsonComponentSerializer JSON strings

public SavedSign(String name, Material signMaterial, List<String> frontLines, List<String> backLines) {
this.name = name;
this.signMaterial = signMaterial;
this.frontLines = frontLines;
this.backLines = backLines;
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public Material getSignMaterial() {
return signMaterial;
}

public List<String> getFrontLines() {
return frontLines;
}

public List<String> getBackLines() {
return backLines;
}

/**
* Builds hover text showing the sign's front and back text content.
*/
public List<Component> buildHoverLines() {
var lines = new ArrayList<Component>();
lines.add(Component.text("── Front ──", NamedTextColor.GRAY));
for (var line : frontLines) {
var component = deserializeLine(line);
var plain = PlainTextComponentSerializer.plainText().serialize(component);
if (!plain.isBlank()) {
lines.add(MinecraftFontWidths.centerText(component));
}
}
lines.add(Component.text("── Back ──", NamedTextColor.GRAY));
for (var line : backLines) {
var component = deserializeLine(line);
var plain = PlainTextComponentSerializer.plainText().serialize(component);
if (!plain.isBlank()) {
lines.add(MinecraftFontWidths.centerText(component));
}
}
return lines;
}

/**
* Converts the saved sign data back into a placeable sign ItemStack with
* the original front/back text restored in the block entity NBT.
*/
public ItemStack toItemStack() {
var item = new ItemStack(signMaterial, 1);
var meta = item.getItemMeta();

if (meta instanceof BlockStateMeta blockStateMeta) {
var state = blockStateMeta.getBlockState();
if (state instanceof Sign sign) {
var frontSide = sign.getSide(Side.FRONT);
for (int i = 0; i < frontLines.size() && i < 4; i++) {
frontSide.line(i, deserializeLine(frontLines.get(i)));
}
var backSide = sign.getSide(Side.BACK);
for (int i = 0; i < backLines.size() && i < 4; i++) {
backSide.line(i, deserializeLine(backLines.get(i)));
}
blockStateMeta.setBlockState(state);
}
}

item.setItemMeta(meta);
return item;
}

/**
* Creates a SavedSign from a sign ItemStack held by a player.
*/
public static @Nullable SavedSign fromItemStack(ItemStack item, String name) {
if (item == null || !isSignMaterial(item.getType())) return null;

var meta = item.getItemMeta();
var frontLines = new ArrayList<String>();
var backLines = new ArrayList<String>();

if (meta instanceof BlockStateMeta blockStateMeta) {
var state = blockStateMeta.getBlockState();
if (state instanceof Sign sign) {
var frontSide = sign.getSide(Side.FRONT);
for (int i = 0; i < 4; i++) {
frontLines.add(serializeLine(frontSide.line(i)));
}
var backSide = sign.getSide(Side.BACK);
for (int i = 0; i < 4; i++) {
backLines.add(serializeLine(backSide.line(i)));
}
}
}

// If no block state meta, just use empty lines
while (frontLines.size() < 4) frontLines.add(serializeLine(Component.empty()));
while (backLines.size() < 4) backLines.add(serializeLine(Component.empty()));

return new SavedSign(name, item.getType(), frontLines, backLines);
}

/**
* Returns the plain text content of all sign lines for search matching.
*/
public String getPlainText() {
var sb = new StringBuilder();
sb.append(name).append(" ");
for (var line : frontLines) {
sb.append(PlainTextComponentSerializer.plainText().serialize(deserializeLine(line))).append(" ");
}
for (var line : backLines) {
sb.append(PlainTextComponentSerializer.plainText().serialize(deserializeLine(line))).append(" ");
}
return sb.toString();
}

/**
* Returns a human-readable sign type description based on the material name.
* e.g. "Oak Sign", "Birch Hanging Sign", "Dark Oak Wall Sign"
*/
public String getSignTypeDescription() {
var name = signMaterial.name();
// Remove _SIGN suffix and convert underscores to spaces, title case
name = name.replace("_SIGN", "").replace("_", " ");
var words = name.toLowerCase().split(" ");
var sb = new StringBuilder();
for (var word : words) {
if (!word.isEmpty()) {
sb.append(Character.toUpperCase(word.charAt(0))).append(word.substring(1)).append(" ");
}
}
sb.append("Sign");
return sb.toString().trim();
}

/**
* Returns true if the sign material is a hanging sign type.
*/
public boolean isHangingSign() {
return signMaterial.name().contains("HANGING");
}

/**
* Returns true if the sign material is a wall sign type.
*/
public boolean isWallSign() {
return signMaterial.name().contains("WALL");
}

public static boolean isSignMaterial(Material material) {
return material != null && material.name().endsWith("_SIGN");
}

private static String serializeLine(Component component) {
return GsonComponentSerializer.gson().serialize(component);
}

private static Component deserializeLine(String json) {
try {
return GsonComponentSerializer.gson().deserialize(json);
} catch (Exception e) {
return Component.text(json);
}
}
}
Loading