add sign text rendering: support font loading, text rasterization, and color adjustments for signs

This commit is contained in:
2026-06-21 16:15:57 +02:00
parent 5330948dbd
commit fed94f97d1
11 changed files with 564 additions and 22 deletions
@@ -65,10 +65,13 @@ public final class Main extends JavaPlugin {
getLogger().severe("Failed to load CEM entity models: " + e.getMessage());
}
eu.mhsl.minecraft.pixelpics.assets.SkinCache skinCache = new eu.mhsl.minecraft.pixelpics.assets.SkinCache();
eu.mhsl.minecraft.pixelpics.assets.font.BitmapFont font =
eu.mhsl.minecraft.pixelpics.assets.font.FontLoader.load(resourcePack, textures, getLogger());
getLogger().info("Loaded sign font (" + (font.isEmpty() ? "no glyphs — text disabled" : "ok") + ").");
eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker entityBaker =
new eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker(cemLoader, textures, skinCache);
eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker blockEntityBaker =
new eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker(cemLoader, textures, skinCache);
new eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker(cemLoader, textures, skinCache, font);
this.screenRenderer = new DefaultScreenRenderer(registry, tintProvider, textures, entityBaker, blockEntityBaker, getLogger());
// Warm the map palette on the main thread so off-thread dithering never triggers its first init.
@@ -27,4 +27,9 @@ public final class AssetPaths {
public static String textureMeta(ResourceLocation id) {
return texture(id) + ".mcmeta";
}
/** {@code assets/<ns>/font/<path>.json}, e.g. for {@code minecraft:default} or {@code minecraft:include/space}. */
public static String font(ResourceLocation id) {
return String.format("assets/%s/font/%s.json", id.namespace(), id.path());
}
}
@@ -0,0 +1,56 @@
package eu.mhsl.minecraft.pixelpics.assets.font;
import java.util.Map;
/**
* A Minecraft bitmap font assembled from the resource pack's {@code font/default.json} providers
* (the {@code bitmap} and {@code space} providers; {@code unihex}/{@code legacy_unicode}/{@code ttf}
* are out of scope). Maps codepoints to {@link Glyph}s and supplies advance widths, enough to rasterize
* a line of text. Immutable after construction.
*/
public final class BitmapFont {
private final Map<Integer, Glyph> glyphs;
private final Map<Integer, Integer> spaceAdvances;
private final int maxAscent;
private final int maxDescent;
public BitmapFont(Map<Integer, Glyph> glyphs, Map<Integer, Integer> spaceAdvances, int maxAscent, int maxDescent) {
this.glyphs = glyphs;
this.spaceAdvances = spaceAdvances;
this.maxAscent = maxAscent;
this.maxDescent = maxDescent;
}
/** The glyph for a codepoint, or null when no bitmap provider supplies it (e.g. CJK via unihex). */
public Glyph glyph(int codepoint) {
return glyphs.get(codepoint);
}
/** Horizontal advance for a codepoint (font px): a space-provider advance, a glyph advance, else 0. */
public int advance(int codepoint) {
Integer sp = spaceAdvances.get(codepoint);
if (sp != null) return sp;
Glyph g = glyphs.get(codepoint);
return g != null ? g.advance() : 0;
}
/** Largest baseline offset across all glyphs (font px) — the common baseline for a rendered line. */
public int maxAscent() {
return maxAscent;
}
/** Largest below-baseline extent across all glyphs (font px). */
public int maxDescent() {
return maxDescent;
}
/** Total vertical extent of one line (ascent + descent, font px). */
public int lineHeight() {
return maxAscent + maxDescent;
}
public boolean isEmpty() {
return glyphs.isEmpty();
}
}
@@ -0,0 +1,160 @@
package eu.mhsl.minecraft.pixelpics.assets.font;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import eu.mhsl.minecraft.pixelpics.assets.AssetPaths;
import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation;
import eu.mhsl.minecraft.pixelpics.assets.ResourcePack;
import eu.mhsl.minecraft.pixelpics.assets.TextureCache;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.logging.Logger;
/**
* Builds a {@link BitmapFont} from a resource pack's {@code font/default.json}. Resolves {@code reference}
* providers recursively, parses {@code bitmap} providers into per-codepoint {@link Glyph}s (cell size and
* per-glyph pixel width derived from the PNG), and {@code space} providers into advance overrides.
* {@code unihex}/{@code legacy_unicode}/{@code ttf} providers are skipped (CJK etc. render as gaps).
*
* <p>Provider order is priority order (first match wins, matching vanilla): an earlier provider's glyph is
* never overwritten by a later one.
*/
public final class FontLoader {
private FontLoader() {}
/** Loads {@code minecraft:default}. Returns an empty font (no glyphs) when the pack has no font assets. */
public static BitmapFont load(ResourcePack pack, TextureCache textures, Logger log) {
Builder b = new Builder(pack, textures, log);
b.loadFont(ResourceLocation.parse("minecraft:default"), new HashSet<>());
return b.build();
}
private static final class Builder {
private final ResourcePack pack;
private final TextureCache textures;
private final Logger log;
private final Map<Integer, Glyph> glyphs = new HashMap<>();
private final Map<Integer, Integer> spaceAdvances = new HashMap<>();
private int maxAscent = 0;
private int maxDescent = 0;
Builder(ResourcePack pack, TextureCache textures, Logger log) {
this.pack = pack;
this.textures = textures;
this.log = log;
}
BitmapFont build() {
return new BitmapFont(glyphs, spaceAdvances, maxAscent, maxDescent);
}
void loadFont(ResourceLocation id, Set<String> visited) {
String path = AssetPaths.font(id);
if (!visited.add(path)) return; // cycle guard
Optional<byte[]> bytes = pack.read(path);
if (bytes.isEmpty()) return;
JsonObject root;
try {
root = JsonParser.parseString(new String(bytes.get(), StandardCharsets.UTF_8)).getAsJsonObject();
} catch (Exception e) {
if (log != null) log.warning("PixelPics: failed to parse font " + path + ": " + e.getMessage());
return;
}
JsonArray providers = root.getAsJsonArray("providers");
if (providers == null) return;
for (JsonElement el : providers) {
if (!el.isJsonObject()) continue;
provider(el.getAsJsonObject(), visited);
}
}
private void provider(JsonObject p, Set<String> visited) {
String type = p.has("type") ? p.get("type").getAsString() : "";
switch (type) {
case "reference" -> {
if (p.has("id")) loadFont(ResourceLocation.parse(p.get("id").getAsString()), visited);
}
case "space" -> space(p);
case "bitmap" -> bitmap(p);
default -> { /* unihex, legacy_unicode, ttf, … — out of scope */ }
}
}
private void space(JsonObject p) {
JsonObject advances = p.getAsJsonObject("advances");
if (advances == null) return;
for (Map.Entry<String, JsonElement> e : advances.entrySet()) {
String key = e.getKey();
if (key.isEmpty()) continue;
int cp = key.codePointAt(0);
spaceAdvances.putIfAbsent(cp, e.getValue().getAsInt());
}
}
private void bitmap(JsonObject p) {
if (!p.has("file") || !p.has("chars")) return;
int height = p.has("height") ? p.get("height").getAsInt() : 8;
int ascent = p.has("ascent") ? p.get("ascent").getAsInt() : 7;
String file = p.get("file").getAsString();
if (file.endsWith(".png")) file = file.substring(0, file.length() - 4);
int[][] tex = textures.get(ResourceLocation.parse(file)).orElse(null);
if (tex == null || tex.length == 0) {
if (log != null) log.warning("PixelPics: font bitmap missing: " + file);
return;
}
int imgH = tex.length;
int imgW = tex[0].length;
JsonArray rows = p.getAsJsonArray("chars");
int nRows = rows.size();
if (nRows == 0) return;
int nCols = rows.get(0).getAsString().codePointCount(0, rows.get(0).getAsString().length());
if (nCols == 0) return;
int cellW = imgW / nCols;
int cellH = imgH / nRows;
if (cellW == 0 || cellH == 0) return;
double scale = height / (double) cellH;
for (int r = 0; r < nRows; r++) {
String row = rows.get(r).getAsString();
int col = 0;
for (int ci = 0; ci < row.length(); ) {
int cp = row.codePointAt(ci);
ci += Character.charCount(cp);
int thisCol = col++;
if (cp == 0 || thisCol >= nCols) continue; // 0x0000 = empty slot
if (glyphs.containsKey(cp)) continue; // earlier provider wins
int srcX = thisCol * cellW;
int srcY = r * cellH;
int glyphPx = rightmostOpaqueColumn(tex, srcX, srcY, cellW, cellH) + 1;
if (glyphPx <= 0) continue; // blank glyph (handled by space provider)
int advance = (int) (glyphPx * scale + 0.5) + 1;
glyphs.put(cp, new Glyph(tex, srcX, srcY, cellW, cellH, glyphPx, height, ascent, advance));
maxAscent = Math.max(maxAscent, ascent);
maxDescent = Math.max(maxDescent, height - ascent);
}
}
}
/** Rightmost column index (0-based, relative to the cell) containing an opaque pixel, or -1 if blank. */
private static int rightmostOpaqueColumn(int[][] tex, int srcX, int srcY, int cellW, int cellH) {
int last = -1;
for (int x = 0; x < cellW; x++) {
for (int y = 0; y < cellH; y++) {
int argb = tex[srcY + y][srcX + x];
if (((argb >>> 24) & 0xFF) != 0) { last = x; break; }
}
}
return last;
}
}
}
@@ -0,0 +1,19 @@
package eu.mhsl.minecraft.pixelpics.assets.font;
/**
* A single bitmap-font glyph: a rectangular cell {@code [srcX, srcX+cellW) × [srcY, srcY+cellH)} inside
* its provider PNG ({@code tex}, ARGB top-left), the number of opaque pixel columns to actually draw
* ({@code glyphPx}, measured to the rightmost non-transparent column), the rendered {@code height} and
* {@code ascent} (baseline offset from the top, font px) and the horizontal {@code advance} (font px).
*
* <p>Glyphs from different providers may declare different cell sizes and ascents (e.g. ascii 8px/ascent
* 7 vs accented 12px/ascent 10); rendering aligns them on a common baseline so umlauts and Latin mix.
*/
public record Glyph(int[][] tex, int srcX, int srcY, int cellW, int cellH,
int glyphPx, int height, int ascent, int advance) {
/** Vertical pixels below the baseline (font px). */
public int descent() {
return height - ascent;
}
}
@@ -24,7 +24,9 @@ public record BlockEntityState(
String skinUrl, // player-head owner skin URL; null otherwise
List<BannerPattern> patterns, // banner overlay patterns (may be empty)
List<String> sherds, // decorated-pot sherds: front/back/left/right item keys (may be empty)
BellAttach bellAttach // bell attachment; null when not a bell
BellAttach bellAttach, // bell attachment; null when not a bell
SignText frontText, // sign front-side text; null when not a sign or blank
SignText backText // sign back-side text; null when not a sign or blank
) {
public enum Kind {
CHEST, TRAPPED_CHEST, ENDER_CHEST,
@@ -43,4 +45,11 @@ public record BlockEntityState(
/** One banner overlay layer: a pattern key (e.g. "stripe_top") and the ARGB colour it is dyed with. */
public record BannerPattern(String patternKey, int colorArgb) {}
/**
* One side of a sign: up to four plain-text lines plus the resolved colours. {@code fillArgb} is the
* glyph fill colour (already glow/darken-adjusted); {@code outlineArgb} is the 8-directional outline
* drawn only when {@code glowing}.
*/
public record SignText(List<String> lines, int fillArgb, int outlineArgb, boolean glowing) {}
}
@@ -3,6 +3,9 @@ package eu.mhsl.minecraft.pixelpics.render.entity.cem;
import eu.mhsl.minecraft.pixelpics.assets.ResourceLocation;
import eu.mhsl.minecraft.pixelpics.assets.SkinCache;
import eu.mhsl.minecraft.pixelpics.assets.TextureCache;
import eu.mhsl.minecraft.pixelpics.assets.font.BitmapFont;
import eu.mhsl.minecraft.pixelpics.assets.model.Direction;
import eu.mhsl.minecraft.pixelpics.assets.model.Face;
import eu.mhsl.minecraft.pixelpics.render.entity.Affine;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityModels;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState;
@@ -29,14 +32,27 @@ public final class BlockEntityBaker implements EntityBaker<BlockEntityState> {
private static final double SIGN_SCALE = 0.6666667; // vanilla SignRenderer model scale
// Sign-text placement (model px in the board's local frame; calibrated against the test harness).
// The text block is scaled to fill the board's writable area (W×H) so few-line signs are rendered as
// large as possible (legibility on low-res maps), capped so a single line isn't blown up absurdly.
private static final double SIGN_TEXT_W = 22.0, SIGN_TEXT_H = 11.0, SIGN_TEXT_CAP = 0.5; // board 24x12
private static final double HANG_TEXT_W = 13.0, HANG_TEXT_H = 9.0, HANG_TEXT_CAP = 0.42; // board 14x10
private static final double SIGN_TEXT_CY = 20.0; // board Y∈[14,26], centre 20
private static final double HANGING_TEXT_CY = 5.0; // board Y∈[0,10], centre 5
private static final double BOARD_FRONT_Z = 1.0; // both boards: front/back faces at Z=±1
private static final double TEXT_Z_EPS = 0.05; // lift the text just off the board face
private static final double TEXT_THICK = 0.5; // slab thickness so the ray test never degenerates
private final CemModelLoader models;
private final TextureCache textures;
private final SkinCache skins;
private final BitmapFont font;
public BlockEntityBaker(CemModelLoader models, TextureCache textures, SkinCache skins) {
public BlockEntityBaker(CemModelLoader models, TextureCache textures, SkinCache skins, BitmapFont font) {
this.models = models;
this.textures = textures;
this.skins = skins;
this.font = font;
}
/** Returns the baked block-entity, or null when it has no model/texture (then nothing renders). */
@@ -61,9 +77,67 @@ public final class BlockEntityBaker implements EntityBaker<BlockEntityState> {
cubes.add(new EntityCube(b.from(), b.to(), b.faces(), placement.mul(b.world())));
}
}
addSignText(s, placement.mul(pre), cubes);
return cubes.isEmpty() ? null : RenderedEntity.of(cubes);
}
/**
* Appends flat text quads in front of (and behind) the sign board, rasterized from the captured
* {@link BlockEntityState.SignText}. Built in the board's model-px frame and transformed by the same
* {@code placement·pre} as the CEM board, so the text rides exactly on the board.
*
* <p>The CEM sign model faces north by default and is aimed with {@code rotY(180facingDeg)} where
* {@code facingDeg} is the direction the sign's FRONT faces — so the model's Z (NORTH) face ends up
* being the front the player reads, and +Z (SOUTH) is the back. Each face uses an unflipped UV: the
* intersector already mirrors NORTH relative to SOUTH ({@code s=1fx} vs {@code s=fx}), so both read
* left-to-right when viewed from outside.
*/
private void addSignText(BlockEntityState s, Affine toWorld, List<EntityCube> cubes) {
if (font == null || font.isEmpty()) return;
boolean hanging = s.kind() == BlockEntityState.Kind.HANGING_SIGN;
boolean sign = hanging || s.kind() == BlockEntityState.Kind.SIGN || s.kind() == BlockEntityState.Kind.WALL_SIGN;
if (!sign) return;
double cy = hanging ? HANGING_TEXT_CY : SIGN_TEXT_CY;
double tw = hanging ? HANG_TEXT_W : SIGN_TEXT_W;
double th = hanging ? HANG_TEXT_H : SIGN_TEXT_H;
double cap = hanging ? HANG_TEXT_CAP : SIGN_TEXT_CAP;
addSide(s.frontText(), Direction.NORTH, tw, th, cap, cy, toWorld, cubes);
addSide(s.backText(), Direction.SOUTH, tw, th, cap, cy, toWorld, cubes);
}
private void addSide(BlockEntityState.SignText t, Direction faceDir, double tw, double th, double cap,
double cy, Affine toWorld, List<EntityCube> cubes) {
if (t == null) return;
int[][] bmp = SignTextRasterizer.rasterize(trimBlankLines(t.lines()), font, t.fillArgb(), t.outlineArgb(), t.glowing());
if (bmp == null) return;
// Scale so the text block fills the board's writable area, whichever dimension binds, capped.
double fpm = Math.min(cap, Math.min(tw / bmp[0].length, th / bmp.length));
double blockW = bmp[0].length * fpm;
double blockH = bmp.length * fpm;
double z0, z1;
if (faceDir == Direction.NORTH) { // front: Z face, text sits just past 1
z1 = -BOARD_FRONT_Z - TEXT_Z_EPS;
z0 = z1 - TEXT_THICK;
} else { // back: +Z face, text sits just past +1
z0 = BOARD_FRONT_Z + TEXT_Z_EPS;
z1 = z0 + TEXT_THICK;
}
Face[] faces = new Face[6];
faces[faceDir.ordinal()] = new Face(bmp, 0, 0, 1, 1, 0, -1);
double[] from = {-blockW / 2, cy - blockH / 2, z0};
double[] to = {blockW / 2, cy + blockH / 2, z1};
cubes.add(new EntityCube(from, to, faces, toWorld));
}
/** Drops leading/trailing blank lines so short text fills the board; keeps interior blanks. */
private static List<String> trimBlankLines(List<String> lines) {
int lo = 0, hi = lines.size();
while (lo < hi && lines.get(lo).isBlank()) lo++;
while (hi > lo && lines.get(hi - 1).isBlank()) hi--;
return lines.subList(lo, hi);
}
/** The CEM model name; for heads it depends on the texture's aspect (64x32 vs square 64x64). */
private String modelName(BlockEntityState s, int[][] tex) {
if (s.kind() != BlockEntityState.Kind.HEAD && s.kind() != BlockEntityState.Kind.WALL_HEAD) {
@@ -0,0 +1,109 @@
package eu.mhsl.minecraft.pixelpics.render.entity.cem;
import eu.mhsl.minecraft.pixelpics.assets.font.BitmapFont;
import eu.mhsl.minecraft.pixelpics.assets.font.Glyph;
import java.util.List;
/**
* Rasterizes a sign side's text lines into a transparent ARGB grid ({@code int[][]}, top-left origin) at
* the font's native pixel resolution. Lines are centred horizontally; the block of lines fills the canvas
* top-to-bottom (blank lines reserve their slot so vertical centring matches vanilla, which always lays
* out four lines). The baker maps the result onto a quad whose model size keeps the canvas aspect, so the
* text is never stretched. Glyphs from different providers (ascii/accented) are aligned on a common
* baseline. Glowing text gets an 8-directional outline.
*/
final class SignTextRasterizer {
private SignTextRasterizer() {}
/** Returns the rasterized panel, or null when there is nothing to draw (no font/glyphs). */
static int[][] rasterize(List<String> lines, BitmapFont font, int fillArgb, int outlineArgb, boolean glow) {
if (font.isEmpty() || lines.isEmpty()) return null;
int ascent = font.maxAscent();
int pitch = font.lineHeight(); // ascent + descent
int margin = glow ? 1 : 0; // room for the outline
int contentW = 1;
for (String line : lines) contentW = Math.max(contentW, lineWidth(line, font));
int contentH = Math.max(1, lines.size() * pitch);
int w = contentW + 2 * margin;
int h = contentH + 2 * margin;
int[][] out = new int[h][w];
boolean[][] covered = new boolean[h][w];
// Pass 1: stamp glyph coverage (fill colour) into `out` + `covered`.
boolean any = false;
for (int li = 0; li < lines.size(); li++) {
String line = lines.get(li);
int penX = margin + (contentW - lineWidth(line, font)) / 2;
int baseline = margin + li * pitch + ascent;
for (int ci = 0; ci < line.length(); ) {
int cp = line.codePointAt(ci);
ci += Character.charCount(cp);
Glyph g = font.glyph(cp);
if (g != null) {
any |= blit(g, penX, baseline, fillArgb, out, covered);
}
penX += font.advance(cp);
}
}
if (!any) return null;
// Pass 2 (glow only): paint the outline into uncovered neighbours of covered pixels.
if (glow) {
for (int y = 0; y < h; y++) {
for (int x = 0; x < w; x++) {
if (!covered[y][x]) continue;
for (int dy = -1; dy <= 1; dy++) {
for (int dx = -1; dx <= 1; dx++) {
if (dx == 0 && dy == 0) continue;
int nx = x + dx, ny = y + dy;
if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
if (!covered[ny][nx]) out[ny][nx] = outlineArgb;
}
}
}
}
}
return out;
}
/** Blits a glyph's opaque pixels as {@code fillArgb}; aligns the glyph's baseline to {@code baseline}. */
private static boolean blit(Glyph g, int penX, int baseline, int fillArgb, int[][] out, boolean[][] covered) {
double scale = g.height() / (double) g.cellH();
int rw = Math.max(1, (int) (g.glyphPx() * scale + 0.5));
int rh = g.height();
int top = baseline - g.ascent();
boolean any = false;
for (int dy = 0; dy < rh; dy++) {
int oy = top + dy;
if (oy < 0 || oy >= out.length) continue;
int sy = g.srcY() + (int) (dy / scale);
for (int dx = 0; dx < rw; dx++) {
int ox = penX + dx;
if (ox < 0 || ox >= out[0].length) continue;
int sx = g.srcX() + (int) (dx / scale);
int argb = g.tex()[sy][sx];
if (((argb >>> 24) & 0xFF) == 0) continue;
out[oy][ox] = fillArgb;
covered[oy][ox] = true;
any = true;
}
}
return any;
}
/** Total advance width of a line in font px. */
private static int lineWidth(String line, BitmapFont font) {
int w = 0;
for (int ci = 0; ci < line.length(); ) {
int cp = line.codePointAt(ci);
ci += Character.charCount(cp);
w += font.advance(cp);
}
return w;
}
}
@@ -120,15 +120,22 @@ public final class BlockEntitySnapshotBuilder {
// --- signs ---
if (n.endsWith("_SIGN")) {
String wood = signWood(n);
Kind kind;
float yaw;
if (n.endsWith("_WALL_SIGN")) {
return base(Kind.WALL_SIGN, bx, by, bz, facingYaw(data)).wood(wood).build();
kind = Kind.WALL_SIGN; yaw = facingYaw(data);
} else if (n.endsWith("_HANGING_SIGN")) {
kind = Kind.HANGING_SIGN;
yaw = n.endsWith("_WALL_HANGING_SIGN") ? facingYaw(data) : rotationYaw(data);
} else {
kind = Kind.SIGN; yaw = rotationYaw(data);
}
if (n.endsWith("_HANGING_SIGN")) {
boolean wall = n.endsWith("_WALL_HANGING_SIGN");
float yaw = wall ? facingYaw(data) : rotationYaw(data);
return base(Kind.HANGING_SIGN, bx, by, bz, yaw).wood(wood).build();
Builder b = base(kind, bx, by, bz, yaw).wood(wood);
if (ts instanceof org.bukkit.block.Sign sign) {
b.frontText(signText(sign.getSide(org.bukkit.block.sign.Side.FRONT)));
b.backText(signText(sign.getSide(org.bukkit.block.sign.Side.BACK)));
}
return base(Kind.SIGN, bx, by, bz, rotationYaw(data)).wood(wood).build();
return b.build();
}
// --- heads / skulls ---
@@ -222,6 +229,23 @@ public final class BlockEntitySnapshotBuilder {
return name.substring(0, name.length() - suffix.length()).toLowerCase(java.util.Locale.ROOT);
}
/** One sign side → {@link BlockEntityState.SignText}, or null when all four lines are blank. */
private static BlockEntityState.SignText signText(org.bukkit.block.sign.SignSide side) {
String[] raw = side.getLines();
List<String> lines = new ArrayList<>(raw.length);
boolean any = false;
for (String l : raw) {
String s = l == null ? "" : l.replaceAll("§.", ""); // strip legacy §-codes
if (!s.isEmpty()) any = true;
lines.add(s);
}
if (!any) return null;
DyeColor dye = side.getColor();
boolean glow = side.isGlowingText();
return new BlockEntityState.SignText(lines, ColorUtil.signFillArgb(dye, glow),
ColorUtil.signOutlineArgb(dye), glow);
}
private static String signWood(String name) {
String s = name;
for (String suf : new String[]{"_WALL_HANGING_SIGN", "_HANGING_SIGN", "_WALL_SIGN", "_SIGN"}) {
@@ -293,6 +317,8 @@ public final class BlockEntitySnapshotBuilder {
private List<BannerPattern> patterns = List.of();
private List<String> sherds = List.of();
private BellAttach bellAttach;
private BlockEntityState.SignText frontText;
private BlockEntityState.SignText backText;
Builder(Kind kind, int bx, int by, int bz, float yaw) {
this.kind = kind; this.bx = bx; this.by = by; this.bz = bz; this.yaw = yaw;
@@ -308,10 +334,12 @@ public final class BlockEntitySnapshotBuilder {
Builder patterns(List<BannerPattern> v) { this.patterns = v; return this; }
Builder sherds(List<String> v) { this.sherds = v; return this; }
Builder bellAttach(BellAttach v) { this.bellAttach = v; return this; }
Builder frontText(BlockEntityState.SignText v) { this.frontText = v; return this; }
Builder backText(BlockEntityState.SignText v) { this.backText = v; return this; }
BlockEntityState build() {
return new BlockEntityState(kind, bx, by, bz, yaw, chestKind, baseColorArgb, colorName, wood,
bedPart, headType, skinUrl, patterns, sherds, bellAttach);
bedPart, headType, skinUrl, patterns, sherds, bellAttach, frontText, backText);
}
}
}
@@ -23,6 +23,51 @@ public final class ColorUtil {
return argb(0xFF, c.getRed(), c.getGreen(), c.getBlue());
}
/**
* The vanilla sign-text colour for a dye (opaque ARGB), from Mojang's {@code DyeColor.getTextColor()}
* table (the firework/text colours, NOT the cloth colours). Null = black (the default sign ink).
*/
public static int signTextColor(org.bukkit.DyeColor dye) {
int rgb = switch (dye == null ? org.bukkit.DyeColor.BLACK : dye) {
case WHITE -> 0xF9FFFE;
case ORANGE -> 0xF9801D;
case MAGENTA -> 0xC74EBD;
case LIGHT_BLUE -> 0x3AB3DA;
case YELLOW -> 0xFED83D;
case LIME -> 0x80C71F;
case PINK -> 0xF38BAA;
case GRAY -> 0x474F52;
case LIGHT_GRAY -> 0x9D9D97;
case CYAN -> 0x169C9C;
case PURPLE -> 0x8932B8;
case BLUE -> 0x3C44AA;
case BROWN -> 0x835432;
case GREEN -> 0x5E7C16;
case RED -> 0xB02E26;
case BLACK -> 0x1D1D21;
};
return 0xFF000000 | rgb;
}
/**
* The fill colour for sign text: glowing text uses the full dye colour; non-glowing text is the dye
* colour darkened to 40% (matching vanilla {@code SignRenderer}, which is why normal ink looks dim).
*/
public static int signFillArgb(org.bukkit.DyeColor dye, boolean glowing) {
int base = signTextColor(dye);
return glowing ? base : (0xFF000000 | (shade(base, 0.4) & 0xFFFFFF));
}
/**
* The 8-directional outline colour drawn around glowing sign text (vanilla {@code getDarkColor}):
* the dye colour darkened to 40%, except glowing BLACK ink which gets a light cream outline so it
* stays readable.
*/
public static int signOutlineArgb(org.bukkit.DyeColor dye) {
if ((dye == null ? org.bukkit.DyeColor.BLACK : dye) == org.bukkit.DyeColor.BLACK) return 0xFFF0EBCC;
return 0xFF000000 | (shade(signTextColor(dye), 0.4) & 0xFFFFFF);
}
/** Multiplies the RGB channels of {@code base} by {@code tint} (per-channel, 0..255), keeping base alpha. */
public static int multiply(int base, int tint) {
int a = alpha(base);