From 5330948dbd5a7bf3d83cc157c2c9f476d0e2ee5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Sun, 21 Jun 2026 15:32:53 +0200 Subject: [PATCH] enhance entity rendering: add support for villager levels, horse armor, llama decor, and saddle/chest states --- .../pixelpics/render/entity/EntityState.java | 10 ++- .../pixelpics/render/entity/cem/CemBaker.java | 76 ++++++++++++++++++- .../snapshot/BlockEntitySnapshotBuilder.java | 10 ++- .../snapshot/EntitySnapshotBuilder.java | 54 ++++++++++++- .../pixelpics/utils/MapColorPalette.java | 5 +- tools/BlockEntityTestRender.java | 2 +- tools/EntityTestRender.java | 24 +++++- 7 files changed, 170 insertions(+), 11 deletions(-) diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityState.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityState.java index c695942..2bb9a62 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityState.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityState.java @@ -11,7 +11,13 @@ public record EntityState( boolean baby, double width, double height, boolean player, String skinUrl, boolean slim, - String variant, // texture-selecting variant key (e.g. "ashen", "warm", "tabby"), or null + String variant, // texture-selecting variant key (e.g. "ashen", "warm", "tabby"); for villagers the biome type, or null int tint, // ARGB multiplier for tintable layers (sheep wool); 0 = none - double sizeScale // extra model scale (slime/magma-cube size); 1.0 = default + double sizeScale, // extra model scale (slime/magma-cube size); 1.0 = default + String profession, // villager profession key (e.g. "farmer", "librarian", "none"), or null + int villagerLevel, // villager profession level 1-5 (badge tier); 0 = none/unknown + String markings, // horse coat markings style (e.g. "white", "whitefield", "whitedots", "blackdots"), or null + boolean saddle, // horse/donkey/mule is saddled + boolean chest, // donkey/mule/llama is carrying a chest + String bodyEquip // horse armor material (iron/gold/diamond/leather) OR llama carpet colour OR "trader_llama"; null = none ) {} diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/CemBaker.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/CemBaker.java index 52ea9eb..830380a 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/CemBaker.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/CemBaker.java @@ -46,6 +46,14 @@ public final class CemBaker implements EntityBaker { public RenderedEntity bake(EntityState s) { int[][] tex = resolveTexture(s); String cem = EntityModels.cemModel(s.typeKey()); + // Same-UV overlays are composited straight into the base texture (no extra geometry -> no ray Z-fighting). + if (s.typeKey().equals("villager") || s.typeKey().equals("zombie_villager")) { + tex = compositeVillager(s, tex); + } else if (cem.equals("horse")) { + tex = compositeHorse(s, tex); // coat markings + horse armor + } else if (cem.equals("llama")) { + tex = compositeLlama(s, tex); // dyed/trader carpet decor + } CemModelLoader.CemModel model = models.get(cem); if (model == null || tex == null) return fallbackBox(s, tex); @@ -54,7 +62,12 @@ public final class CemBaker implements EntityBaker { // rotations and handedness; only px->block scaling is applied. Affine pre = Affine.scale(sc / 16.0); - java.util.Set hidden = HIDDEN_PARTS.getOrDefault(cem, java.util.Set.of()); + java.util.Set hidden = new java.util.HashSet<>(HIDDEN_PARTS.getOrDefault(cem, java.util.Set.of())); + // Donkeys/llamas carry the chest boxes inside the base model; hide them unless a chest is equipped. + if (!s.chest()) { + if (cem.equals("donkey")) { hidden.add("left_chest"); hidden.add("right_chest"); } + else if (cem.equals("llama")) { hidden.add("chest_left"); hidden.add("chest_right"); } + } List baked = new ArrayList<>(CemGeometry.bakeModel(model, tex, pre, hidden)); // Sheep: render the inflated, dye-tinted wool fur layer over the body (transparent where the face shows). if (s.typeKey().equals("sheep")) { @@ -74,6 +87,8 @@ public final class CemBaker implements EntityBaker { Face[] faces = BoxUv.build(mc, tex, model.texW(), model.texH()); baked.add(new CemGeometry.Baked(org, new double[]{org[0]+size[0], org[1]+size[1], org[2]+size[2]}, faces, pre)); } + // Saddle: an extra inflated layer from the *_saddle CEM model, showing only its saddle-specific parts. + if (s.saddle()) addSaddleLayer(s, cem, model, pre, baked); if (baked.isEmpty()) return fallbackBox(s, tex); double minY = Double.MAX_VALUE; @@ -104,6 +119,65 @@ public final class CemBaker implements EntityBaker { return null; } + // --- villager texture compositing (biome type + profession + level badge over the base body) --- + // Profession-level (1-5) -> badge texture, like Mojang's VillagerProfessionLayer. + private static final String[] LEVEL_BADGE = {"stone", "iron", "gold", "emerald", "diamond"}; + + private int[][] compositeVillager(EntityState s, int[][] base) { + if (base == null) return null; + String folder = s.typeKey(); // "villager" or "zombie_villager" + int[][] out = TextureOps.deepCopy(base); + // Biome-type clothing overlay (always, if known). + if (s.variant() != null) overlayIfPresent(out, "entity/" + folder + "/type/" + s.variant()); + String prof = s.profession(); + if (prof != null && !prof.equals("none")) { + overlayIfPresent(out, "entity/" + folder + "/profession/" + prof); + // Level badge: only for real professions (not the work-less nitwit) and a known level. + if (!prof.equals("nitwit") && s.villagerLevel() >= 1) { + int lvl = Math.min(5, s.villagerLevel()); + overlayIfPresent(out, "entity/" + folder + "/profession_level/" + LEVEL_BADGE[lvl - 1]); + } + } + return out; + } + + /** Alpha-composite an overlay texture onto {@code dst} in place, if it exists and matches dst's size. */ + private void overlayIfPresent(int[][] dst, String path) { + int[][] o = textures.get(ResourceLocation.parse(path)).orElse(null); + if (o == null || o.length != dst.length || o[0].length != dst[0].length) return; // missing or HD-mismatch + TextureOps.overlay(dst, o); + } + + // --- horse / llama equipment compositing (same UV layout as the base model) --- + private int[][] compositeHorse(EntityState s, int[][] base) { + if (base == null || (s.markings() == null && s.bodyEquip() == null)) return base; + int[][] out = TextureOps.deepCopy(base); + if (s.markings() != null) overlayIfPresent(out, "entity/horse/horse_markings_" + s.markings()); + if (s.bodyEquip() != null) overlayIfPresent(out, "entity/equipment/horse_body/" + s.bodyEquip()); + return out; + } + + private int[][] compositeLlama(EntityState s, int[][] base) { + if (base == null || s.bodyEquip() == null) return base; + int[][] out = TextureOps.deepCopy(base); + overlayIfPresent(out, "entity/equipment/llama_body/" + s.bodyEquip()); + return out; + } + + /** Bake the saddle as a separate inflated layer; only the saddle-specific parts (those not in the base model). */ + private void addSaddleLayer(EntityState s, String cem, CemModelLoader.CemModel base, Affine pre, List baked) { + String saddleModel = cem.equals("donkey") ? "donkey_saddle" : (cem.equals("horse") ? "horse_saddle" : null); + if (saddleModel == null) return; // llamas and other mounts have no saddle model + CemModelLoader.CemModel sm = models.get(saddleModel); + if (sm == null) return; + int[][] saddleTex = textures.get(ResourceLocation.parse("entity/equipment/" + s.typeKey() + "_saddle/saddle")).orElse(null); + if (saddleTex == null) return; + // Show only the saddle-specific parts: hide every part the base body model also defines. + java.util.Set hideBase = new java.util.HashSet<>(); + for (CemModelLoader.CemPart p : base.parts()) hideBase.add(p.name()); + baked.addAll(CemGeometry.bakeModel(sm, saddleTex, pre, hideBase)); + } + private RenderedEntity fallbackBox(EntityState s, int[][] tex) { double w = Math.max(0.3, s.width()) * 16 * s.sizeScale(), h = Math.max(0.3, s.height()) * 16 * s.sizeScale(); double[] from = {-w / 2, 0, -w / 2}; diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/BlockEntitySnapshotBuilder.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/BlockEntitySnapshotBuilder.java index a83b48f..248a99c 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/BlockEntitySnapshotBuilder.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/BlockEntitySnapshotBuilder.java @@ -110,8 +110,7 @@ public final class BlockEntitySnapshotBuilder { b.baseColorArgb(ColorUtil.dyeArgb(base, 0xFFFFFFFF)); List pats = new ArrayList<>(); for (Pattern p : banner.getPatterns()) { - String key = p.getPattern().key().value(); - pats.add(new BannerPattern(key, ColorUtil.dyeArgb(p.getColor(), 0xFFFFFFFF))); + pats.add(new BannerPattern(patternKey(p), ColorUtil.dyeArgb(p.getColor(), 0xFFFFFFFF))); } b.patterns(pats); } @@ -267,6 +266,13 @@ public final class BlockEntitySnapshotBuilder { return null; } + /** The banner pattern's path key (e.g. "stripe_bottom"); every PatternType key accessor is marked + * for removal in this API with no stable replacement, so the warning is suppressed here. */ + @SuppressWarnings("removal") + private static String patternKey(Pattern p) { + return p.getPattern().getKey().getKey(); + } + // --- small fluent builder to keep the 15-field record construction readable --- private static Builder base(Kind kind, int bx, int by, int bz, float yaw) { diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/EntitySnapshotBuilder.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/EntitySnapshotBuilder.java index e11683d..6d23861 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/EntitySnapshotBuilder.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/EntitySnapshotBuilder.java @@ -75,6 +75,12 @@ public final class EntitySnapshotBuilder { String variant = null; int tint = 0; double sizeScale = 1.0; + String profession = null; + int villagerLevel = 0; + String markings = null; + boolean saddle = false; + boolean chest = false; + String bodyEquip = null; try { // Slime & magma cube (MagmaCube extends Slime) scale their model by size (1/2/4). if (e instanceof org.bukkit.entity.Slime sl) sizeScale = sl.getSize(); @@ -93,8 +99,21 @@ public final class EntitySnapshotBuilder { variant = keyOf(r.getRabbitType()); } else if (e instanceof org.bukkit.entity.Horse h) { variant = keyOf(h.getColor()); + markings = markingsKey(h.getStyle()); + saddle = isSaddled(h); + bodyEquip = horseArmorKey(h); } else if (e instanceof org.bukkit.entity.Llama l) { variant = keyOf(l.getColor()); + chest = l.isCarryingChest(); + // Trader llamas wear a fixed decor; normal llamas carry a dyed carpet in the decor slot. + bodyEquip = (e instanceof org.bukkit.entity.TraderLlama) ? "trader_llama" : carpetKey(l); + } else if (e instanceof org.bukkit.entity.ChestedHorse ch) { + // Donkey & mule (llama already handled above). + chest = ch.isCarryingChest(); + saddle = isSaddled(ch); + } else if (e instanceof org.bukkit.entity.AbstractHorse ah) { + // Skeleton/zombie horse: only saddle (no colour/markings/armor variants). + saddle = isSaddled(ah); } else if (e instanceof org.bukkit.entity.Fox f) { variant = keyOf(f.getFoxType()); } else if (e instanceof org.bukkit.entity.MushroomCow mc) { @@ -107,8 +126,12 @@ public final class EntitySnapshotBuilder { variant = s.getColor() == null ? null : keyOf(s.getColor()); } else if (e instanceof org.bukkit.entity.ZombieVillager zv) { variant = keyOf(zv.getVillagerType()); + profession = keyOf(zv.getVillagerProfession()); + // ZombieVillager exposes no level via Bukkit -> no profession-level badge (matches vanilla). } else if (e instanceof org.bukkit.entity.Villager vi) { variant = keyOf(vi.getVillagerType()); + profession = keyOf(vi.getProfession()); + villagerLevel = vi.getVillagerLevel(); } else if (e instanceof org.bukkit.entity.Cow co) { variant = keyOf(co.getVariant()); } else if (e instanceof org.bukkit.entity.Pig pg) { @@ -122,7 +145,36 @@ public final class EntitySnapshotBuilder { return new EntityState(type, loc.getX(), loc.getY(), loc.getZ(), bodyYaw, baby, width, height, - player, skinUrl, slim, variant, tint, sizeScale); + player, skinUrl, slim, variant, tint, sizeScale, profession, villagerLevel, + markings, saddle, chest, bodyEquip); + } + + /** Horse coat markings overlay key (vanilla texture suffix); null for the plain NONE style. */ + private static String markingsKey(org.bukkit.entity.Horse.Style style) { + if (style == null || style == org.bukkit.entity.Horse.Style.NONE) return null; + return style.name().toLowerCase(java.util.Locale.ROOT).replace("_", ""); // WHITE_DOTS -> whitedots + } + + /** Horse armor material -> equipment/horse_body texture key (golden uses the "gold" file); null if none. */ + private static String horseArmorKey(org.bukkit.entity.Horse h) { + org.bukkit.inventory.ItemStack a = h.getInventory().getArmor(); + if (a == null || a.getType().isAir()) return null; + String k = a.getType().getKey().getKey().replace("_horse_armor", ""); + return k.equals("golden") ? "gold" : k; + } + + /** Llama carpet decor -> equipment/llama_body colour key; null if none. */ + private static String carpetKey(org.bukkit.entity.Llama l) { + org.bukkit.inventory.ItemStack d = l.getInventory().getDecor(); + if (d == null || d.getType().isAir()) return null; + String k = d.getType().getKey().getKey(); + return k.endsWith("_carpet") ? k.substring(0, k.length() - "_carpet".length()) : null; + } + + /** Whether a horse-like mount carries a saddle in its dedicated saddle slot. */ + private static boolean isSaddled(org.bukkit.entity.AbstractHorse h) { + org.bukkit.inventory.ItemStack st = h.getInventory().getSaddle(); + return st != null && !st.getType().isAir(); } /** Registry/Keyed values yield their key path; plain enums yield their lower-case name. */ diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapColorPalette.java b/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapColorPalette.java index 27f45fd..e340fe0 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapColorPalette.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapColorPalette.java @@ -21,6 +21,9 @@ public final class MapColorPalette { private MapColorPalette() {} + // MapPalette.getColor(byte) is deprecated for removal with no replacement for enumerating the + // index->colour palette; suppress until the API offers an alternative. + @SuppressWarnings("removal") private static synchronized void ensure() { if (initialized) return; List idx = new ArrayList<>(); @@ -33,7 +36,7 @@ public final class MapColorPalette { } catch (Throwable t) { continue; } - if (c == null || c.getAlpha() < 255) continue; // skip transparent slots + if (c.getAlpha() < 255) continue; // skip transparent slots idx.add((byte) i); rgbs.add((c.getRed() << 16) | (c.getGreen() << 8) | c.getBlue()); labs.add(rgbToLab(c.getRed(), c.getGreen(), c.getBlue())); diff --git a/tools/BlockEntityTestRender.java b/tools/BlockEntityTestRender.java index e07cb2b..216f88c 100644 --- a/tools/BlockEntityTestRender.java +++ b/tools/BlockEntityTestRender.java @@ -54,7 +54,7 @@ public class BlockEntityTestRender { DefaultScreenRenderer renderer = new DefaultScreenRenderer(registry, tint, textures, baker, beBaker, log); BlockData air = (BlockData) Proxy.newProxyInstance(BlockEntityTestRender.class.getClassLoader(), - new Class[]{BlockData.class}, (p, m, a) -> { + new Class[]{BlockData.class}, (p, m, a) -> { switch (m.getName()) { case "getMaterial": return Material.AIR; case "equals": return p == a[0]; diff --git a/tools/EntityTestRender.java b/tools/EntityTestRender.java index 2eb30a1..6931f0d 100644 --- a/tools/EntityTestRender.java +++ b/tools/EntityTestRender.java @@ -48,7 +48,24 @@ public class EntityTestRender { Map.entry("parrot", "red"), Map.entry("rabbit", "brown"), Map.entry("horse", "white"), Map.entry("llama", "creamy"), Map.entry("trader_llama", "creamy"), Map.entry("fox", "red"), Map.entry("mooshroom", "red"), Map.entry("frog", "temperate"), Map.entry("panda", "normal"), - Map.entry("cow", "temperate"), Map.entry("pig", "temperate"), Map.entry("chicken", "temperate") + Map.entry("cow", "temperate"), Map.entry("pig", "temperate"), Map.entry("chicken", "temperate"), + Map.entry("villager", "taiga"), Map.entry("zombie_villager", "swamp") + ); + + // Villager profession / level for the standalone render (biome type comes from VAR above). + static final Map PROF = Map.ofEntries( + Map.entry("villager", "librarian"), Map.entry("zombie_villager", "farmer") + ); + static final Map LVL = Map.ofEntries( + Map.entry("villager", 5) + ); + + // Horse/llama/donkey equipment for the standalone render. + static final Map MARK = Map.of("horse", "blackdots"); // coat markings + static final java.util.Set SADDLE = java.util.Set.of("horse", "donkey", "mule"); + static final java.util.Set CHEST = java.util.Set.of("llama", "donkey"); + static final Map EQUIP = Map.ofEntries( // armor / carpet + Map.entry("horse", "diamond"), Map.entry("llama", "red"), Map.entry("trader_llama", "trader_llama") ); public static void main(String[] args) throws Exception { @@ -67,7 +84,7 @@ public class EntityTestRender { DefaultScreenRenderer renderer = new DefaultScreenRenderer(registry, tint, textures, baker, beBaker, log); BlockData air = (BlockData) Proxy.newProxyInstance(EntityTestRender.class.getClassLoader(), - new Class[]{BlockData.class}, (p, m, a) -> { + new Class[]{BlockData.class}, (p, m, a) -> { switch (m.getName()) { case "getMaterial": return Material.AIR; case "equals": return p == a[0]; @@ -126,7 +143,8 @@ public class EntityTestRender { SkyContext sky, String key, float yaw) { boolean isPlayer = key.equals("player"); EntityState s = new EntityState(key, 0, 0, 0, yaw, false, 0.8, 1.0, - isPlayer, null, false, VAR.get(key), 0, 1.0); + isPlayer, null, false, VAR.get(key), 0, 1.0, PROF.get(key), LVL.getOrDefault(key, 0), + MARK.get(key), SADDLE.contains(key), CHEST.contains(key), EQUIP.get(key)); RenderedEntity re = baker.bake(s); double cx = (re.aabbMin[0] + re.aabbMax[0]) / 2; double cy = (re.aabbMin[1] + re.aabbMax[1]) / 2;