add survival mode support: camera, film system, and dynamic crafting recipes for photos

This commit is contained in:
2026-06-21 17:30:24 +02:00
parent fed94f97d1
commit 220cda1deb
14 changed files with 948 additions and 95 deletions
@@ -19,5 +19,24 @@ public record EntityState(
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
) {}
String bodyEquip, // horse armor material (iron/gold/diamond/leather) OR llama carpet colour OR "trader_llama"; null = none
Equipment equipment, // worn humanoid armor (players, armor stands, zombies, …); null = none/not a wearer
boolean invisible // entity is invisible -> render only its equipment (like vanilla), not the body
) {
/** Worn armor (4 slots) of a humanoid wearer; any field may be null. */
public record Equipment(EquipPiece head, EquipPiece chest, EquipPiece legs, EquipPiece feet) {
public boolean isEmpty() {
return head == null && chest == null && legs == null && feet == null;
}
}
/** One worn armor piece. */
public record EquipPiece(
String asset, // equipment asset id, e.g. "diamond", "leather", "elytra", "turtle_scute"
int dyeColor, // ARGB tint for dyeable (leather) armor; 0 = undyed/not dyeable
String trimMaterial, // armor-trim material key (e.g. "diamond"); null = no trim
String trimPattern, // armor-trim pattern key (e.g. "coast"); null = no trim
boolean glint // item is enchanted -> render the enchantment glint
) {}
}
@@ -52,4 +52,63 @@ public final class TextureOps {
}
}
}
/**
* Palette-swap recolouring used for armor trims: each (non-transparent) texel of {@code tex} is matched
* to its nearest entry in the grayscale source palette {@code from} (8-colour key, RGB distance) and
* replaced by the same-index colour from the material palette {@code to}, preserving alpha. The vanilla
* trim patterns are authored in those 8 key shades, so this reproduces the runtime palette swap.
*/
public static void paletteSwap(int[][] tex, int[] from, int[] to) {
int n = Math.min(from.length, to.length);
if (n == 0) return;
for (int[] row : tex) {
for (int x = 0; x < row.length; x++) {
int p = row[x];
int a = (p >>> 24) & 0xFF;
if (a == 0) continue;
int pr = (p >> 16) & 0xFF, pg = (p >> 8) & 0xFF, pb = p & 0xFF;
int best = 0, bestD = Integer.MAX_VALUE;
for (int i = 0; i < n; i++) {
int c = from[i];
int dr = pr - ((c >> 16) & 0xFF), dg = pg - ((c >> 8) & 0xFF), db = pb - (c & 0xFF);
int d = dr * dr + dg * dg + db * db;
if (d < bestD) { bestD = d; best = i; }
}
row[x] = (a << 24) | (to[best] & 0xFFFFFF);
}
}
}
/**
* Static approximation of the (animated) enchantment glint: a single glint frame is tiled over the
* texture and additively blended — tinted by {@code glintColor} and scaled by {@code strength} and the
* frame's luminance — onto the texture's opaque texels only (so it sheens the armor, not the
* transparent background). Not animated; just a frozen purple shimmer.
*/
public static void addGlint(int[][] tex, int[][] glint, int glintColor, double strength) {
if (glint == null || glint.length == 0 || glint[0].length == 0) return;
int gh = glint.length, gw = glint[0].length;
int cr = (glintColor >> 16) & 0xFF, cg = (glintColor >> 8) & 0xFF, cb = glintColor & 0xFF;
for (int y = 0; y < tex.length; y++) {
for (int x = 0; x < tex[y].length; x++) {
int p = tex[y][x];
int a = (p >>> 24) & 0xFF;
if (a == 0) continue; // only sheen actual armor pixels
int gp = glint[y % gh][x % gw];
int ga = (gp >>> 24) & 0xFF;
int gl = Math.max((gp >> 16) & 0xFF, Math.max((gp >> 8) & 0xFF, gp & 0xFF));
double inten = strength * (ga / 255.0) * (gl / 255.0);
if (inten <= 0) continue;
int r = clamp(((p >> 16) & 0xFF) + (int) (cr * inten));
int g = clamp(((p >> 8) & 0xFF) + (int) (cg * inten));
int b = clamp((p & 0xFF) + (int) (cb * inten));
tex[y][x] = (a << 24) | (r << 16) | (g << 8) | b;
}
}
}
private static int clamp(int v) {
return v < 0 ? 0 : (Math.min(v, 255));
}
}
@@ -53,9 +53,14 @@ public final class CemBaker implements EntityBaker<EntityState> {
tex = compositeHorse(s, tex); // coat markings + horse armor
} else if (cem.equals("llama")) {
tex = compositeLlama(s, tex); // dyed/trader carpet decor
} else if (cem.equals("nautilus")) {
tex = compositeNautilus(s, tex); // body armor + saddle (same-UV overlays)
}
CemModelLoader.CemModel model = models.get(cem);
if (model == null || tex == null) return fallbackBox(s, tex);
// A visible entity needs its body model+texture; an invisible one renders only its equipment
// (vanilla hides the body but still draws worn armor).
boolean invisible = s.invisible();
if (!invisible && (model == null || tex == null)) return fallbackBox(s, tex);
double sc = (s.baby() ? 0.5 : 1.0) * s.sizeScale();
// CEM model px -> entity-local blocks. Identity orientation (no axis flip) preserves ALL part
@@ -68,31 +73,44 @@ public final class CemBaker implements EntityBaker<EntityState> {
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<CemGeometry.Baked> 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")) {
CemModelLoader.CemModel wool = models.get("sheep_wool");
int[][] woolTex = textures.get(ResourceLocation.parse("entity/sheep/sheep_wool")).orElse(null);
if (wool != null && woolTex != null) {
int[][] t = TextureOps.deepCopy(woolTex);
if (s.tint() != 0) TextureOps.tint(t, s.tint());
baked.addAll(CemGeometry.bakeModel(wool, t, pre, hidden));
}
}
// Guardian: the CEM model ships a RIGHT body side-panel but no left one, and the main body box's
// left face is transparent in the texture → a see-through hole on the left. Add the mirrored left panel.
if (cem.equals("guardian")) {
double[] org = {-8, 2, -6}, size = {2, 12, 12};
ModelCube mc = new ModelCube(org, size, 0, new double[]{0, 28}, true);
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);
// The body model is baked even when invisible — not drawn, but used as the ground-snap reference
// so equipment stays at body height (e.g. a lone helmet sits at the head, not on the floor).
List<CemGeometry.Baked> body = (model != null && tex != null)
? CemGeometry.bakeModel(model, tex, pre, hidden) : java.util.List.of();
List<CemGeometry.Baked> baked = new ArrayList<>();
if (!invisible) {
baked.addAll(body);
// Sheep: render the inflated, dye-tinted wool fur layer over the body (transparent where the face shows).
if (s.typeKey().equals("sheep")) {
CemModelLoader.CemModel wool = models.get("sheep_wool");
int[][] woolTex = textures.get(ResourceLocation.parse("entity/sheep/sheep_wool")).orElse(null);
if (wool != null && woolTex != null) {
int[][] t = TextureOps.deepCopy(woolTex);
if (s.tint() != 0) TextureOps.tint(t, s.tint());
baked.addAll(CemGeometry.bakeModel(wool, t, pre, hidden));
}
}
// Guardian: the CEM model ships a RIGHT body side-panel but no left one, and the main body box's
// left face is transparent in the texture → a see-through hole on the left. Add the mirrored left panel.
if (cem.equals("guardian")) {
double[] org = {-8, 2, -6}, size = {2, 12, 12};
ModelCube mc = new ModelCube(org, size, 0, new double[]{0, 28}, true);
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);
}
// Humanoid armor (players, armor stands, zombies, …): extra inflated armor_layer models. Rendered
// even when invisible, so an invisible armored entity shows floating armor like in vanilla.
if (s.equipment() != null) addArmorLayers(s, pre, baked);
if (baked.isEmpty()) return invisible ? null : fallbackBox(s, tex);
// Ground-snap from the body extent (preserves visible behaviour); fall back to the drawn geometry.
List<CemGeometry.Baked> ref = (invisible && !body.isEmpty()) ? body : baked;
double minY = Double.MAX_VALUE;
for (CemGeometry.Baked b : baked) minY = Math.min(minY, b.minWorldY());
for (CemGeometry.Baked b : ref) minY = Math.min(minY, b.minWorldY());
Affine place = Affine.translation(s.x(), s.y(), s.z())
.mul(Affine.rotY(Math.PI - Math.toRadians(s.bodyYaw())))
@@ -164,6 +182,15 @@ public final class CemBaker implements EntityBaker<EntityState> {
return out;
}
/** Nautilus body armor + saddle: same 128² UV as the nautilus texture, so composited as overlays. */
private int[][] compositeNautilus(EntityState s, int[][] base) {
if (base == null || (s.bodyEquip() == null && !s.saddle())) return base;
int[][] out = TextureOps.deepCopy(base);
if (s.bodyEquip() != null) overlayIfPresent(out, "entity/equipment/nautilus_body/" + s.bodyEquip());
if (s.saddle()) overlayIfPresent(out, "entity/equipment/nautilus_saddle/saddle");
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<CemGeometry.Baked> baked) {
String saddleModel = cem.equals("donkey") ? "donkey_saddle" : (cem.equals("horse") ? "horse_saddle" : null);
@@ -178,6 +205,103 @@ public final class CemBaker implements EntityBaker<EntityState> {
baked.addAll(CemGeometry.bakeModel(sm, saddleTex, pre, hideBase));
}
// --- humanoid armor ---------------------------------------------------------------------------
// Vanilla splits worn armor across two inflated layer models that overlay the standard humanoid body:
// armor_layer_1 (texture entity/equipment/humanoid/<mat>): head=helmet, body+arms=chestplate,
// shoes=boots;
// armor_layer_2 (texture entity/equipment/humanoid_leggings/<mat>): waist+legs=leggings.
// Each slot may use a different material, so each is baked separately, showing only its parts.
private static final java.util.Set<String> ARMOR1_HEAD = java.util.Set.of("head");
private static final java.util.Set<String> ARMOR1_CHEST = java.util.Set.of("body", "left_arm", "right_arm");
private static final java.util.Set<String> ARMOR1_FEET = java.util.Set.of("left_shoe", "right_shoe");
private static final java.util.Set<String> ARMOR2_LEGS = java.util.Set.of("waist", "left_leg", "right_leg");
private static final int GLINT_COLOR = 0xFF8040CC; // approximated enchantment-glint purple
private void addArmorLayers(EntityState s, Affine pre, List<CemGeometry.Baked> baked) {
EntityState.Equipment eq = s.equipment();
bakeArmorPiece(eq.head(), "armor_layer_1", ARMOR1_HEAD, "humanoid", pre, baked);
// Chest slot: an elytra renders its own wing model instead of the chestplate layer.
if (eq.chest() != null && eq.chest().asset().equals("elytra")) {
bakeElytra(eq.chest(), pre, baked);
} else {
bakeArmorPiece(eq.chest(), "armor_layer_1", ARMOR1_CHEST, "humanoid", pre, baked);
}
bakeArmorPiece(eq.legs(), "armor_layer_2", ARMOR2_LEGS, "humanoid_leggings", pre, baked);
bakeArmorPiece(eq.feet(), "armor_layer_1", ARMOR1_FEET, "humanoid", pre, baked);
}
/** Bake one armor slot: its layer model with only {@code show} parts, textured for the material. */
private void bakeArmorPiece(EntityState.EquipPiece piece, String modelName, java.util.Set<String> show,
String layerFolder, Affine pre, List<CemGeometry.Baked> baked) {
if (piece == null) return;
CemModelLoader.CemModel model = models.get(modelName);
if (model == null) return;
int[][] tex = buildArmorTexture(piece, layerFolder);
if (tex == null) return;
java.util.Set<String> hidden = new java.util.HashSet<>();
for (CemModelLoader.CemPart p : model.parts()) if (!show.contains(p.name())) hidden.add(p.name());
baked.addAll(CemGeometry.bakeModel(model, tex, pre, hidden));
}
/** Resolve + composite a single armor slot's texture: material base (+leather dye/overlay), trim, glint. */
private int[][] buildArmorTexture(EntityState.EquipPiece piece, String layerFolder) {
String asset = piece.asset();
int[][] base = textures.get(ResourceLocation.parse("entity/equipment/" + layerFolder + "/" + asset)).orElse(null);
if (base == null) return null;
int[][] out = TextureOps.deepCopy(base);
// Leather is dyeable: tint the base layer, then composite the (undyed) overlay layer on top.
if (asset.equals("leather")) {
if (piece.dyeColor() != 0) TextureOps.tint(out, piece.dyeColor());
overlayIfPresent(out, "entity/equipment/" + layerFolder + "/leather_overlay");
}
if (piece.trimMaterial() != null && piece.trimPattern() != null) {
applyTrim(out, layerFolder, piece.trimPattern(), piece.trimMaterial(), asset);
}
if (piece.glint()) applyGlint(out);
return out;
}
/** Armor trim: recolour the grayscale pattern via the material palette (same UV) and overlay it. */
private void applyTrim(int[][] armorTex, String layerFolder, String pattern, String material, String armorAsset) {
int[][] mask = textures.get(ResourceLocation.parse("trims/entity/" + layerFolder + "/" + pattern)).orElse(null);
if (mask == null || mask.length != armorTex.length || mask[0].length != armorTex[0].length) return;
int[] from = palette8("trims/color_palettes/trim_palette");
// Vanilla uses the darker palette variant when the trim material matches the armor material.
String palette = material;
if (material.equals(armorAsset)
&& textures.get(ResourceLocation.parse("trims/color_palettes/" + material + "_darker")).isPresent()) {
palette = material + "_darker";
}
int[] to = palette8("trims/color_palettes/" + palette);
if (from == null || to == null) return;
int[][] colored = TextureOps.deepCopy(mask);
TextureOps.paletteSwap(colored, from, to);
TextureOps.overlay(armorTex, colored);
}
/** First row of an 8×1 trim colour-palette texture; null if missing. */
private int[] palette8(String path) {
int[][] p = textures.get(ResourceLocation.parse(path)).orElse(null);
return (p == null || p.length == 0) ? null : p[0];
}
/** Static enchantment-glint approximation over an armor/elytra/item texture (in place). */
private void applyGlint(int[][] tex) {
textures.get(ResourceLocation.parse("misc/enchanted_glint_armor"))
.ifPresent(glint -> TextureOps.addGlint(tex, glint, GLINT_COLOR, 0.6));
}
/** Elytra in the chest slot: bake the wing model with the default elytra texture (no chestplate). */
private void bakeElytra(EntityState.EquipPiece piece, Affine pre, List<CemGeometry.Baked> baked) {
CemModelLoader.CemModel model = models.get("elytra");
if (model == null) return;
int[][] tex = textures.get(ResourceLocation.parse("entity/equipment/wings/elytra")).orElse(null);
if (tex == null) return;
int[][] out = TextureOps.deepCopy(tex);
if (piece.glint()) applyGlint(out);
baked.addAll(CemGeometry.bakeModel(model, out, pre, java.util.Set.of()));
}
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};
@@ -33,6 +33,15 @@ public final class EntitySnapshotBuilder {
"potion", "ender_pearl", "tnt", "falling_block", "item"
);
// Entities whose vanilla renderer draws the humanoid armor layers (HumanoidArmorLayer) and held
// items. Their CEM bodies share standard humanoid proportions, so the armor_layer_1/2 models align.
private static final java.util.Set<String> HUMANOID_ARMOR_WEARERS = java.util.Set.of(
"player", "mannequin", "armor_stand", "giant",
"zombie", "husk", "drowned", "zombie_villager", "zombified_piglin",
"skeleton", "stray", "wither_skeleton", "bogged",
"piglin", "piglin_brute"
);
public static List<EntityState> build(Location eye, List<Vector> rayMap, double maxDistance, UUID shooter) {
FrustumBounds bounds = FrustumBounds.of(eye.toVector(), rayMap, maxDistance);
Collection<Entity> nearby = bounds.nearbyEntities(eye.getWorld(), 2);
@@ -58,7 +67,13 @@ public final class EntitySnapshotBuilder {
}
boolean baby = (e instanceof Ageable a && !a.isAdult())
|| (e instanceof org.bukkit.entity.Zombie z && z.isBaby());
|| (e instanceof org.bukkit.entity.Zombie z && z.isAdult());
// Invisible entities render only their equipment (like vanilla): the generic invisible flag, an
// invisibility potion effect, or an explicitly-hidden armor stand.
boolean invisible = e.isInvisible()
|| (e instanceof org.bukkit.entity.ArmorStand as && !as.isVisible())
|| (e instanceof LivingEntity inv && inv.hasPotionEffect(org.bukkit.potion.PotionEffectType.INVISIBILITY));
double width = safeDim(e::getWidth, () -> e.getBoundingBox().getWidthX());
double height = safeDim(e::getHeight, () -> e.getBoundingBox().getHeight());
@@ -114,6 +129,14 @@ public final class EntitySnapshotBuilder {
} 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.AbstractNautilus && e instanceof LivingEntity nl) {
// Nautilus body armor + saddle are same-UV overlays (like horse armor).
org.bukkit.inventory.EntityEquipment eq = nl.getEquipment();
if (eq != null) {
bodyEquip = equipAsset(eq.getItem(org.bukkit.inventory.EquipmentSlot.BODY));
org.bukkit.inventory.ItemStack sd = eq.getItem(org.bukkit.inventory.EquipmentSlot.SADDLE);
saddle = sd != null && !sd.getType().isAir();
}
} else if (e instanceof org.bukkit.entity.Fox f) {
variant = keyOf(f.getFoxType());
} else if (e instanceof org.bukkit.entity.MushroomCow mc) {
@@ -143,10 +166,62 @@ public final class EntitySnapshotBuilder {
// Unsupported on this server version — fall back to the base texture.
}
EntityState.Equipment equipment = null;
if (HUMANOID_ARMOR_WEARERS.contains(type) && e instanceof LivingEntity wearer) {
equipment = captureEquipment(wearer);
}
return new EntityState(type, loc.getX(), loc.getY(), loc.getZ(),
bodyYaw, baby, width, height,
player, skinUrl, slim, variant, tint, sizeScale, profession, villagerLevel,
markings, saddle, chest, bodyEquip);
markings, saddle, chest, bodyEquip, equipment, invisible);
}
/** Worn armor (4 slots) from a humanoid wearer; null when nothing is equipped. */
private static EntityState.Equipment captureEquipment(LivingEntity le) {
try {
org.bukkit.inventory.EntityEquipment eq = le.getEquipment();
if (eq == null) return null;
EntityState.Equipment equip = new EntityState.Equipment(
armorPiece(eq.getHelmet()), armorPiece(eq.getChestplate()),
armorPiece(eq.getLeggings()), armorPiece(eq.getBoots()));
return equip.isEmpty() ? null : equip;
} catch (Throwable t) {
return null;
}
}
/** One armor slot -> EquipPiece (asset, leather dye, trim, glint); null for empty / non-armor items. */
private static EntityState.EquipPiece armorPiece(org.bukkit.inventory.ItemStack it) {
if (it == null || it.getType().isAir()) return null;
String asset = armorAsset(it.getType());
if (asset == null) return null; // not a humanoid-armor item (e.g. a mob head / pumpkin)
int dye = 0;
String trimMat = null, trimPat = null;
boolean glint = false;
if (it.hasItemMeta()) {
org.bukkit.inventory.meta.ItemMeta meta = it.getItemMeta();
glint = meta.hasEnchants();
if (meta instanceof org.bukkit.inventory.meta.LeatherArmorMeta lam) dye = lam.getColor().asARGB();
if (meta instanceof org.bukkit.inventory.meta.ArmorMeta am && am.getTrim() != null) {
trimMat = keyOf(am.getTrim().getMaterial());
trimPat = keyOf(am.getTrim().getPattern());
}
}
return new EntityState.EquipPiece(asset, dye, trimMat, trimPat, glint);
}
/** Item Material -> equipment asset id (= texture name): strips the slot suffix; null if not armor. */
private static String armorAsset(org.bukkit.Material m) {
String key = m.getKey().getKey();
if (key.equals("elytra")) return "elytra";
if (key.equals("turtle_helmet")) return "turtle_scute";
String base = null;
for (String suf : new String[]{"_helmet", "_chestplate", "_leggings", "_boots"}) {
if (key.endsWith(suf)) { base = key.substring(0, key.length() - suf.length()); break; }
}
if (base == null) return null;
return base.equals("golden") ? "gold" : base;
}
/** Horse coat markings overlay key (vanilla texture suffix); null for the plain NONE style. */
@@ -171,6 +246,17 @@ public final class EntitySnapshotBuilder {
return k.endsWith("_carpet") ? k.substring(0, k.length() - "_carpet".length()) : null;
}
/** Equipment asset id (= equipment/<asset> texture name) from an item's EQUIPPABLE component; null if none. */
private static String equipAsset(org.bukkit.inventory.ItemStack it) {
if (it == null || it.getType().isAir()) return null;
try {
var comp = it.getData(io.papermc.paper.datacomponent.DataComponentTypes.EQUIPPABLE);
if (comp != null && comp.assetId() != null) return comp.assetId().value();
} catch (Throwable ignored) {
}
return 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();