add sign text rendering: support font loading, text rasterization, and color adjustments for signs
This commit is contained in:
@@ -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(180−facingDeg)} 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=1−fx} 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;
|
||||
}
|
||||
}
|
||||
+35
-7
@@ -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);
|
||||
|
||||
@@ -35,7 +35,18 @@ public class BlockEntityTestRender {
|
||||
|
||||
static BlockEntityState be(Kind kind, int bx, int by, int bz, float yaw) {
|
||||
return new BlockEntityState(kind, bx, by, bz, yaw, ChestKind.SINGLE, 0, null, "oak",
|
||||
null, null, null, List.of(), List.of(), null);
|
||||
null, null, null, List.of(), List.of(), null, null, null);
|
||||
}
|
||||
|
||||
static BlockEntityState sign(Kind kind, float yaw, SignText front, SignText back) {
|
||||
return new BlockEntityState(kind, 0, 0, 0, yaw, null, 0, null, "oak",
|
||||
null, null, null, List.of(), List.of(), null, front, back);
|
||||
}
|
||||
|
||||
static SignText txt(org.bukkit.DyeColor dye, boolean glow, String... lines) {
|
||||
return new SignText(List.of(lines),
|
||||
eu.mhsl.minecraft.pixelpics.render.util.ColorUtil.signFillArgb(dye, glow),
|
||||
eu.mhsl.minecraft.pixelpics.render.util.ColorUtil.signOutlineArgb(dye), glow);
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
@@ -48,8 +59,11 @@ public class BlockEntityTestRender {
|
||||
CemModelLoader geo = new CemModelLoader();
|
||||
geo.load(new java.io.FileInputStream("/tmp/cem_models.json"), log);
|
||||
SkinCache skins = new SkinCache();
|
||||
eu.mhsl.minecraft.pixelpics.assets.font.BitmapFont font =
|
||||
eu.mhsl.minecraft.pixelpics.assets.font.FontLoader.load(pack, textures, log);
|
||||
log.info("font glyphs loaded: " + (font.isEmpty() ? "NONE" : "ok"));
|
||||
CemBaker baker = new CemBaker(geo, textures, skins);
|
||||
beBaker = new BlockEntityBaker(geo, textures, skins);
|
||||
beBaker = new BlockEntityBaker(geo, textures, skins, font);
|
||||
decoBaker = new DecorationBaker(textures);
|
||||
DefaultScreenRenderer renderer = new DefaultScreenRenderer(registry, tint, textures, baker, beBaker, log);
|
||||
|
||||
@@ -76,23 +90,43 @@ public class BlockEntityTestRender {
|
||||
scenes.put("chest_E", List.of(be(Kind.CHEST, 0, 0, 0, 270)));
|
||||
scenes.put("chest_W", List.of(be(Kind.CHEST, 0, 0, 0, 90)));
|
||||
scenes.put("double_chest", List.of(
|
||||
new BlockEntityState(Kind.CHEST, 0, 0, 0, 0, ChestKind.LEFT, 0, null, null, null, null, null, List.of(), List.of(), null),
|
||||
new BlockEntityState(Kind.CHEST, 1, 0, 0, 0, ChestKind.RIGHT, 0, null, null, null, null, null, List.of(), List.of(), null)));
|
||||
new BlockEntityState(Kind.CHEST, 0, 0, 0, 0, ChestKind.LEFT, 0, null, null, null, null, null, List.of(), List.of(), null, null, null),
|
||||
new BlockEntityState(Kind.CHEST, 1, 0, 0, 0, ChestKind.RIGHT, 0, null, null, null, null, null, List.of(), List.of(), null, null, null)));
|
||||
scenes.put("sign", List.of(be(Kind.SIGN, 0, 0, 0, 0)));
|
||||
scenes.put("wall_sign", List.of(be(Kind.WALL_SIGN, 0, 0, 0, 0)));
|
||||
scenes.put("hanging_sign", List.of(be(Kind.HANGING_SIGN, 0, 0, 0, 0)));
|
||||
scenes.put("banner", List.of(new BlockEntityState(Kind.BANNER, 0, 0, 0, 0, null, 0xFFCC2020, null, null, null, null, null, List.of(), List.of(), null)));
|
||||
// --- sign text (calibration) --- facingDeg=180 → front (model −Z) faces the camera (north).
|
||||
org.bukkit.DyeColor B = org.bukkit.DyeColor.BLACK;
|
||||
scenes.put("sign_text", List.of(sign(Kind.SIGN, 180,
|
||||
txt(B, false, "Hello World", "zweite Zeile", "Gruesse: AÖÜ", "ßäöü 12345"), null)));
|
||||
scenes.put("wall_sign_text", List.of(sign(Kind.WALL_SIGN, 180,
|
||||
txt(B, false, "Wall Sign", "Line 2", "Line 3", "Line 4"), null)));
|
||||
scenes.put("hanging_sign_text", List.of(sign(Kind.HANGING_SIGN, 180,
|
||||
txt(B, false, "Hanging", "sign text", "row 3", "row 4"), null)));
|
||||
scenes.put("sign_text_red", List.of(sign(Kind.SIGN, 180,
|
||||
txt(org.bukkit.DyeColor.RED, false, "RED INK", "colored", "sign text", ""), null)));
|
||||
scenes.put("sign_text_glow", List.of(sign(Kind.SIGN, 180,
|
||||
txt(org.bukkit.DyeColor.LIME, true, "GLOWING", "lime glow", "outline!", ""), null)));
|
||||
scenes.put("sign_text_front", List.of(sign(Kind.SIGN, 180,
|
||||
txt(B, false, "FRONT", "side", "", ""), txt(org.bukkit.DyeColor.BLUE, false, "BACK", "side", "", ""))));
|
||||
// View the BACK side head-on (facingDeg=0 turns the back face toward the camera).
|
||||
scenes.put("sign_view_back", List.of(sign(Kind.SIGN, 0,
|
||||
txt(B, false, "FRONTXY", "f2", "", ""), txt(org.bukkit.DyeColor.BLUE, false, "BACKXY", "b2", "", ""))));
|
||||
scenes.put("sign_1line", List.of(sign(Kind.SIGN, 180, txt(B, false, "", "SHOP", "", ""), null)));
|
||||
scenes.put("sign_2line", List.of(sign(Kind.SIGN, 180, txt(B, false, "", "Welcome", "home!", ""), null)));
|
||||
scenes.put("sign_long", List.of(sign(Kind.SIGN, 180, txt(B, false, "a very long line here", "", "", ""), null)));
|
||||
scenes.put("banner", List.of(new BlockEntityState(Kind.BANNER, 0, 0, 0, 0, null, 0xFFCC2020, null, null, null, null, null, List.of(), List.of(), null, null, null)));
|
||||
scenes.put("banner_patterned", List.of(new BlockEntityState(Kind.BANNER, 0, 0, 0, 0, null, 0xFFFFFFFF, null, null, null, null, null,
|
||||
List.of(new BannerPattern("stripe_bottom", 0xFFCC2020), new BannerPattern("cross", 0xFF2040CC), new BannerPattern("border", 0xFF20A020)), List.of(), null)));
|
||||
scenes.put("wall_banner", List.of(new BlockEntityState(Kind.WALL_BANNER, 0, 0, 0, 0, null, 0xFF2040CC, null, null, null, null, null, List.of(), List.of(), null)));
|
||||
List.of(new BannerPattern("stripe_bottom", 0xFFCC2020), new BannerPattern("cross", 0xFF2040CC), new BannerPattern("border", 0xFF20A020)), List.of(), null, null, null)));
|
||||
scenes.put("wall_banner", List.of(new BlockEntityState(Kind.WALL_BANNER, 0, 0, 0, 0, null, 0xFF2040CC, null, null, null, null, null, List.of(), List.of(), null, null, null)));
|
||||
scenes.put("bed", List.of(
|
||||
new BlockEntityState(Kind.BED, 0, 0, 0, 0, null, 0, "red", null, BedPart.FOOT, null, null, List.of(), List.of(), null),
|
||||
new BlockEntityState(Kind.BED, 0, 0, 1, 0, null, 0, "red", null, BedPart.HEAD, null, null, List.of(), List.of(), null)));
|
||||
scenes.put("shulker_box", List.of(new BlockEntityState(Kind.SHULKER_BOX, 0, 0, 0, 0, null, 0, null, null, null, null, null, List.of(), List.of(), null)));
|
||||
new BlockEntityState(Kind.BED, 0, 0, 0, 0, null, 0, "red", null, BedPart.FOOT, null, null, List.of(), List.of(), null, null, null),
|
||||
new BlockEntityState(Kind.BED, 0, 0, 1, 0, null, 0, "red", null, BedPart.HEAD, null, null, List.of(), List.of(), null, null, null)));
|
||||
scenes.put("shulker_box", List.of(new BlockEntityState(Kind.SHULKER_BOX, 0, 0, 0, 0, null, 0, null, null, null, null, null, List.of(), List.of(), null, null, null)));
|
||||
scenes.put("conduit", List.of(be(Kind.CONDUIT, 0, 0, 0, 0)));
|
||||
scenes.put("decorated_pot", List.of(be(Kind.DECORATED_POT, 0, 0, 0, 0)));
|
||||
scenes.put("decorated_pot_sherds", List.of(new BlockEntityState(Kind.DECORATED_POT, 0, 0, 0, 0, null, 0, null, null, null, null, null,
|
||||
List.of(), List.of("angler_pottery_sherd", "heart_pottery_sherd", "skull_pottery_sherd", "brick"), null)));
|
||||
List.of(), List.of("angler_pottery_sherd", "heart_pottery_sherd", "skull_pottery_sherd", "brick"), null, null, null)));
|
||||
scenes.put("bell", List.of(be(Kind.BELL, 0, 0, 0, 0)));
|
||||
scenes.put("head_skeleton", List.of(head("skeleton")));
|
||||
scenes.put("head_zombie", List.of(head("zombie")));
|
||||
@@ -149,7 +183,7 @@ public class BlockEntityTestRender {
|
||||
|
||||
static BlockEntityState head(String type) { return headKind(Kind.HEAD, type); }
|
||||
static BlockEntityState headKind(Kind kind, String type) {
|
||||
return new BlockEntityState(kind, 0, 0, 0, 0, null, 0, null, null, null, type, null, List.of(), List.of(), null);
|
||||
return new BlockEntityState(kind, 0, 0, 0, 0, null, 0, null, null, null, type, null, List.of(), List.of(), null, null, null);
|
||||
}
|
||||
|
||||
static BufferedImage renderScene(DefaultScreenRenderer renderer, WorldSnapshot world, SkyContext sky,
|
||||
|
||||
Reference in New Issue
Block a user