diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityCube.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityCube.java index c17bb84..fed0a43 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityCube.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityCube.java @@ -13,10 +13,16 @@ public final class EntityCube { public final Face[] faces; // by Direction.ordinal() public final Affine toWorld; public final Affine toLocal; // inverse + public final boolean additive; // emissive, added to what lies behind (energy swirl) instead of blended public final double[] aabbMin = {Double.MAX_VALUE, Double.MAX_VALUE, Double.MAX_VALUE}; public final double[] aabbMax = {-Double.MAX_VALUE, -Double.MAX_VALUE, -Double.MAX_VALUE}; public EntityCube(double[] from, double[] to, Face[] faces, Affine toWorld) { + this(from, to, faces, toWorld, false); + } + + public EntityCube(double[] from, double[] to, Face[] faces, Affine toWorld, boolean additive) { + this.additive = additive; this.from = from; this.to = to; this.faces = faces; diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityIntersector.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityIntersector.java index 0396385..397fa11 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityIntersector.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityIntersector.java @@ -103,7 +103,7 @@ public final class EntityIntersector { Vector normal = new Vector(n[0], n[1], n[2]).normalize(); // A far/back face is seen from inside: shade it as facing the viewer. if (normal.getX() * dx + normal.getY() * dy + normal.getZ() * dz > 0) normal.multiply(-1); - return new FaceHit(t, world, normal, color, -1); + return new FaceHit(t, world, normal, color, -1, cube.additive); } private static double frac(double v, double lo, double hi) { 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 e3c326c..5aa5b2a 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 @@ -35,7 +35,10 @@ public record EntityState( boolean passenger, // riding something (vanilla sitting pose) ItemSpec rightHand, // item in the RIGHT hand (main or off hand, resolved by handedness); null = empty ItemSpec leftHand, // item in the LEFT hand; null = empty - ItemSpec headItem // non-armor item worn in the head slot (pumpkin, skull, block, …); null = none + ItemSpec headItem, // non-armor item worn in the head slot (pumpkin, skull, block, …); null = none + int collar, // ARGB dye of a tamed cat's/wolf's collar; 0 = no collar (untamed) + boolean powered, // energy-swirl layer: charged creeper, wither below half health + ItemSpec mouthItem // item carried in the mouth (fox/dolphin main hand); null = none ) { /** Worn armor (4 slots) of a humanoid wearer; any field may be null. */ 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 e1223ec..dab4e7f 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 @@ -110,6 +110,10 @@ public final class CemBaker implements EntityBaker { String m = "entity/horse/horse_markings_" + s.markings(); overlayIfPresent(tex, ch != null && ch.babyModel ? m + "_baby" : m); } + if (s.collar() != 0 && tex != null && layerModels(base).stream().noneMatch(n -> n.contains("CollarLayer."))) { + // Collar drawn on the body model itself (WolfCollarLayer: parent model, same UV) -> composite it. + tex = withCollar(tex, base, ch != null && ch.babyModel, s.collar()); + } if (s.typeKey().equals("tropical_fish") && tex != null && s.tint() != 0) { tex = TextureOps.deepCopy(tex); TextureOps.tint(tex, s.tint()); // white base body tinted with the body colour @@ -160,15 +164,16 @@ public final class CemBaker implements EntityBaker { : List.of(); List baked = new ArrayList<>(); + List glow = new ArrayList<>(); // additive energy-swirl layers if (!invisible) { baked.addAll(body); - if (vanilla) addVanillaLayers(s, ch, pose, show, tex, baked); + if (vanilla) addVanillaLayers(s, ch, pose, show, tex, baked, glow); } // Humanoid armor / elytra: drawn even when invisible (vanilla "floating armor"). if (vanilla && s.equipment() != null) addArmorLayers(s, ch, pose, baked); List itemCubes = new ArrayList<>(); if (vanilla && items != null) addItems(s, frames, isIllager(info), itemCubes); - if (baked.isEmpty() && itemCubes.isEmpty()) return invisible ? null : fallbackBox(s, tex); + if (baked.isEmpty() && glow.isEmpty() && itemCubes.isEmpty()) return invisible ? null : fallbackBox(s, tex); // Legacy CEM models: snap the lowest point to the ground (their rest geometry has no reliable origin). if (place == null) { @@ -182,6 +187,9 @@ public final class CemBaker implements EntityBaker { List cubes = new ArrayList<>(baked.size()); for (CemGeometry.Baked b : baked) cubes.add(new EntityCube(b.from(), b.to(), b.faces(), place.mul(b.world()))); + for (CemGeometry.Baked b : glow) { + cubes.add(new EntityCube(b.from(), b.to(), b.faces(), place.mul(b.world()), true)); + } for (EntityCube c : itemCubes) cubes.add(new EntityCube(c.from, c.to, c.faces, place.mul(c.toWorld))); return RenderedEntity.of(cubes); } @@ -260,6 +268,7 @@ public final class CemBaker implements EntityBaker { items.bake(item, "ground", false, x, out); } } + addMouthItem(s, frames, out); ItemSpec head = s.headItem(); Affine h = frames.get("head"); if (head != null && h != null) { @@ -280,6 +289,27 @@ public final class CemBaker implements EntityBaker { } } + /** + * Item carried in the mouth, rendered in the "ground" context like vanilla (HoldingEntityRenderState). + * Fox (FoxHeldItemLayer, awake): head pivot · [baby: S(0.75)] · head rotation · T(0.06, 0.27 [baby 0.26], + * -0.5) · rotX(90) — the head frame carries exactly that pivot + rotation. Dolphin (DolphinCarryingItemLayer) + * at the model root · T(0, 1, -1) (its xRot-dependent shift is 0: PixelPics dolphins aren't pitched). + */ + private void addMouthItem(EntityState s, Map frames, List out) { + ItemSpec item = s.mouthItem(); + if (item == null) return; + boolean baby = s.baby(); + Affine x = null; + if (s.typeKey().equals("fox") && frames.containsKey("head")) { + x = frames.get("head").mul(CemGeometry.JAVA_LOCAL); + if (baby) x = x.mul(Affine.scale(0.75)); + x = x.mul(Affine.translation(0.06, baby ? 0.26 : 0.27, -0.5)).mul(Affine.rotX(Math.PI / 2)); + } else if (s.typeKey().equals("dolphin") && frames.containsKey("root")) { + x = frames.get("root").mul(CemGeometry.JAVA_LOCAL).mul(Affine.translation(0, 1, -1)); + } + if (x != null) items.bake(item, "ground", false, x, out); + } + private static boolean isIllager(CemModelLoader.EntityInfo info) { return info != null && info.poses().keySet().stream().anyMatch(k -> k.startsWith("arms_")); } @@ -373,7 +403,13 @@ public final class CemBaker implements EntityBaker { // --- vanilla render layers ----------------------------------------------------------------------- private void addVanillaLayers( - EntityState s, Choice ch, Pose pose, Set show, int[][] bodyTex, List baked) { + EntityState s, + Choice ch, + Pose pose, + Set show, + int[][] bodyTex, + List baked, + List glow) { String base = ch.base; boolean baby = ch.babyModel; for (String n : layerModels(base)) { @@ -392,6 +428,21 @@ public final class CemBaker implements EntityBaker { baked.addAll(pose.bake(lm, t, show)); } case "TropicalFishPatternLayer" -> addTropicalPattern(s, n, lm, pose, baked); + case "CatCollarLayer" -> { + // Own collar model (inflated by 0.01), collar texture tinted with the collar's dye colour. + if (s.collar() == 0 || lm.texture() == null) continue; + int[][] t = texture(lm.texture()); + if (t == null) continue; + t = TextureOps.deepCopy(t); + TextureOps.tint(t, s.collar()); + baked.addAll(pose.bake(lm, t, show)); + } + case "CreeperPowerLayer", "WitherArmorLayer" -> { + // EnergySwirlLayer: charged creeper / wither below half health; drawn additively. + if (!s.powered() || lm.texture() == null) continue; + int[][] t = energySwirl(lm.texture(), layerClass.equals("WitherArmorLayer")); + if (t != null) glow.addAll(pose.bake(lm, t, show)); + } case "SlimeOuterLayer" -> { // Translucent outer cube on the body texture (alpha < 255 there; blended by the tracer). int[][] t = lm.texture() != null ? texture(lm.texture()) : bodyTex; @@ -475,6 +526,49 @@ public final class CemBaker implements EntityBaker { return out; } + // --- energy swirl & collars ---------------------------------------------------------------------- + + // A photo has no time: the swirl's scrolling texture is frozen at this age (ticks). Chosen so the sparse + // wither_armor pattern covers every wither part (at e.g. 42 the side heads land on empty rows). + private static final float SWIRL_AGE = 59f; + + /** + * Energy-swirl texture (vanilla EnergySwirlLayer / RenderTypes.energySwirl): the texture matrix shifts the UV + * by ({@code xOffset(age) % 1}, {@code age·0.01 % 1}) on a repeating texture — baked here as a wrapped copy + * of the texture rolled by that offset — and the vertex colour 0xFF808080 halves the RGB. The result is + * drawn additively (BlendFunction.ADDITIVE), so alpha only cuts out (shader ALPHA_CUTOUT 0.1). + * xOffset: creeper {@code age·0.01}, wither {@code cos(age·0.02)·3}. + */ + private int[][] energySwirl(String path, boolean wither) { + int[][] src = texture(path); + if (src == null) return null; + double u = wither ? Math.cos(SWIRL_AGE * 0.02) * 3 : SWIRL_AGE * 0.01; + double v = SWIRL_AGE * 0.01; + int h = src.length, w = src[0].length; + int du = Math.floorMod((int) Math.round((u % 1) * w), w); + int dv = Math.floorMod((int) Math.round((v % 1) * h), h); + int[][] out = new int[h][w]; + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + int p = src[(y + dv) % h][(x + du) % w]; + out[y][x] = (p & 0xFF000000) | ((p >>> 1) & 0x7F7F7F); + } + } + return out; + } + + /** Body texture with the dye-tinted {@code entity//_collar[_baby]} composited over it. */ + private int[][] withCollar(int[][] tex, String base, boolean baby, int dye) { + String path = "entity/" + base + "/" + base + "_collar" + (baby ? "_baby" : ""); + int[][] collar = texture(path); + if (collar == null || collar.length != tex.length || collar[0].length != tex[0].length) return tex; + int[][] tinted = TextureOps.deepCopy(collar); + TextureOps.tint(tinted, dye); + int[][] out = TextureOps.deepCopy(tex); + TextureOps.overlay(out, tinted); + return out; + } + // --- texture resolution -------------------------------------------------------------------------- private int[][] texture(String path) { @@ -501,6 +595,12 @@ public final class CemBaker implements EntityBaker { Math.min(adult.size(), EntityModels.specificCandidateCount(s.typeKey(), s.variant())), info.texture()); } + if (s.collar() != 0) { + // Tamed: vanilla switches to the "_tame" texture where the variant has one (wolves). + List tame = new ArrayList<>(); + for (String a : adult) tame.add(a + "_tame"); + adult.addAll(0, tame); + } List order = new ArrayList<>(); if (ch != null && ch.babyModel) { for (String a : adult) order.add(a + "_baby"); diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/ElementIntersector.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/ElementIntersector.java index 7033452..52a2b16 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/ElementIntersector.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/ElementIntersector.java @@ -81,7 +81,7 @@ public final class ElementIntersector { Vector world = new Vector(bx + ox + dx * c.t, by + oy + dy * c.t, bz + oz + dz * c.t); Vector normal = new Vector(c.normal[0], c.normal[1], c.normal[2]); - return new FaceHit(c.t, world, normal, color, face.tintIndex); + return new FaceHit(c.t, world, normal, color, face.tintIndex, false); } return null; } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/FaceHit.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/FaceHit.java index cb15b3f..651af61 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/FaceHit.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/FaceHit.java @@ -4,6 +4,7 @@ import org.bukkit.util.Vector; /** * The result of intersecting a ray with a block's geometry: the world-space hit point and normal, - * the sampled ARGB color (before shading/tinting) and the face's tint index ({@code -1} = none). + * the sampled ARGB color (before shading/tinting), the face's tint index ({@code -1} = none) and whether the + * surface is drawn additively (emissive energy-swirl layers: its colour is added to what lies behind). */ -public record FaceHit(double t, Vector point, Vector normal, int color, int tintIndex) {} +public record FaceHit(double t, Vector point, Vector normal, int color, int tintIndex, boolean additive) {} diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/SnapshotRaytracer.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/SnapshotRaytracer.java index cc4eebc..a96d158 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/SnapshotRaytracer.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/raytrace/SnapshotRaytracer.java @@ -42,6 +42,8 @@ public final class SnapshotRaytracer { private static final int MAX_TRANSLUCENT_LAYERS = 6; private static final double MIN_TRANSMITTANCE = 0.03; private static final double LAYER_EPS = 1e-5; + // layerAlpha marker of an additive (emissive) layer: its colour is added to what lies behind. + private static final double ADDITIVE = -1; private final BlockModelRegistry registry; private final BiomeTintProvider tintProvider; @@ -78,7 +80,8 @@ public final class SnapshotRaytracer { BlockCursor blocks = new BlockCursor(snapshot, origin, direction, sky, scene, depth); boolean entities = scene != null && !scene.isEmpty(); - // Translucent surfaces in front of the final one, front to back (shaded colour, alpha 0..1, ray t). + // Translucent surfaces in front of the final one, front to back (shaded colour, alpha 0..1 or ADDITIVE, + // ray t). int[] layerColor = null; double[] layerAlpha = null, layerT = null; int layers = 0; @@ -97,8 +100,11 @@ public final class SnapshotRaytracer { FaceHit eh = entities ? scene.nearestHit(ox, oy, oz, dx, dy, dz, minT, limit) : null; int color; double t; + boolean additive = false; if (eh != null) { - color = ColorUtil.shade(eh.color(), shadeFactor(eh.normal())); + // Emissive additive surfaces (energy swirl) are unshaded: vanilla NO_CARDINAL_LIGHTING + EMISSIVE. + additive = eh.additive(); + color = additive ? eh.color() : ColorUtil.shade(eh.color(), shadeFactor(eh.normal())); t = eh.t(); } else if (pending != null) { color = pending.color; @@ -109,7 +115,14 @@ public final class SnapshotRaytracer { } int alpha = ColorUtil.alpha(color); double a = alpha / 255.0; - if (alpha >= OPAQUE_ALPHA + if (additive) { + // Additive light never hides what lies behind it: it neither ends the ray nor dims it. Beyond the + // layer budget it is dropped. + if (layers == MAX_TRANSLUCENT_LAYERS) { + minT = t + LAYER_EPS; + continue; + } + } else if (alpha >= OPAQUE_ALPHA || layers == MAX_TRANSLUCENT_LAYERS || transmittance * (1 - a) < MIN_TRANSMITTANCE) { // Opaque, or what lies behind would contribute next to nothing: this surface ends the ray. @@ -123,10 +136,10 @@ public final class SnapshotRaytracer { layerT = new double[MAX_TRANSLUCENT_LAYERS]; } layerColor[layers] = color; - layerAlpha[layers] = a; + layerAlpha[layers] = additive ? ADDITIVE : a; layerT[layers] = t; layers++; - transmittance *= 1 - a; + if (!additive) transmittance *= 1 - a; minT = t + LAYER_EPS; } @@ -138,7 +151,12 @@ public final class SnapshotRaytracer { color = blocks.applyLiquid(color, finalT, skyColor); liquidPending = false; } - color = ColorUtil.mix(fog(layerColor[i], layerT[i], skyColor), color, layerAlpha[i], 1 - layerAlpha[i]); + if (layerAlpha[i] == ADDITIVE) { + // Added light fades with distance like the surface it would otherwise be. + color = ColorUtil.add(color, ColorUtil.shade(layerColor[i], 1 - fogFactor(layerT[i]))); + } else { + color = ColorUtil.mix(fog(layerColor[i], layerT[i], skyColor), color, layerAlpha[i], 1 - layerAlpha[i]); + } } if (liquidPending) color = blocks.applyLiquid(color, finalT, skyColor); return color & 0xFFFFFF; 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 2397656..35d1a53 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 @@ -19,6 +19,8 @@ import java.util.function.DoubleSupplier; import org.bukkit.Keyed; import org.bukkit.Location; import org.bukkit.Material; +import org.bukkit.attribute.Attribute; +import org.bukkit.attribute.AttributeInstance; import org.bukkit.entity.AbstractArrow; import org.bukkit.entity.AbstractHorse; import org.bukkit.entity.Ageable; @@ -28,6 +30,7 @@ import org.bukkit.entity.Cat; import org.bukkit.entity.ChestedHorse; import org.bukkit.entity.Chicken; import org.bukkit.entity.Cow; +import org.bukkit.entity.Creeper; import org.bukkit.entity.Entity; import org.bukkit.entity.Fox; import org.bukkit.entity.Frog; @@ -51,6 +54,7 @@ import org.bukkit.entity.TraderLlama; import org.bukkit.entity.Trident; import org.bukkit.entity.TropicalFish; import org.bukkit.entity.Villager; +import org.bukkit.entity.Wither; import org.bukkit.entity.Wolf; import org.bukkit.entity.ZombieVillager; import org.bukkit.inventory.EntityEquipment; @@ -114,6 +118,9 @@ public final class EntitySnapshotBuilder { "piglin", "piglin_brute"); + // Mobs whose main-hand item is drawn in the mouth (FoxHeldItemLayer, DolphinCarryingItemLayer). + private static final Set MOUTH_CARRIERS = Set.of("fox", "dolphin"); + /** {@code nearby}: {@link FrustumBounds#nearbyEntities} of this photo. */ public static List build(Collection nearby, UUID shooter) { List states = new ArrayList<>(); @@ -169,6 +176,8 @@ public final class EntitySnapshotBuilder { String bodyEquip = null; int bodyDye = 0; int tint2 = 0; + int collar = 0; + boolean powered = false; try { // Slime & magma cube (MagmaCube extends Slime) scale their model by size (1/2/4). if (e instanceof Slime sl) sizeScale = sl.getSize(); @@ -187,8 +196,16 @@ public final class EntitySnapshotBuilder { default -> "big";}; } else if (e instanceof Cat c) { variant = keyOf(c.getCatType()); + if (c.isTamed()) collar = ColorUtil.dyeArgb(c.getCollarColor(), 0); } else if (e instanceof Wolf w) { variant = keyOf(w.getVariant()); + if (w.isTamed()) collar = ColorUtil.dyeArgb(w.getCollarColor(), 0); + } else if (e instanceof Creeper cr) { + powered = cr.isPowered(); + } else if (e instanceof Wither wi) { + // WitherBoss.isPowered(): health <= max health / 2 (wither armor layer). + AttributeInstance max = wi.getAttribute(Attribute.MAX_HEALTH); + powered = max != null && wi.getHealth() <= max.getValue() / 2; } else if (e instanceof Axolotl a) { variant = keyOf(a.getVariant()); } else if (e instanceof Parrot p) { @@ -245,7 +262,7 @@ public final class EntitySnapshotBuilder { // Equipment slots: saddle + BODY equipment (horse armor, llama carpet, harness, wolf/nautilus armor, …) // for every mount — including ones added in newer versions — plus held items (by hand side, like // vanilla: a left-handed mob holds its main-hand item left) and a non-armor head item. - ItemSpec rightHand = null, leftHand = null, headItem = null; + ItemSpec rightHand = null, leftHand = null, headItem = null, mouthItem = null; EntityEquipment eq = e instanceof LivingEntity le ? le.getEquipment() : null; if (eq != null) { try { @@ -263,8 +280,12 @@ public final class EntitySnapshotBuilder { || (e instanceof HumanEntity he && he.getMainHand() == MainHand.LEFT); ItemSpec main = ItemSpecs.of(eq.getItemInMainHand()); ItemSpec off = ItemSpecs.of(eq.getItemInOffHand()); - rightHand = leftHanded ? off : main; - leftHand = leftHanded ? main : off; + if (MOUTH_CARRIERS.contains(type)) { + mouthItem = main; // fox/dolphin carry their main-hand item in the mouth, not in a hand + } else { + rightHand = leftHanded ? off : main; + leftHand = leftHanded ? main : off; + } ItemStack helmet = eq.getHelmet(); if (helmet != null && !helmet.getType().isAir() && armorAsset(helmet.getType()) == null) { headItem = ItemSpecs.of(helmet); @@ -312,7 +333,10 @@ public final class EntitySnapshotBuilder { e.isInsideVehicle(), rightHand, leftHand, - headItem); + headItem, + collar, + powered, + mouthItem); } /** Worn armor (4 slots) from a humanoid wearer; null when nothing is equipped. */ diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/util/ColorUtil.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/util/ColorUtil.java index 69b0e8b..49c2fec 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/util/ColorUtil.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/util/ColorUtil.java @@ -92,7 +92,6 @@ public final class ColorUtil { return argb(a, r, g, b); } - /** Scales the RGB channels by {@code factor} (0..1), keeping alpha. Used for directional face shading. */ /** Texels with alpha at or below this are holes (alpha cutout) for every intersector and the extrusion. */ public static final int ALPHA_CUTOUT = 16; @@ -101,6 +100,7 @@ public final class ColorUtil { return alpha(argb) > ALPHA_CUTOUT; } + /** Scales the RGB channels by {@code factor} (0..1), keeping alpha. Used for directional face shading. */ public static int shade(int argb, double factor) { int a = alpha(argb); int r = clamp((int) (red(argb) * factor)); @@ -109,6 +109,13 @@ public final class ColorUtil { return argb(a, r, g, b); } + /** Additive blend (vanilla BlendFunction.ADDITIVE, ONE/ONE): RGB of {@code src} added to {@code dst}, clamped. */ + public static int add(int dst, int src) { + return (clamp(red(dst) + red(src)) << 16) + | (clamp(green(dst) + green(src)) << 8) + | clamp(blue(dst) + blue(src)); + } + private static int clamp(int v) { return v < 0 ? 0 : Math.min(v, 255); } diff --git a/tools/EntityTestRender.java b/tools/EntityTestRender.java index 97b3144..5de812e 100644 --- a/tools/EntityTestRender.java +++ b/tools/EntityTestRender.java @@ -185,7 +185,7 @@ public class EntityTestRender { EntityState s = new EntityState(key, 0, 0, 0, yaw, baby, 0.8, 1.0, isPlayer, null, false, VAR.get(key), tint, 1.0, PROF.get(key), LVL.getOrDefault(key, 0), MARK.get(key), SADDLE.contains(key), CHEST.contains(key), EQUIP.get(key), baby ? null : ARMOR.get(key), - !baby && INVISIBLE.contains(key), 0, 0, tint2, false, null, null, null); + !baby && INVISIBLE.contains(key), 0, 0, tint2, false, null, null, null, 0, false, null); RenderedEntity re = baker.bake(s); if (re == null || re.cubes.isEmpty()) { // invisible with no equipment -> nothing renders return new BufferedImage(TW, TH, BufferedImage.TYPE_INT_RGB); diff --git a/tools/edgecase-plugin/pptest/PPTest.java b/tools/edgecase-plugin/pptest/PPTest.java index 42dcd01..991e769 100644 --- a/tools/edgecase-plugin/pptest/PPTest.java +++ b/tools/edgecase-plugin/pptest/PPTest.java @@ -79,6 +79,18 @@ public class PPTest extends JavaPlugin { scenes.put("proj_stuck_close_t", new Scene("proj_stuck_close_t", this::projStuck, 18.5, 2.6, -3, 13.5, 1.6, 1)); // Rendered 4 ticks after the shots: freshly stuck arrows still wobble (vanilla shakeTime -> pitch offset). scenes.put("proj_shake", new Scene("proj_shake", this::projStuck, -2.5, 2.4, -2.5, 2.5, 1.6, 1)); + // Conditional vanilla layers: collars (tamed only, dye-tinted), charged creeper / wither armor (additive + // energy swirl), fox/dolphin mouth items. + scenes.put("layers", new Scene("layers", this::layers, 4.8, 2.4, -5.5, 4.8, 0.6, 1.5)); + scenes.put("layers_side", new Scene("layers_side", this::layers, 12.5, 2.2, 5.5, 5, 0.5, 2.5)); + scenes.put("layers_close", new Scene("layers_close", this::layers, 5.5, 1.3, 0.3, 5.5, 0.4, 2.5)); + scenes.put("layers_pets", new Scene("layers_pets", this::layers, 4.5, 1.6, -3.2, 4.5, 0.3, 0)); + scenes.put("layers_dolphin", new Scene("layers_dolphin", this::layers, 10, 1.0, 1.0, 7.5, 0.4, 2.0)); + scenes.put("layers_cats", new Scene("layers_cats", this::layers, 1.7, 1.1, -1.6, 1.7, 0.3, 0)); + scenes.put("layers_cats_side", new Scene("layers_cats_side", this::layers, 1.7, 1.6, 1.4, 1.7, 0.2, 0)); + scenes.put("layers_top", new Scene("layers_top", this::layers, 5.5, 6, 2.0, 5.5, 0, 2.5)); + scenes.put("layers_wither", new Scene("layers_wither", this::withers, 3.5, 4, -9, 3.5, 2.2, 1)); + scenes.put("layers_wither_close", new Scene("layers_wither_close", this::withers, 6, 3.4, -3.5, 6, 2.6, 1)); scenes.put("proj_stuck_sw", new Scene("proj_stuck_sw", this::projStuck, -3, 6, 10, 8, 1.5, 2)); } @@ -613,6 +625,46 @@ public class PPTest extends JavaPlugin { t.setBillboard(Display.Billboard.CENTER); } + // --- conditional layers ---------------------------------------------------------------------- + + private void layers(Location o) { + tame(spawn(o, 0, 0, 0, 180, Cat.class), DyeColor.RED); + tame(spawn(o, 1.2, 0, 0, 180, Cat.class), DyeColor.BLUE); + spawn(o, 2.4, 0, 0, 180, Cat.class); // untamed: no collar + Cat kitten = spawn(o, 3.4, 0, 0, 180, Cat.class); + kitten.setBaby(); + tame(kitten, DyeColor.YELLOW); + tame(spawn(o, 5, 0, 0, 180, Wolf.class), DyeColor.RED); + tame(spawn(o, 6.4, 0, 0, 180, Wolf.class), DyeColor.LIME); + spawn(o, 7.8, 0, 0, 180, Wolf.class); // untamed: wild texture, no collar + Wolf pup = spawn(o, 9.0, 0, 0, 180, Wolf.class); + pup.setBaby(); + tame(pup, DyeColor.MAGENTA); + spawn(o, 0.5, 0, 2.5, 180, Creeper.class); + spawn(o, 2.2, 0, 2.5, 180, Creeper.class).setPowered(true); + hands(spawn(o, 4.2, 0, 2.5, 180, Fox.class), Material.EMERALD, null); + Fox kit = spawn(o, 5.5, 0, 2.5, 180, Fox.class); + kit.setBaby(); + hands(kit, Material.SWEET_BERRIES, null); + hands(spawn(o, 7.5, 0, 2.5, 180, Dolphin.class), Material.COD, null); + // Spawned mobs get a random body yaw: face them all to the camera (north). + for (Entity e : w.getNearbyEntities(o.clone().add(5, 0, 1), 7, 3, 4)) + if (e instanceof LivingEntity le && !(e instanceof Player)) le.setBodyYaw(180); + } + + private void tame(Tameable t, DyeColor collar) { + t.setTamed(true); + if (t instanceof Cat c) c.setCollarColor(collar); + if (t instanceof Wolf wo) wo.setCollarColor(collar); + } + + private void withers(Location o) { + spawn(o, 1, 0, 1, 180, Wither.class); // full health: no armor + Wither half = spawn(o, 6, 0, 1, 180, Wither.class); + half.setHealth(half.getAttribute(org.bukkit.attribute.Attribute.MAX_HEALTH).getValue() / 2 - 1); + for (Entity e : w.getNearbyEntities(o, 12, 8, 8)) if (e instanceof Wither wi) wi.setBodyYaw(180); + } + // --- projectiles ----------------------------------------------------------------------------- /** Spawns a projectile at p moving along dir, rotated like vanilla Projectile#shoot (yaw atan2(x,z), pitch up). */