simplify snapshot code: frustum bounds computed once per photo and shared by all builders, cached per-class reflection handles via a shared helper (getHandle, standing eye height, map data and decorations), one facing yaw for wall and standing block entities, shared banner pattern/skin/copper stage/dye helpers, shield state via the builder, map view read once per frame, leash endpoints without temp arrays, package-private item/map helpers, fully qualified names imported, dead checks removed, comments trimmed

This commit is contained in:
MineTec committed 2026-11-04 23:41:05 +01:00
1 parent bc07631fec
commit 2a3db3d7aa
11 files changed
+504 -587

No files matched your search

@@ -116,10 +116,11 @@ public class DefaultScreenRenderer {
!= FluidRenderer.NONE; != FluidRenderer.NONE;
List<Vector> rayMap = List<Vector> rayMap =
buildRayMap(eyeLocation, superW, superH, inFluid ? V_FOV_DEG * FLUID_FOV_SCALE : V_FOV_DEG); buildRayMap(eyeLocation, superW, superH, inFluid ? V_FOV_DEG * FLUID_FOV_SCALE : V_FOV_DEG);
WorldSnapshot snapshot = SnapshotBuilder.build(eyeLocation, rayMap, MAX_DISTANCE, logger); FrustumBounds bounds = FrustumBounds.of(eyeLocation, rayMap, MAX_DISTANCE);
Collection<Entity> nearby = FrustumBounds.nearbyEntities(eyeLocation, rayMap, MAX_DISTANCE); WorldSnapshot snapshot = SnapshotBuilder.build(world, bounds, logger);
Collection<Entity> nearby = bounds.nearbyEntities(world);
List<EntityState> entities = EntitySnapshotBuilder.build(nearby, shooter); List<EntityState> entities = EntitySnapshotBuilder.build(nearby, shooter);
List<BlockEntityState> blockEntities = BlockEntitySnapshotBuilder.build(eyeLocation, rayMap, MAX_DISTANCE); List<BlockEntityState> blockEntities = BlockEntitySnapshotBuilder.build(eyeLocation, bounds);
List<DecorationState> decorations = DecorationSnapshotBuilder.build(nearby); List<DecorationState> decorations = DecorationSnapshotBuilder.build(nearby);
List<ObjectState> objects = ObjectSnapshotBuilder.build(nearby, eyeLocation); List<ObjectState> objects = ObjectSnapshotBuilder.build(nearby, eyeLocation);
@@ -12,6 +12,7 @@ import java.util.EnumMap;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.regex.Pattern;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.DyeColor; import org.bukkit.DyeColor;
import org.bukkit.HeightMap; import org.bukkit.HeightMap;
@@ -20,6 +21,7 @@ import org.bukkit.Material;
import org.bukkit.World; import org.bukkit.World;
import org.bukkit.block.Banner; import org.bukkit.block.Banner;
import org.bukkit.block.Beacon; import org.bukkit.block.Beacon;
import org.bukkit.block.Block;
import org.bukkit.block.BlockFace; import org.bukkit.block.BlockFace;
import org.bukkit.block.BlockState; import org.bukkit.block.BlockState;
import org.bukkit.block.Campfire; import org.bukkit.block.Campfire;
@@ -31,11 +33,11 @@ import org.bukkit.block.Sign;
import org.bukkit.block.Skull; import org.bukkit.block.Skull;
import org.bukkit.block.TrialSpawner; import org.bukkit.block.TrialSpawner;
import org.bukkit.block.Vault; import org.bukkit.block.Vault;
import org.bukkit.block.banner.Pattern;
import org.bukkit.block.data.BlockData; import org.bukkit.block.data.BlockData;
import org.bukkit.block.data.Directional; import org.bukkit.block.data.Directional;
import org.bukkit.block.data.Rotatable; import org.bukkit.block.data.Rotatable;
import org.bukkit.block.data.type.Chest; import org.bukkit.block.data.type.Chest;
import org.bukkit.block.data.type.CopperGolemStatue;
import org.bukkit.block.data.type.Lectern; import org.bukkit.block.data.type.Lectern;
import org.bukkit.block.sign.Side; import org.bukkit.block.sign.Side;
import org.bukkit.block.sign.SignSide; import org.bukkit.block.sign.SignSide;
@@ -47,21 +49,20 @@ import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.inventory.meta.ShieldMeta; import org.bukkit.inventory.meta.ShieldMeta;
import org.bukkit.inventory.meta.SkullMeta; import org.bukkit.inventory.meta.SkullMeta;
import org.bukkit.profile.PlayerProfile; import org.bukkit.profile.PlayerProfile;
import org.bukkit.util.Vector; import org.bukkit.spawner.BaseSpawner;
/** /**
* Captures block-entities (chests, signs, banners, heads, pots, lecterns, …) near the view frustum into immutable * Captures block-entities (chests, signs, banners, heads, pots, lecterns, …) inside the frustum bounds into
* {@link BlockEntityState}s. MUST run on the main thread (live {@link BlockState} access). Reads each * {@link BlockEntityState}s. MUST run on the main thread (live {@link BlockState} access). Reads each loaded
* loaded chunk's tile-entities rather than scanning every block, then keeps only those inside the * chunk's tile entities instead of scanning every block.
* camera's bounding box.
*/ */
public final class BlockEntitySnapshotBuilder { public final class BlockEntitySnapshotBuilder {
private BlockEntitySnapshotBuilder() {} private BlockEntitySnapshotBuilder() {}
public static List<BlockEntityState> build(Location eye, List<Vector> rayMap, double maxDistance) { /** {@code eye}: camera position for the camera-dependent parts (enchanting-table book, beacon beam width). */
public static List<BlockEntityState> build(Location eye, FrustumBounds bounds) {
World world = eye.getWorld(); World world = eye.getWorld();
FrustumBounds bounds = FrustumBounds.of(eye.toVector(), rayMap, maxDistance);
// 1-block margin so block-entities straddling the frustum edge are still captured. // 1-block margin so block-entities straddling the frustum edge are still captured.
int bMinX = (int) Math.floor(bounds.minX) - 1, bMaxX = (int) Math.ceil(bounds.maxX) + 1; int bMinX = (int) Math.floor(bounds.minX) - 1, bMaxX = (int) Math.ceil(bounds.maxX) + 1;
int bMinY = (int) Math.floor(bounds.minY) - 1, bMaxY = (int) Math.ceil(bounds.maxY) + 1; int bMinY = (int) Math.floor(bounds.minY) - 1, bMaxY = (int) Math.ceil(bounds.maxY) + 1;
@@ -76,14 +77,16 @@ public final class BlockEntitySnapshotBuilder {
for (BlockState ts : world.getChunkAt(cx, cz).getTileEntities(false)) { for (BlockState ts : world.getChunkAt(cx, cz).getTileEntities(false)) {
int bx = ts.getX(), by = ts.getY(), bz = ts.getZ(); int bx = ts.getX(), by = ts.getY(), bz = ts.getZ();
// A beacon's beam rises from below: keep beacons under the view box too. // A beacon's beam rises from below: keep beacons under the view box too.
boolean below = by < bMinY && ts instanceof Beacon; if (bx < bMinX
if (bx < bMinX || bx > bMaxX || (by < bMinY && !below) || by > bMaxY || bz < bMinZ || bz > bMaxZ) || bx > bMaxX
continue; || (by < bMinY && !(ts instanceof Beacon))
|| by > bMaxY
|| bz < bMinZ
|| bz > bMaxZ) continue;
try { try {
BlockEntityState s = classify(ts.getBlockData(), bx, by, bz, ts, eye); BlockEntityState s = classify(ts.getBlockData(), bx, by, bz, ts, eye);
if (s != null) out.add(s); if (s != null) out.add(s);
} catch (Throwable ignored) { } catch (Throwable ignored) {
// Unsupported on this server version / odd state — skip this one.
} }
} }
} }
@@ -92,12 +95,10 @@ public final class BlockEntitySnapshotBuilder {
} }
/** /**
* A block-entity state from bare block data (no world tile entity) at cell (0,0,0): for block-entity * A state from bare block data (no tile) at cell 0 for block displays, minecarts and special items; without
* blocks shown by BLOCK DISPLAYS, minecarts and special ITEMS (chest/head/banner/shulker/… items). * tile-only details (patterns, sign text, skins). Null for blocks that aren't block-entity rendered.
* Tile-only details (banner patterns, sign text, head skins) are absent. MUST run on the main thread.
* Returns null for blocks that aren't block-entity rendered.
*/ */
public static BlockEntityState fromBlockData(BlockData data) { static BlockEntityState fromBlockData(BlockData data) {
try { try {
return classify(data, 0, 0, 0, null, null); return classify(data, 0, 0, 0, null, null);
} catch (Throwable t) { } catch (Throwable t) {
@@ -106,63 +107,37 @@ public final class BlockEntitySnapshotBuilder {
} }
/** /**
* Adds the item-stack details a placed tile would carry to a {@link #fromBlockData} state: the owner skin * Adds the stack details a placed tile would carry to a {@link #fromBlockData} state: a player head's skin
* of a player head ({@code SkullMeta} profile) and banner patterns ({@code BannerMeta}). Other kinds are * ({@code SkullMeta}) and banner patterns ({@code BannerMeta}).
* returned unchanged. MUST run on the main thread.
*/ */
public static BlockEntityState withItemMeta(BlockEntityState s, ItemMeta meta) { static BlockEntityState withItemMeta(BlockEntityState s, ItemMeta meta) {
if (s == null || meta == null) return s; if (s == null || meta == null) return s;
try { try {
if (meta instanceof SkullMeta skull && "player".equals(s.headType())) { if (meta instanceof SkullMeta skull && "player".equals(s.headType())) {
PlayerProfile profile = skull.getOwnerProfile(); String skin = skinUrl(skull.getOwnerProfile());
if (profile == null || profile.getTextures().getSkin() == null) return s; return skin == null ? s : copy(s, skin, s.patterns());
return copy(s, profile.getTextures().getSkin().toString(), s.patterns());
} }
if (meta instanceof BannerMeta banner && !banner.getPatterns().isEmpty()) { if (meta instanceof BannerMeta banner && !banner.getPatterns().isEmpty()) {
List<BannerPattern> pats = new ArrayList<>(); return copy(s, s.skinUrl(), patterns(banner.getPatterns()));
for (Pattern p : banner.getPatterns()) {
pats.add(new BannerPattern(patternKey(p), ColorUtil.dyeArgb(p.getColor(), 0xFFFFFFFF)));
}
return copy(s, s.skinUrl(), pats);
} }
} catch (Throwable ignored) { } catch (Throwable ignored) {
// API drift: keep the plain block state
} }
return s; return s;
} }
/** /** A shield's base colour + patterns as a BANNER state for the shield special model; null for a plain shield. */
* A shield's banner data (ShieldMeta: base colour + patterns) as a {@code BANNER} state at cell 0 for the static BlockEntityState shieldPatterns(ItemMeta meta) {
* shield special model; null without patterns and base colour (plain shield).
*/
public static BlockEntityState shieldPatterns(ItemMeta meta) {
try { try {
if (!(meta instanceof ShieldMeta shield)) return null; if (!(meta instanceof ShieldMeta shield)) return null;
DyeColor baseColor = shield.getBaseColor(); DyeColor baseColor = shield.getBaseColor();
List<BannerPattern> pats = new ArrayList<>(); List<BannerPattern> pats = patterns(shield.getPatterns());
for (Pattern p : shield.getPatterns()) {
pats.add(new BannerPattern(patternKey(p), ColorUtil.dyeArgb(p.getColor(), 0xFFFFFFFF)));
}
if (baseColor == null && pats.isEmpty()) return null; if (baseColor == null && pats.isEmpty()) return null;
return new BlockEntityState( return base(Kind.BANNER, 0, 0, 0, 0)
Kind.BANNER, .baseColorArgb(ColorUtil.dyeArgb(baseColor, 0))
0, .patterns(pats)
0, .build();
0,
0,
null,
false,
null,
ColorUtil.dyeArgb(baseColor, 0),
null,
null,
null,
pats,
List.of(),
null,
null);
} catch (Throwable ignored) { } catch (Throwable ignored) {
return null; // API drift: plain shield return null;
} }
} }
@@ -190,42 +165,30 @@ public final class BlockEntitySnapshotBuilder {
s.pose()); s.pose());
} }
/** /** {@code ts} = the live tile (null for bare block data), {@code eye} = camera position or null. */
* {@code ts} = the live tile (null for bare block data), {@code eye} = camera position (null: none) for
* the camera-dependent parts (enchanting-table book, beacon beam width).
*/
private static BlockEntityState classify(BlockData data, int bx, int by, int bz, BlockState ts, Location eye) { private static BlockEntityState classify(BlockData data, int bx, int by, int bz, BlockState ts, Location eye) {
Material mat = data.getMaterial(); Material mat = data.getMaterial();
String n = mat.name(); String n = mat.name();
// --- chests --- Kind chest = n.endsWith("COPPER_CHEST")
boolean copper = n.endsWith("COPPER_CHEST"); ? Kind.COPPER_CHEST
if (mat == Material.CHEST || mat == Material.TRAPPED_CHEST || mat == Material.ENDER_CHEST || copper) { : switch (mat) {
Kind kind = copper case CHEST -> Kind.CHEST;
? Kind.COPPER_CHEST case TRAPPED_CHEST -> Kind.TRAPPED_CHEST;
: mat == Material.TRAPPED_CHEST case ENDER_CHEST -> Kind.ENDER_CHEST;
? Kind.TRAPPED_CHEST default -> null;
: mat == Material.ENDER_CHEST ? Kind.ENDER_CHEST : Kind.CHEST; };
ChestKind ck = ChestKind.SINGLE; if (chest != null) {
if (data instanceof Chest cd) { ChestKind ck =
ck = switch (cd.getType()) { data instanceof Chest cd ? ChestKind.valueOf(cd.getType().name()) : ChestKind.SINGLE;
case LEFT -> ChestKind.LEFT; Builder b = base(chest, bx, by, bz, facingYaw(data)).chestKind(ck);
case RIGHT -> ChestKind.RIGHT; if (chest == Kind.COPPER_CHEST) b.colorName(copperStage(n, "COPPER_CHEST"));
case SINGLE -> ChestKind.SINGLE;};
}
Builder b = base(kind, bx, by, bz, facingYaw(data)).chestKind(ck);
// Copper stage from the name: [WAXED_][EXPOSED_|WEATHERED_|OXIDIZED_]COPPER_CHEST.
if (copper) {
String stage =
n.replace("WAXED_", "").replace("COPPER_CHEST", "").replace("_", "");
b.colorName(stage.isEmpty() ? null : stage.toLowerCase(Locale.ROOT));
}
return b.build(); return b.build();
} }
// --- shulker boxes ---
if (n.endsWith("SHULKER_BOX")) { if (n.endsWith("SHULKER_BOX")) {
String color = n.equals("SHULKER_BOX") ? null : stripColor(n, "_SHULKER_BOX"); String color =
n.equals("SHULKER_BOX") ? null : prefix(n, "_SHULKER_BOX").toLowerCase(Locale.ROOT);
String dir = data instanceof Directional d ? d.getFacing().name().toLowerCase(Locale.ROOT) : "up"; String dir = data instanceof Directional d ? d.getFacing().name().toLowerCase(Locale.ROOT) : "up";
return base(Kind.SHULKER_BOX, bx, by, bz, 0) return base(Kind.SHULKER_BOX, bx, by, bz, 0)
.direction(dir) .direction(dir)
@@ -233,47 +196,24 @@ public final class BlockEntitySnapshotBuilder {
.build(); .build();
} }
// --- banners ---
if (n.endsWith("_BANNER")) { if (n.endsWith("_BANNER")) {
boolean wall = n.endsWith("_WALL_BANNER"); boolean wall = n.endsWith("_WALL_BANNER");
float yaw = wall ? facingYaw(data) : rotationYaw(data); Builder b = base(wall ? Kind.WALL_BANNER : Kind.BANNER, bx, by, bz, facingYaw(data));
Builder b = base(wall ? Kind.WALL_BANNER : Kind.BANNER, bx, by, bz, yaw);
if (!(ts instanceof Banner)) {
try { // no tile: the base colour is in the block name
String colour =
stripColor(n, wall ? "_WALL_BANNER" : "_BANNER").toUpperCase(Locale.ROOT);
b.baseColorArgb(ColorUtil.dyeArgb(DyeColor.valueOf(colour), 0xFFFFFFFF));
} catch (IllegalArgumentException ignored) {
// unknown colour name
}
}
if (ts instanceof Banner banner) { if (ts instanceof Banner banner) {
DyeColor base = banner.getBaseColor(); b.baseColorArgb(ColorUtil.dyeArgb(banner.getBaseColor(), 0xFFFFFFFF));
b.baseColorArgb(ColorUtil.dyeArgb(base, 0xFFFFFFFF)); b.patterns(patterns(banner.getPatterns()));
List<BannerPattern> pats = new ArrayList<>(); } else { // no tile: the base colour is in the block name
for (Pattern p : banner.getPatterns()) { DyeColor dye = dye(prefix(n, wall ? "_WALL_BANNER" : "_BANNER"));
pats.add(new BannerPattern(patternKey(p), ColorUtil.dyeArgb(p.getColor(), 0xFFFFFFFF))); if (dye != null) b.baseColorArgb(ColorUtil.dyeArgb(dye, 0xFFFFFFFF));
}
b.patterns(pats);
} }
return b.build(); return b.build();
} }
// --- signs ---
if (n.endsWith("_SIGN")) { if (n.endsWith("_SIGN")) {
Kind kind; Kind kind = n.endsWith("_WALL_SIGN")
float yaw; ? Kind.WALL_SIGN
if (n.endsWith("_WALL_SIGN")) { : n.endsWith("_HANGING_SIGN") ? Kind.HANGING_SIGN : Kind.SIGN;
kind = Kind.WALL_SIGN; Builder b = base(kind, bx, by, bz, facingYaw(data));
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);
}
Builder b = base(kind, bx, by, bz, yaw);
if (ts instanceof Sign sign) { if (ts instanceof Sign sign) {
b.frontText(signText(sign.getSide(Side.FRONT))); b.frontText(signText(sign.getSide(Side.FRONT)));
b.backText(signText(sign.getSide(Side.BACK))); b.backText(signText(sign.getSide(Side.BACK)));
@@ -281,50 +221,47 @@ public final class BlockEntitySnapshotBuilder {
return b.build(); return b.build();
} }
// --- heads / skulls ---
if (n.endsWith("_SKULL") || n.endsWith("_HEAD")) { if (n.endsWith("_SKULL") || n.endsWith("_HEAD")) {
boolean wall = n.contains("_WALL_"); boolean wall = n.contains("_WALL_");
String headType = headType(n); String headType = headType(n);
float yaw = wall ? facingYaw(data) : rotationYaw(data); Builder b = base(wall ? Kind.WALL_HEAD : Kind.HEAD, bx, by, bz, facingYaw(data))
Builder b = base(wall ? Kind.WALL_HEAD : Kind.HEAD, bx, by, bz, yaw).headType(headType); .headType(headType);
if ("player".equals(headType) && ts instanceof Skull skull) { if ("player".equals(headType) && ts instanceof Skull skull) {
b.skinUrl(skinUrl(skull)); try {
b.skinUrl(skinUrl(skull.getOwnerProfile()));
} catch (Throwable ignored) {
}
} }
return b.build(); return b.build();
} }
// --- conduit ---
if (mat == Material.CONDUIT) { if (mat == Material.CONDUIT) {
return base(Kind.CONDUIT, bx, by, bz, 0) return base(Kind.CONDUIT, bx, by, bz, 0)
.active(ts instanceof Conduit c && c.isActive()) .active(ts instanceof Conduit c && c.isActive())
.build(); .build();
} }
// --- lectern (the block is a block model; the renderer only adds the open book) --- // Lectern and bell frames are block models; the renderers add only the book / the bell body.
if (data instanceof Lectern lectern) { if (data instanceof Lectern lectern) {
return base(Kind.LECTERN, bx, by, bz, facingYaw(data)) return base(Kind.LECTERN, bx, by, bz, facingYaw(data))
.active(lectern.hasBook()) .active(lectern.hasBook())
.build(); .build();
} }
// --- decorated pot ---
if (mat == Material.DECORATED_POT) { if (mat == Material.DECORATED_POT) {
Builder b = base(Kind.DECORATED_POT, bx, by, bz, facingYaw(data)); Builder b = base(Kind.DECORATED_POT, bx, by, bz, facingYaw(data));
if (ts instanceof DecoratedPot pot) b.sherds(sherds(pot)); if (ts instanceof DecoratedPot pot) b.sherds(sherds(pot));
return b.build(); return b.build();
} }
// --- bell (frame = block model; the renderer draws the bell body) ---
if (mat == Material.BELL) { if (mat == Material.BELL) {
return base(Kind.BELL, bx, by, bz, 0).build(); return base(Kind.BELL, bx, by, bz, 0).build();
} }
// --- enchanting table: the floating book (EnchantingTableBlockEntity.bookAnimationTick) ---
if (mat == Material.ENCHANTING_TABLE) { if (mat == Material.ENCHANTING_TABLE) {
return enchantingTable(bx, by, bz, eye); return enchantingTable(bx, by, bz, eye);
} }
// --- campfire items (CampfireRenderer) ---
if (data instanceof org.bukkit.block.data.type.Campfire) { if (data instanceof org.bukkit.block.data.type.Campfire) {
Builder b = base(Kind.CAMPFIRE, bx, by, bz, facingYaw(data)); Builder b = base(Kind.CAMPFIRE, bx, by, bz, facingYaw(data));
if (ts instanceof Campfire c) { if (ts instanceof Campfire c) {
@@ -335,7 +272,7 @@ public final class BlockEntitySnapshotBuilder {
return b.build(); return b.build();
} }
// --- shelf items (ShelfRenderer; align_items_to_bottom has no API → false) --- // ShelfRenderer's align_items_to_bottom has no API (false).
if (data instanceof org.bukkit.block.data.type.Shelf) { if (data instanceof org.bukkit.block.data.type.Shelf) {
Builder b = base(Kind.SHELF, bx, by, bz, facingYaw(data)); Builder b = base(Kind.SHELF, bx, by, bz, facingYaw(data));
if (ts instanceof Shelf shelf) { if (ts instanceof Shelf shelf) {
@@ -347,24 +284,20 @@ public final class BlockEntitySnapshotBuilder {
return b.build(); return b.build();
} }
// --- copper golem statue: [WAXED_][EXPOSED_|WEATHERED_|OXIDIZED_]COPPER_GOLEM_STATUE --- if (data instanceof CopperGolemStatue statue) {
if (data instanceof org.bukkit.block.data.type.CopperGolemStatue statue) {
String stage =
n.replace("WAXED_", "").replace("COPPER_GOLEM_STATUE", "").replace("_", "");
return base(Kind.COPPER_GOLEM_STATUE, bx, by, bz, facingYaw(data)) return base(Kind.COPPER_GOLEM_STATUE, bx, by, bz, facingYaw(data))
.colorName(stage.isEmpty() ? null : stage.toLowerCase(Locale.ROOT)) .colorName(copperStage(n, "COPPER_GOLEM_STATUE"))
.pose(statue.getCopperGolemPose().name().toLowerCase(Locale.ROOT)) .pose(statue.getCopperGolemPose().name().toLowerCase(Locale.ROOT))
.build(); .build();
} }
// --- spawners: the spinning mini mob (SpawnerRenderer / TrialSpawnerRenderer) ---
if (mat == Material.SPAWNER || n.equals("TRIAL_SPAWNER")) { if (mat == Material.SPAWNER || n.equals("TRIAL_SPAWNER")) {
return base(Kind.SPAWNER, bx, by, bz, 0) return base(Kind.SPAWNER, bx, by, bz, 0)
.displayEntity(ts == null ? null : spawnerEntity(ts, data)) .displayEntity(spawnerEntity(ts, data))
.build(); .build();
} }
// --- vault: the displayed key/reward item while a player is connected (VaultRenderer) --- // VaultRenderer shows the displayed item only while a player is connected.
if (n.equals("VAULT")) { if (n.equals("VAULT")) {
Builder b = base(Kind.VAULT, bx, by, bz, facingYaw(data)); Builder b = base(Kind.VAULT, bx, by, bz, facingYaw(data));
if (ts instanceof Vault vault && !vault.getConnectedPlayers().isEmpty()) { if (ts instanceof Vault vault && !vault.getConnectedPlayers().isEmpty()) {
@@ -374,14 +307,13 @@ public final class BlockEntitySnapshotBuilder {
return b.build(); return b.build();
} }
// --- beacon beam (BeaconRenderer; only with a pyramid: getBeamSections is empty at level 0) --- // No beam without a pyramid (getBeamSections is empty at level 0).
if (mat == Material.BEACON) { if (mat == Material.BEACON) {
Builder b = base(Kind.BEACON, bx, by, bz, 0); Builder b = base(Kind.BEACON, bx, by, bz, 0);
if (ts instanceof Beacon beacon && beacon.getTier() > 0) b.beam(beam(ts.getWorld(), bx, by, bz, eye)); if (ts instanceof Beacon beacon && beacon.getTier() > 0) b.beam(beam(ts.getWorld(), bx, by, bz, eye));
return b.build(); return b.build();
} }
// --- end portal / gateway (TheEndPortalRenderer: top + bottom; gateway: unoccluded faces) ---
if (mat == Material.END_PORTAL) { if (mat == Material.END_PORTAL) {
return base(Kind.END_PORTAL, bx, by, bz, 0).build(); return base(Kind.END_PORTAL, bx, by, bz, 0).build();
} }
@@ -394,8 +326,6 @@ public final class BlockEntitySnapshotBuilder {
return null; return null;
} }
// --- contents helpers ---
/** /**
* EnchantingTableBlockEntity.bookAnimationTick: with a player within 3 blocks of the table centre the book * EnchantingTableBlockEntity.bookAnimationTick: with a player within 3 blocks of the table centre the book
* opens and turns to {@code atan2(dz, dx)}; otherwise it is closed (its idle yaw drifts — static 0 here). * opens and turns to {@code atan2(dz, dx)}; otherwise it is closed (its idle yaw drifts — static 0 here).
@@ -415,15 +345,13 @@ public final class BlockEntitySnapshotBuilder {
} }
/** /**
* The spawner's display mob as an entity state at its own (unspawned) position: the spawn data's entity * The spawner's display mob (vanilla BaseSpawner/TrialSpawnerStateData.getOrCreateDisplayEntity loads the
* snapshot (vanilla BaseSpawner/TrialSpawnerStateData.getOrCreateDisplayEntity loads the same NBT; * same NBT): the spawn data's entity snapshot, else a bare entity of the spawned type. Trial spawners show
* {@code createEntity(World)} loads it without spawning or finalizeSpawn randomisation), else a bare * it only while waiting/active, from the ominous configuration when ominous. Null when empty or no tile.
* entity of the spawned type. Trial spawners show it only while waiting/active, from the
* ominous configuration when ominous. Null when the spawner is empty.
*/ */
private static EntityState spawnerEntity(BlockState ts, BlockData data) { private static EntityState spawnerEntity(BlockState ts, BlockData data) {
try { try {
org.bukkit.spawner.BaseSpawner spawner; BaseSpawner spawner;
if (ts instanceof CreatureSpawner cs) { if (ts instanceof CreatureSpawner cs) {
spawner = cs; spawner = cs;
} else if (ts instanceof TrialSpawner trial && data instanceof org.bukkit.block.data.type.TrialSpawner td) { } else if (ts instanceof TrialSpawner trial && data instanceof org.bukkit.block.data.type.TrialSpawner td) {
@@ -443,12 +371,12 @@ public final class BlockEntitySnapshotBuilder {
"{id:\"" + spawner.getSpawnedType().getKey() + "\"}"); "{id:\"" + spawner.getSpawnedType().getKey() + "\"}");
} }
if (snap == null) return null; if (snap == null) return null;
// createEntity(World), NOT createEntity(Location): the Location variant ADDS the entity to the world. // createEntity(World), NOT createEntity(Location): the Location variant ADDS the entity to the world
// The display state sits wherever the bare entity is; the baker moves it to the cage by -position. // (createEntity(World) also skips finalizeSpawn randomisation). The baker moves the state to the cage.
Entity e = snap.createEntity(ts.getWorld()); Entity e = snap.createEntity(ts.getWorld());
return e == null ? null : EntitySnapshotBuilder.displayState(e); return e == null ? null : EntitySnapshotBuilder.toState(e);
} catch (Throwable t) { } catch (Throwable t) {
return null; // API drift / unknown entity return null;
} }
} }
@@ -462,7 +390,7 @@ public final class BlockEntitySnapshotBuilder {
int top = world.getHighestBlockYAt(bx, bz, HeightMap.WORLD_SURFACE); int top = world.getHighestBlockYAt(bx, bz, HeightMap.WORLD_SURFACE);
List<int[]> sections = new ArrayList<>(); // {argb, height} List<int[]> sections = new ArrayList<>(); // {argb, height}
for (int y = by; y <= top; y++) { for (int y = by; y <= top; y++) {
Material m = y == by ? Material.BEACON : world.getBlockAt(bx, y, bz).getType(); Material m = y == by ? Material.BEACON : world.getType(bx, y, bz);
int color = beamColor(m); int color = beamColor(m);
if (color != 0) { if (color != 0) {
if (sections.size() <= 1) { if (sections.size() <= 1) {
@@ -484,7 +412,7 @@ public final class BlockEntitySnapshotBuilder {
double dx = eye.getX() - (bx + 0.5), dz = eye.getZ() - (bz + 0.5); double dx = eye.getX() - (bx + 0.5), dz = eye.getZ() - (bz + 0.5);
scale = (float) Math.max(1, Math.sqrt(dx * dx + dz * dz) / 96); scale = (float) Math.max(1, Math.sqrt(dx * dx + dz * dz) / 96);
} }
return out.isEmpty() ? null : new BlockEntityState.Beam(out, scale); return new BlockEntityState.Beam(out, scale);
} }
/** BeaconBeamBlock colour (DyeColor texture-diffuse colour) of a beacon/stained glass (pane); 0 = none. */ /** BeaconBeamBlock colour (DyeColor texture-diffuse colour) of a beacon/stained glass (pane); 0 = none. */
@@ -492,12 +420,7 @@ public final class BlockEntitySnapshotBuilder {
if (m == Material.BEACON) return ColorUtil.dyeArgb(DyeColor.WHITE, 0); if (m == Material.BEACON) return ColorUtil.dyeArgb(DyeColor.WHITE, 0);
String n = m.name(); String n = m.name();
int i = n.indexOf("_STAINED_GLASS"); int i = n.indexOf("_STAINED_GLASS");
if (i < 0) return 0; return i < 0 ? 0 : ColorUtil.dyeArgb(dye(n.substring(0, i)), 0);
try {
return ColorUtil.dyeArgb(DyeColor.valueOf(n.substring(0, i)), 0);
} catch (IllegalArgumentException e) {
return 0;
}
} }
/** Light dampening 15 (full opaque cubes, tinted glass) — the beam stops there. */ /** Light dampening 15 (full opaque cubes, tinted glass) — the beam stops there. */
@@ -516,35 +439,28 @@ public final class BlockEntitySnapshotBuilder {
/** TheEndGatewayBlockEntity.shouldRenderFace ≈ the neighbour is not a full opaque cube; "+"-joined. */ /** TheEndGatewayBlockEntity.shouldRenderFace ≈ the neighbour is not a full opaque cube; "+"-joined. */
private static String gatewayFaces(BlockState ts) { private static String gatewayFaces(BlockState ts) {
Block block = ts.getBlock();
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
for (BlockFace f : new BlockFace[] { for (BlockFace f : GATEWAY_FACES) {
BlockFace.DOWN, BlockFace.UP, BlockFace.NORTH, BlockFace.SOUTH, BlockFace.WEST, BlockFace.EAST if (block.getRelative(f).getType().isOccluding()) continue;
}) {
if (ts.getBlock().getRelative(f).getType().isOccluding()) continue;
if (!sb.isEmpty()) sb.append('+'); if (!sb.isEmpty()) sb.append('+');
sb.append(f.name().toLowerCase(Locale.ROOT)); sb.append(f.name().toLowerCase(Locale.ROOT));
} }
return sb.toString(); return sb.toString();
} }
// --- facing / rotation helpers --- private static final BlockFace[] GATEWAY_FACES = {
BlockFace.DOWN, BlockFace.UP, BlockFace.NORTH, BlockFace.SOUTH, BlockFace.WEST, BlockFace.EAST
};
/** Yaw preferring the block's {@link Directional} facing (wall-mounted block-entities). */ /**
* Yaw in degrees (0 = south, clockwise, vanilla rotation convention) of a wall block's {@link Directional}
* facing or a standing block's {@link Rotatable} rotation (no block data implements both).
*/
private static float facingYaw(BlockData data) { private static float facingYaw(BlockData data) {
if (data instanceof Directional d) return faceToYaw(d.getFacing()); BlockFace face =
if (data instanceof Rotatable r) return faceToYaw(r.getRotation()); data instanceof Directional d ? d.getFacing() : data instanceof Rotatable r ? r.getRotation() : null;
return 0; Float y = face == null ? null : FACE_YAW.get(face);
}
/** Yaw preferring the block's {@link Rotatable} rotation (free-standing block-entities). */
private static float rotationYaw(BlockData data) {
if (data instanceof Rotatable r) return faceToYaw(r.getRotation());
return facingYaw(data);
}
/** Yaw in degrees for a block face, 0 = south increasing clockwise (vanilla rotation convention). */
private static float faceToYaw(BlockFace face) {
Float y = FACE_YAW.get(face);
return y == null ? 0 : y; return y == null ? 0 : y;
} }
@@ -571,19 +487,42 @@ public final class BlockEntitySnapshotBuilder {
return m; return m;
} }
// --- data extraction helpers --- private static String prefix(String name, String suffix) {
return name.substring(0, name.length() - suffix.length());
private static String stripColor(String name, String suffix) {
return name.substring(0, name.length() - suffix.length()).toLowerCase(Locale.ROOT);
} }
/** One sign side → {@link BlockEntityState.SignText}, or null when all four lines are blank. */ /** The dye of an upper-case colour name, or null. */
private static DyeColor dye(String name) {
try {
return DyeColor.valueOf(name);
} catch (IllegalArgumentException e) {
return null;
}
}
/** Weathering stage from {@code [WAXED_][EXPOSED_|WEATHERED_|OXIDIZED_]<suffix>}; null when unweathered. */
private static String copperStage(String name, String suffix) {
String stage = name.replace("WAXED_", "").replace(suffix, "").replace("_", "");
return stage.isEmpty() ? null : stage.toLowerCase(Locale.ROOT);
}
private static List<BannerPattern> patterns(List<org.bukkit.block.banner.Pattern> patterns) {
List<BannerPattern> out = new ArrayList<>(patterns.size());
for (org.bukkit.block.banner.Pattern p : patterns) {
out.add(new BannerPattern(patternKey(p), ColorUtil.dyeArgb(p.getColor(), 0xFFFFFFFF)));
}
return out;
}
private static final Pattern LEGACY_FORMAT = Pattern.compile("§.");
/** Null when all four lines are blank. */
private static BlockEntityState.SignText signText(SignSide side) { private static BlockEntityState.SignText signText(SignSide side) {
String[] raw = side.getLines(); String[] raw = side.getLines();
List<String> lines = new ArrayList<>(raw.length); List<String> lines = new ArrayList<>(raw.length);
boolean any = false; boolean any = false;
for (String l : raw) { for (String l : raw) {
String s = l == null ? "" : l.replaceAll("§.", ""); // strip legacy §-codes String s = l == null ? "" : LEGACY_FORMAT.matcher(l).replaceAll("");
if (!s.isEmpty()) any = true; if (!s.isEmpty()) any = true;
lines.add(s); lines.add(s);
} }
@@ -607,38 +546,30 @@ public final class BlockEntitySnapshotBuilder {
}; };
} }
/** Order of the DecoratedPotRenderer side parts (BlockEntityModels.potParts). */
private static final DecoratedPot.Side[] SHERD_SIDES = {
DecoratedPot.Side.FRONT, DecoratedPot.Side.LEFT, DecoratedPot.Side.RIGHT, DecoratedPot.Side.BACK
};
private static List<String> sherds(DecoratedPot pot) { private static List<String> sherds(DecoratedPot pot) {
// Order: front, left, right, back — the DecoratedPotRenderer side parts (BlockEntityModels.potParts).
List<String> out = new ArrayList<>(4); List<String> out = new ArrayList<>(4);
for (DecoratedPot.Side side : new DecoratedPot.Side[] { for (DecoratedPot.Side side : SHERD_SIDES)
DecoratedPot.Side.FRONT, DecoratedPot.Side.LEFT, DecoratedPot.Side.RIGHT, DecoratedPot.Side.BACK out.add(pot.getSherd(side).name().toLowerCase(Locale.ROOT));
}) {
Material m = pot.getSherd(side);
out.add(m.name().toLowerCase(Locale.ROOT));
}
return out; return out;
} }
private static String skinUrl(Skull skull) { private static String skinUrl(PlayerProfile profile) {
try { return profile == null || profile.getTextures().getSkin() == null
PlayerProfile profile = skull.getOwnerProfile(); ? null
if (profile != null && profile.getTextures().getSkin() != null) { : profile.getTextures().getSkin().toString();
return profile.getTextures().getSkin().toString();
}
} catch (Throwable ignored) {
}
return null;
} }
/** The banner pattern's path key (e.g. "stripe_bottom"); every PatternType key accessor is marked /** Every PatternType key accessor is marked for removal with no stable replacement. */
* for removal in this API with no stable replacement, so the warning is suppressed here. */
@SuppressWarnings("removal") @SuppressWarnings("removal")
private static String patternKey(Pattern p) { private static String patternKey(org.bukkit.block.banner.Pattern p) {
return p.getPattern().getKey().getKey(); return p.getPattern().getKey().getKey();
} }
// --- small fluent builder to keep the record construction readable ---
private static Builder base(Kind kind, int bx, int by, int bz, float yaw) { private static Builder base(Kind kind, int bx, int by, int bz, float yaw) {
return new Builder(kind, bx, by, bz, yaw); return new Builder(kind, bx, by, bz, yaw);
} }
@@ -12,18 +12,17 @@ import org.bukkit.entity.GlowItemFrame;
import org.bukkit.entity.ItemFrame; import org.bukkit.entity.ItemFrame;
import org.bukkit.entity.Painting; import org.bukkit.entity.Painting;
import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.ItemStack;
import org.bukkit.map.MapView;
import org.bukkit.util.BoundingBox; import org.bukkit.util.BoundingBox;
/** /**
* Captures flat wall decorations (paintings + item frames) near the view frustum into immutable * Captures paintings and item frames into {@link DecorationState}s. MUST run on the main thread. The entity's
* {@link DecorationState}s. MUST run on the main thread. Uses each entity's world bounding box directly, * world bounding box already encodes vanilla's painting placement offsets.
* which already encodes vanilla's painting placement offsets.
*/ */
public final class DecorationSnapshotBuilder { public final class DecorationSnapshotBuilder {
private DecorationSnapshotBuilder() {} private DecorationSnapshotBuilder() {}
/** {@code nearby}: {@link FrustumBounds#nearbyEntities} of this photo. */
public static List<DecorationState> build(Collection<Entity> nearby) { public static List<DecorationState> build(Collection<Entity> nearby) {
List<DecorationState> out = new ArrayList<>(); List<DecorationState> out = new ArrayList<>();
for (Entity e : nearby) { for (Entity e : nearby) {
@@ -37,50 +36,48 @@ public final class DecorationSnapshotBuilder {
} }
private static DecorationState toState(Entity e) { private static DecorationState toState(Entity e) {
DecorationState.Kind kind;
String art = null;
ItemSpec item = null;
int[][] map = null;
List<DecorationState.MapMarker> markers = List.of();
int rotation = 0;
boolean glow = false, invisible = false;
if (e instanceof Painting painting) { if (e instanceof Painting painting) {
BoundingBox bb = e.getBoundingBox(); kind = DecorationState.Kind.PAINTING;
String art = painting.getArt().assetId().value(); art = painting.getArt().assetId().value();
return new DecorationState( } else if (e instanceof ItemFrame frame) {
DecorationState.Kind.PAINTING, kind = DecorationState.Kind.ITEM_FRAME;
bb.getMinX(),
bb.getMinY(),
bb.getMinZ(),
bb.getMaxX(),
bb.getMaxY(),
bb.getMaxZ(),
facing(e.getFacing()),
art,
null,
null,
0,
false,
false);
}
if (e instanceof ItemFrame frame) {
BoundingBox bb = e.getBoundingBox();
boolean glow =
e instanceof GlowItemFrame || e.getType().getKey().getKey().equals("glow_item_frame");
ItemStack stack = frame.getItem(); ItemStack stack = frame.getItem();
ItemSpec item = ItemSpecs.of(stack); item = ItemSpecs.of(stack);
int[][] map = item != null && stack.getType() == Material.FILLED_MAP ? MapPixels.of(stack) : null; if (stack.getType() == Material.FILLED_MAP) {
return new DecorationState( MapView view = MapPixels.view(stack);
DecorationState.Kind.ITEM_FRAME, map = MapPixels.of(view);
bb.getMinX(), if (map != null) markers = MapPixels.markers(view);
bb.getMinY(), }
bb.getMinZ(), rotation = frame.getRotation().ordinal();
bb.getMaxX(), glow = e instanceof GlowItemFrame;
bb.getMaxY(), invisible = !frame.isVisible();
bb.getMaxZ(), } else {
facing(e.getFacing()), return null;
null,
item,
map,
frame.getRotation().ordinal(),
glow,
!frame.isVisible(),
map != null ? MapPixels.markers(stack) : List.of());
} }
return null; BoundingBox bb = e.getBoundingBox();
return new DecorationState(
kind,
bb.getMinX(),
bb.getMinY(),
bb.getMinZ(),
bb.getMaxX(),
bb.getMaxY(),
bb.getMaxZ(),
facing(e.getFacing()),
art,
item,
map,
rotation,
glow,
invisible,
markers);
} }
private static DecorationState.Facing facing(BlockFace face) { private static DecorationState.Facing facing(BlockFace face) {
@@ -7,40 +7,57 @@ import eu.mhsl.minecraft.pixelpics.render.entity.EntityState;
import eu.mhsl.minecraft.pixelpics.render.entity.ItemSpec; import eu.mhsl.minecraft.pixelpics.render.entity.ItemSpec;
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil; import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
import io.papermc.paper.datacomponent.DataComponentTypes; import io.papermc.paper.datacomponent.DataComponentTypes;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Base64; import java.util.Base64;
import java.util.Collection; import java.util.Collection;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.TreeSet;
import java.util.UUID; import java.util.UUID;
import java.util.function.DoubleSupplier; import java.util.function.DoubleSupplier;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import org.bukkit.Color;
import org.bukkit.DyeColor; import org.bukkit.DyeColor;
import org.bukkit.Keyed; import org.bukkit.Keyed;
import org.bukkit.Location; import org.bukkit.Location;
import org.bukkit.Material; import org.bukkit.Material;
import org.bukkit.Tag;
import org.bukkit.attribute.Attribute; import org.bukkit.attribute.Attribute;
import org.bukkit.attribute.AttributeInstance; import org.bukkit.attribute.AttributeInstance;
import org.bukkit.block.BlockFace;
import org.bukkit.block.data.type.Bed;
import org.bukkit.entity.AbstractArrow; import org.bukkit.entity.AbstractArrow;
import org.bukkit.entity.AbstractHorse; import org.bukkit.entity.AbstractHorse;
import org.bukkit.entity.Ageable; import org.bukkit.entity.Ageable;
import org.bukkit.entity.ArmorStand; import org.bukkit.entity.ArmorStand;
import org.bukkit.entity.Arrow;
import org.bukkit.entity.Axolotl; import org.bukkit.entity.Axolotl;
import org.bukkit.entity.Bat; import org.bukkit.entity.Bat;
import org.bukkit.entity.Cat; import org.bukkit.entity.Cat;
import org.bukkit.entity.ChestedHorse; import org.bukkit.entity.ChestedHorse;
import org.bukkit.entity.Creaking;
import org.bukkit.entity.Creeper; import org.bukkit.entity.Creeper;
import org.bukkit.entity.Entity; import org.bukkit.entity.Entity;
import org.bukkit.entity.Fox; import org.bukkit.entity.Fox;
import org.bukkit.entity.Horse; import org.bukkit.entity.Horse;
import org.bukkit.entity.HumanEntity; import org.bukkit.entity.HumanEntity;
import org.bukkit.entity.Illusioner;
import org.bukkit.entity.LivingEntity; import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Llama; import org.bukkit.entity.Llama;
import org.bukkit.entity.Mob; import org.bukkit.entity.Mob;
import org.bukkit.entity.MushroomCow; import org.bukkit.entity.MushroomCow;
import org.bukkit.entity.Panda; import org.bukkit.entity.Panda;
import org.bukkit.entity.Parrot; import org.bukkit.entity.Parrot;
import org.bukkit.entity.Piglin;
import org.bukkit.entity.PiglinAbstract;
import org.bukkit.entity.Pillager;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.entity.PufferFish; import org.bukkit.entity.PufferFish;
import org.bukkit.entity.Rabbit; import org.bukkit.entity.Rabbit;
@@ -50,10 +67,12 @@ import org.bukkit.entity.Sittable;
import org.bukkit.entity.Slime; import org.bukkit.entity.Slime;
import org.bukkit.entity.Sniffer; import org.bukkit.entity.Sniffer;
import org.bukkit.entity.Snowman; import org.bukkit.entity.Snowman;
import org.bukkit.entity.Spellcaster;
import org.bukkit.entity.TraderLlama; import org.bukkit.entity.TraderLlama;
import org.bukkit.entity.Trident; import org.bukkit.entity.Trident;
import org.bukkit.entity.TropicalFish; import org.bukkit.entity.TropicalFish;
import org.bukkit.entity.Villager; import org.bukkit.entity.Villager;
import org.bukkit.entity.Vindicator;
import org.bukkit.entity.Wither; import org.bukkit.entity.Wither;
import org.bukkit.entity.Wolf; import org.bukkit.entity.Wolf;
import org.bukkit.entity.ZombieVillager; import org.bukkit.entity.ZombieVillager;
@@ -62,20 +81,20 @@ import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.MainHand; import org.bukkit.inventory.MainHand;
import org.bukkit.inventory.meta.ArmorMeta; import org.bukkit.inventory.meta.ArmorMeta;
import org.bukkit.inventory.meta.CrossbowMeta;
import org.bukkit.inventory.meta.ItemMeta; import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.inventory.meta.LeatherArmorMeta; import org.bukkit.inventory.meta.LeatherArmorMeta;
import org.bukkit.potion.PotionEffectType; import org.bukkit.potion.PotionEffectType;
import org.bukkit.util.BoundingBox;
import org.bukkit.util.EulerAngle;
/** /** Captures entities into {@link EntityState}s. MUST run on the main thread (live entity access). */
* Captures entities near the view frustum into immutable {@link EntityState}s. MUST run on the main
* thread (live entity access). The camera entity is skipped.
*/
public final class EntitySnapshotBuilder { public final class EntitySnapshotBuilder {
private EntitySnapshotBuilder() {} private EntitySnapshotBuilder() {}
// Technical / non-mob entity types that have no meaningful geometry; rendering them would only // Types without a mob model (technical entities, or drawn by the decoration/object builders); they would
// produce stray fallback boxes. Markers, displays, item frames, paintings, projectiles, drops, etc. // otherwise render as fallback boxes.
private static final Set<String> NON_RENDERABLE = Set.of( private static final Set<String> NON_RENDERABLE = Set.of(
"area_effect_cloud", "area_effect_cloud",
"marker", "marker",
@@ -102,7 +121,7 @@ public final class EntitySnapshotBuilder {
// Mobs whose main-hand item is drawn in the mouth (FoxHeldItemLayer, DolphinCarryingItemLayer). // Mobs whose main-hand item is drawn in the mouth (FoxHeldItemLayer, DolphinCarryingItemLayer).
private static final Set<String> MOUTH_CARRIERS = Set.of("fox", "dolphin"); private static final Set<String> MOUTH_CARRIERS = Set.of("fox", "dolphin");
/** {@code nearby}: {@link FrustumBounds#nearbyEntities} of this photo. */ /** {@code shooter}: the photographer, skipped. */
public static List<EntityState> build(Collection<Entity> nearby, UUID shooter) { public static List<EntityState> build(Collection<Entity> nearby, UUID shooter) {
List<EntityState> states = new ArrayList<>(); List<EntityState> states = new ArrayList<>();
for (Entity e : nearby) { for (Entity e : nearby) {
@@ -113,27 +132,13 @@ public final class EntitySnapshotBuilder {
return states; return states;
} }
/** /** The entity's state, or null for non-renderable types. Also used for unspawned spawner display mobs. */
* A not-spawned display entity (spawner mob) as a state at its own position; null for technical types. static EntityState toState(Entity e) {
* MUST run on the main thread.
*/
public static EntityState displayState(Entity e) {
return toState(e);
}
private static EntityState toState(Entity e) {
Location loc = e.getLocation();
// Skip non-renderable technical entities.
String type = e.getType().getKey().getKey(); String type = e.getType().getKey().getKey();
// Boats now have a bundled geometry.boat; rafts use a different hull we don't ship yet — skip those.
if (NON_RENDERABLE.contains(type)) return null; if (NON_RENDERABLE.contains(type)) return null;
Location loc = e.getLocation();
float bodyYaw = e instanceof LivingEntity le ? le.getBodyYaw() : loc.getYaw();
float bodyYaw = loc.getYaw();
if (e instanceof LivingEntity le) {
bodyYaw = le.getBodyYaw();
}
// (Zombie implements Ageable — no extra case; an earlier "|| zombie.isAdult()" made ADULT zombies babies.)
boolean baby = (e instanceof Ageable a && !a.isAdult()) || (e instanceof ArmorStand st && st.isSmall()); boolean baby = (e instanceof Ageable a && !a.isAdult()) || (e instanceof ArmorStand st && st.isSmall());
// Invisible entities render only their equipment (like vanilla): the generic invisible flag, an // Invisible entities render only their equipment (like vanilla): the generic invisible flag, an
@@ -148,8 +153,8 @@ public final class EntitySnapshotBuilder {
boolean player = e instanceof Player; boolean player = e instanceof Player;
String skinUrl = null; String skinUrl = null;
boolean slim = false; boolean slim = false;
if (player) { if (e instanceof Player p) {
String[] skin = resolveSkin((Player) e); String[] skin = resolveSkin(p);
skinUrl = skin[0]; skinUrl = skin[0];
slim = "slim".equals(skin[1]); slim = "slim".equals(skin[1]);
} }
@@ -169,9 +174,8 @@ public final class EntitySnapshotBuilder {
int collar = 0; int collar = 0;
boolean powered = false; boolean powered = false;
try { try {
// Slime & magma cube (MagmaCube extends Slime) scale their model by size (1/2/4). if (e instanceof Slime sl) sizeScale = sl.getSize(); // incl. magma cubes
if (e instanceof Slime sl) sizeScale = sl.getSize(); // Order matters for subtypes (MushroomCow extends Cow, Llama extends ChestedHorse extends AbstractHorse).
// MushroomCow extends Cow, ZombieVillager does not extend Villager — order matters.
if (e instanceof Sheep sh) { if (e instanceof Sheep sh) {
tint = isJebSheep(sh) ? jebWoolColor(sh.getTicksLived()) : sheepWoolColor(sh.getColor()); tint = isJebSheep(sh) ? jebWoolColor(sh.getTicksLived()) : sheepWoolColor(sh.getColor());
if (sh.isSheared()) variant = "sheared"; if (sh.isSheared()) variant = "sheared";
@@ -185,7 +189,7 @@ public final class EntitySnapshotBuilder {
case 1 -> "mid"; case 1 -> "mid";
default -> "big";}; default -> "big";};
} else if (e instanceof Cat c) { } else if (e instanceof Cat c) {
variant = keyOf(c.getCatType()); // data-driven variants: key -> asset_id via the vanilla dump variant = keyOf(c.getCatType());
if (c.isTamed()) collar = ColorUtil.dyeArgb(c.getCollarColor(), 0); if (c.isTamed()) collar = ColorUtil.dyeArgb(c.getCollarColor(), 0);
} else if (e instanceof Wolf w) { } else if (e instanceof Wolf w) {
variant = keyOf(w.getVariant()); variant = keyOf(w.getVariant());
@@ -213,14 +217,11 @@ public final class EntitySnapshotBuilder {
} else if (e instanceof Llama l) { } else if (e instanceof Llama l) {
variant = keyOf(l.getColor()); variant = keyOf(l.getColor());
chest = l.isCarryingChest(); chest = l.isCarryingChest();
// Trader llamas wear a fixed decor; normal llamas carry a dyed carpet in the decor slot.
bodyEquip = (e instanceof TraderLlama) ? "trader_llama" : carpetKey(l); bodyEquip = (e instanceof TraderLlama) ? "trader_llama" : carpetKey(l);
} else if (e instanceof ChestedHorse ch) { } else if (e instanceof ChestedHorse ch) {
// Donkey & mule (llama already handled above).
chest = ch.isCarryingChest(); chest = ch.isCarryingChest();
saddle = isSaddled(ch); saddle = isSaddled(ch);
} else if (e instanceof AbstractHorse ah) { } else if (e instanceof AbstractHorse ah) {
// Skeleton/zombie horse: only saddle (no colour/markings/armor variants).
saddle = isSaddled(ah); saddle = isSaddled(ah);
} else if (e instanceof Fox f) { } else if (e instanceof Fox f) {
variant = keyOf(f.getFoxType()); variant = keyOf(f.getFoxType());
@@ -229,26 +230,23 @@ public final class EntitySnapshotBuilder {
} else if (e instanceof Panda pa) { } else if (e instanceof Panda pa) {
variant = keyOf(pa.getMainGene()); variant = keyOf(pa.getMainGene());
} else if (e instanceof Shulker s) { } else if (e instanceof Shulker s) {
variant = s.getColor() == null ? null : keyOf(s.getColor()); variant = keyOf(s.getColor());
} else if (e instanceof ZombieVillager zv) { } else if (e instanceof ZombieVillager zv) {
variant = keyOf(zv.getVillagerType()); variant = keyOf(zv.getVillagerType());
// ZombieVillager exposes no level via Bukkit -> no profession-level badge (matches vanilla).
} else if (e instanceof Villager vi) { } else if (e instanceof Villager vi) {
variant = keyOf(vi.getVillagerType()); variant = keyOf(vi.getVillagerType());
profession = keyOf(vi.getProfession()); profession = keyOf(vi.getProfession());
villagerLevel = vi.getVillagerLevel(); villagerLevel = vi.getVillagerLevel();
} }
if (e instanceof ArmorStand st) arms = st.hasArms(); if (e instanceof ArmorStand st) arms = st.hasArms();
// Any other mob with a variant/weathering getter (frog, cow, pig, chicken, zombie nautilus, copper // Any other mob with a variant/weathering getter (frog, cow, pig, copper golem, new mobs, …).
// golem, new mobs, …). Reflective on purpose: the API's compile-time declaring interface of // Reflective on purpose: the compile-time declaring interface of getVariant() (AbstractCow) doesn't
// getVariant() (AbstractCow) doesn't exist at runtime on every server build (NoSuchMethodError). // exist at runtime on every server build (NoSuchMethodError).
if (variant == null) variant = genericVariant(e); if (variant == null) variant = genericVariant(e);
} catch (Throwable ignored) { } catch (Throwable ignored) {
// Unsupported on this server version — fall back to the base texture.
} }
// Equipment slots: saddle + BODY equipment (horse armor, llama carpet, harness, wolf/nautilus armor, …) // Saddle + BODY slot for every mount (horse armor, carpet, harness, wolf armor, …), held items by hand
// for every mount — including ones added in newer versions — plus held items (by hand side, like // side (a left-handed mob holds its main-hand item left) and a non-armor head item.
// vanilla: a left-handed mob holds its main-hand item left) and a non-armor head item.
ItemSpec rightHand = null, leftHand = null, headItem = null, mouthItem = null; ItemSpec rightHand = null, leftHand = null, headItem = null, mouthItem = null;
EntityEquipment eq = e instanceof LivingEntity le ? le.getEquipment() : null; EntityEquipment eq = e instanceof LivingEntity le ? le.getEquipment() : null;
if (eq != null) { if (eq != null) {
@@ -257,10 +255,12 @@ public final class EntitySnapshotBuilder {
if (sd != null && !sd.getType().isAir()) saddle = true; if (sd != null && !sd.getType().isAir()) saddle = true;
ItemStack body = eq.getItem(EquipmentSlot.BODY); ItemStack body = eq.getItem(EquipmentSlot.BODY);
String asset = equipAsset(body); String asset = equipAsset(body);
if (asset != null && bodyEquip == null) bodyEquip = asset; if (asset != null) {
if (asset != null) bodyDye = dyeOf(body); if (bodyEquip == null) bodyEquip = asset;
bodyDye = dyeOf(body);
}
} catch (Throwable ignored) { } catch (Throwable ignored) {
// slot not supported by this entity / server version // slot not supported by this entity
} }
try { try {
boolean leftHanded = (e instanceof Mob mob && mob.isLeftHanded()) boolean leftHanded = (e instanceof Mob mob && mob.isLeftHanded())
@@ -268,7 +268,7 @@ public final class EntitySnapshotBuilder {
ItemSpec main = ItemSpecs.of(eq.getItemInMainHand()); ItemSpec main = ItemSpecs.of(eq.getItemInMainHand());
ItemSpec off = ItemSpecs.of(eq.getItemInOffHand()); ItemSpec off = ItemSpecs.of(eq.getItemInOffHand());
if (MOUTH_CARRIERS.contains(type)) { if (MOUTH_CARRIERS.contains(type)) {
mouthItem = main; // fox/dolphin carry their main-hand item in the mouth, not in a hand mouthItem = main;
} else { } else {
rightHand = leftHanded ? off : main; rightHand = leftHanded ? off : main;
leftHand = leftHanded ? main : off; leftHand = leftHanded ? main : off;
@@ -282,20 +282,18 @@ public final class EntitySnapshotBuilder {
headItem = ItemSpecs.of(new ItemStack(Material.CARVED_PUMPKIN)); headItem = ItemSpecs.of(new ItemStack(Material.CARVED_PUMPKIN));
} }
} catch (Throwable ignored) { } catch (Throwable ignored) {
// no hands / equipment on this entity
} }
} }
// Worn armor of every living entity; the baker draws it only for renderers with a HumanoidArmorLayer // Worn armor of every living entity; the baker draws it only for renderers with a HumanoidArmorLayer.
// (vanilla dump), so the wearer list follows the game version. EntityState.Equipment equipment = eq == null ? null : captureEquipment(eq);
EntityState.Equipment equipment = e instanceof LivingEntity wearer ? captureEquipment(wearer) : null;
String pose = poseFlags(e); String pose = poseFlags(e);
double x = loc.getX(), z = loc.getZ(); double x = loc.getX(), z = loc.getZ();
if (pose != null && pose.contains("sleeping") && e instanceof LivingEntity sleeper && !(e instanceof Fox)) { if (pose != null && pose.contains("sleeping") && e instanceof LivingEntity sleeper && !(e instanceof Fox)) {
// LivingEntityRenderer: a sleeper in a bed lies along the bed (yaw from the bed direction instead of // LivingEntityRenderer: a sleeper in a bed lies along the bed (yaw from the bed direction instead of
// the body yaw) and is shifted by its standing eye height - 0.1 towards the foot end. // the body yaw) and is shifted by its standing eye height - 0.1 towards the foot end.
org.bukkit.block.BlockFace bed = bedFacing(e); BlockFace bed = bedFacing(e);
if (bed != null) { if (bed != null) {
bodyYaw = switch (bed) { bodyYaw = switch (bed) {
case SOUTH -> 90; case SOUTH -> 90;
@@ -307,7 +305,7 @@ public final class EntitySnapshotBuilder {
z -= bed.getModZ() * off; z -= bed.getModZ() * off;
} }
} }
java.util.Map<String, float[]> partPoses = e instanceof ArmorStand as ? armorStandPoses(as) : null; Map<String, float[]> partPoses = e instanceof ArmorStand as ? armorStandPoses(as) : null;
String shoulderLeft = null, shoulderRight = null; String shoulderLeft = null, shoulderRight = null;
if (e instanceof HumanEntity he) { if (e instanceof HumanEntity he) {
shoulderLeft = shoulderParrot(he, true); shoulderLeft = shoulderParrot(he, true);
@@ -355,14 +353,11 @@ public final class EntitySnapshotBuilder {
} }
/** /**
* The entity's state-pose flags (names of the vanilla dump's STATE_POSES, sorted, "+"-joined; null = idle): * State-pose flags (names of the vanilla dump's STATE_POSES, sorted, "+"-joined; null = idle): the generic
* the generic {@link Entity#getPose()} (sleeping, crouching, swimming/crawling, fall_flying, sitting, * pose plus the per-mob flags vanilla's renderers read from the entity. The baker ignores unknown flags.
* digging) plus the per-mob flags vanilla's renderers read from the entity (sitting pets/pandas/camels,
* fox sleeping/crouching/leaping/faceplanted, cat lying/head up/on a sleeping player, panda on its back,
* resting bat, standing/dancing parrot) and {@code in_water}. The baker ignores flags a type doesn't know.
*/ */
private static String poseFlags(Entity e) { private static String poseFlags(Entity e) {
java.util.TreeSet<String> f = new java.util.TreeSet<>(); TreeSet<String> f = new TreeSet<>();
try { try {
switch (e.getPose()) { switch (e.getPose()) {
case SLEEPING -> f.add("sleeping"); case SLEEPING -> f.add("sleeping");
@@ -400,68 +395,57 @@ public final class EntitySnapshotBuilder {
} }
renderFlags(e, f); renderFlags(e, f);
} catch (Throwable ignored) { } catch (Throwable ignored) {
// API missing on this server version: render the flags found so far // keep the flags found so far
} }
return f.isEmpty() ? null : String.join("+", f); return f.isEmpty() ? null : String.join("+", f);
} }
/** /** Render-state flags beyond poses: arm poses ({@code arms_<pose>}), textures and layers. */
* Render-state flags beyond poses (the baker ignores those a type doesn't use): mob arm poses private static void renderFlags(Entity e, Set<String> f) {
* ({@code arms_<pose>}, the dump's IllagerArmPose / PiglinArmPose constants), angry wolves ({@code angry}:
* angry texture + raised tail), tipped arrows ({@code tipped}), the invulnerable (blinking) wither
* ({@code invulnerable}), active creakings ({@code eyes_glowing}) and sheep with an undercoat
* ({@code undercoat}).
*/
private static void renderFlags(Entity e, java.util.Set<String> f) {
String arms = armPose(e); String arms = armPose(e);
if (arms != null) f.add("arms_" + arms); if (arms != null) f.add("arms_" + arms);
if (e instanceof Wolf w && w.isAngry()) f.add("angry"); if (e instanceof Wolf w && w.isAngry()) f.add("angry");
// TippableArrowRenderer: isTipped = Arrow.getColor() > 0 — the raw ARGB int, so only colours without the // TippableArrowRenderer: isTipped = Arrow.getColor() > 0 — the raw ARGB int, so only colours without the
// alpha bit (custom RGB colours) count; potion colours are opaque ARGB (negative) and keep the plain arrow. // alpha bit (custom RGB colours) count; potion colours are opaque ARGB (negative) and keep the plain arrow.
if (e instanceof org.bukkit.entity.Arrow ar if (e instanceof Arrow ar && ar.getColor() != null && ar.getColor().asARGB() > 0) f.add("tipped");
&& ar.getColor() != null
&& ar.getColor().asARGB() > 0) {
f.add("tipped");
}
if (e instanceof Wither wi) { if (e instanceof Wither wi) {
// WitherBossRenderer.getTextureLocation: invulnerable texture above 80 ticks, then blinking every 5. // WitherBossRenderer.getTextureLocation: invulnerable texture above 80 ticks, then blinking every 5.
int t = wi.getInvulnerableTicks(); int t = wi.getInvulnerableTicks();
if (t > 0 && (t > 80 || t / 5 % 2 != 1)) f.add("invulnerable"); if (t > 0 && (t > 80 || t / 5 % 2 != 1)) f.add("invulnerable");
} }
if (e instanceof org.bukkit.entity.Creaking cr && cr.isActive()) f.add("eyes_glowing"); if (e instanceof Creaking cr && cr.isActive()) f.add("eyes_glowing");
// SheepWoolUndercoatLayer: jeb_ or non-white wool (adults only, decided by the baker), sheared or not. // SheepWoolUndercoatLayer: jeb_ or non-white wool (adults only, decided by the baker), sheared or not.
if (e instanceof Sheep sh && (isJebSheep(sh) || sh.getColor() != DyeColor.WHITE)) f.add("undercoat"); if (e instanceof Sheep sh && (isJebSheep(sh) || sh.getColor() != DyeColor.WHITE)) f.add("undercoat");
} }
/** /**
* Arm pose of a mob whose renderer reads one from the entity (lower-case enum constant; null = the idle * Arm pose (lower-case IllagerArmPose / PiglinArmPose constant; null = idle) like Piglin/PiglinBrute.getArmPose
* pose): Piglin/PiglinBrute.getArmPose (dancing, admiring a loved off-hand item, aggressive with a melee * and the illagers' getArmPose.
* weapon, charging / holding a charged crossbow) and the illagers' getArmPose (pillager: crossbow charge /
* hold, else attacking or neutral; vindicator: attacking / celebrating; spellcasters: spellcasting,
* illusioner bow, celebrating; otherwise crossed).
*/ */
private static String armPose(Entity e) { private static String armPose(Entity e) {
boolean aggressive = e instanceof Mob m && m.isAggressive(); boolean aggressive = e instanceof Mob m && m.isAggressive();
if (e instanceof org.bukkit.entity.PiglinAbstract pa) { if (e instanceof PiglinAbstract pa) {
EntityEquipment eq = pa.getEquipment(); EntityEquipment eq = pa.getEquipment();
ItemStack main = eq == null ? null : eq.getItemInMainHand(); ItemStack main = eq == null ? null : eq.getItemInMainHand();
ItemStack off = eq == null ? null : eq.getItemInOffHand(); ItemStack off = eq == null ? null : eq.getItemInOffHand();
boolean melee = main != null && !main.getType().isAir() && main.hasData(DataComponentTypes.TOOL); boolean melee = main != null && !main.getType().isAir() && main.hasData(DataComponentTypes.TOOL);
if (e instanceof org.bukkit.entity.Piglin p) { if (e instanceof Piglin p) {
if (p.isDancing()) return "dancing"; if (p.isDancing()) return "dancing";
if (off != null && org.bukkit.Tag.ITEMS_PIGLIN_LOVED.isTagged(off.getType())) return "admiring_item"; if (off != null && Tag.ITEMS_PIGLIN_LOVED.isTagged(off.getType())) return "admiring_item";
if (aggressive && melee) return "attacking_with_melee_weapon"; if (aggressive && melee) return "attacking_with_melee_weapon";
if (p.isChargingCrossbow()) return "crossbow_charge"; if (p.isChargingCrossbow()) return "crossbow_charge";
boolean holding = (main != null && main.getType() == Material.CROSSBOW) boolean holding = (main != null && main.getType() == Material.CROSSBOW)
|| (off != null && off.getType() == Material.CROSSBOW); || (off != null && off.getType() == Material.CROSSBOW);
boolean charged = main != null return holding
&& main.getItemMeta() instanceof org.bukkit.inventory.meta.CrossbowMeta cm && main != null
&& cm.hasChargedProjectiles(); && main.getItemMeta() instanceof CrossbowMeta cm
return holding && charged ? "crossbow_hold" : null; && cm.hasChargedProjectiles()
? "crossbow_hold"
: null;
} }
return aggressive && melee ? "attacking_with_melee_weapon" : null; return aggressive && melee ? "attacking_with_melee_weapon" : null;
} }
if (e instanceof org.bukkit.entity.Pillager pi) { if (e instanceof Pillager pi) {
if (pi.isChargingAttack()) return "crossbow_charge"; if (pi.isChargingAttack()) return "crossbow_charge";
EntityEquipment eq = pi.getEquipment(); EntityEquipment eq = pi.getEquipment();
boolean crossbow = eq != null boolean crossbow = eq != null
@@ -469,12 +453,12 @@ public final class EntitySnapshotBuilder {
|| eq.getItemInOffHand().getType() == Material.CROSSBOW); || eq.getItemInOffHand().getType() == Material.CROSSBOW);
return crossbow ? "crossbow_hold" : aggressive ? "attacking" : "neutral"; return crossbow ? "crossbow_hold" : aggressive ? "attacking" : "neutral";
} }
if (e instanceof org.bukkit.entity.Vindicator v) { if (e instanceof Vindicator v) {
return aggressive ? "attacking" : v.isCelebrating() ? "celebrating" : null; return aggressive ? "attacking" : v.isCelebrating() ? "celebrating" : null;
} }
if (e instanceof org.bukkit.entity.Spellcaster sc) { if (e instanceof Spellcaster sc) {
if (sc.getSpell() != org.bukkit.entity.Spellcaster.Spell.NONE) return "spellcasting"; if (sc.getSpell() != Spellcaster.Spell.NONE) return "spellcasting";
if (e instanceof org.bukkit.entity.Illusioner) return aggressive ? "bow_and_arrow" : null; if (e instanceof Illusioner) return aggressive ? "bow_and_arrow" : null;
return sc.isCelebrating() ? "celebrating" : null; return sc.isCelebrating() ? "celebrating" : null;
} }
return null; return null;
@@ -482,11 +466,9 @@ public final class EntitySnapshotBuilder {
/** SheepRenderer: a sheep named "jeb_" (checkMagicName) cycles through all wool colours. */ /** SheepRenderer: a sheep named "jeb_" (checkMagicName) cycles through all wool colours. */
private static boolean isJebSheep(Sheep sh) { private static boolean isJebSheep(Sheep sh) {
net.kyori.adventure.text.Component name = sh.customName(); Component name = sh.customName();
return name != null return name != null
&& "jeb_" && "jeb_".equals(PlainTextComponentSerializer.plainText().serialize(name));
.equals(net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText()
.serialize(name));
} }
/** /**
@@ -494,12 +476,9 @@ public final class EntitySnapshotBuilder {
*/ */
private static int sheepWoolColor(DyeColor dye) { private static int sheepWoolColor(DyeColor dye) {
if (dye == null || dye == DyeColor.WHITE) return 0xFFE6E6E6; if (dye == null || dye == DyeColor.WHITE) return 0xFFE6E6E6;
org.bukkit.Color c = dye.getColor(); Color c = dye.getColor();
return ColorUtil.argb( return ColorUtil.argb(
0xFF, 0xFF, (int) (c.getRed() * 0.75f), (int) (c.getGreen() * 0.75f), (int) (c.getBlue() * 0.75f));
Math.clamp((int) Math.floor(c.getRed() * 0.75f), 0, 255),
Math.clamp((int) Math.floor(c.getGreen() * 0.75f), 0, 255),
Math.clamp((int) Math.floor(c.getBlue() * 0.75f), 0, 255));
} }
/** /**
@@ -523,7 +502,7 @@ public final class EntitySnapshotBuilder {
/** Cat.isLyingOnTopOfSleepingPlayer: a sleeping player within the cat's block box inflated by 2. */ /** Cat.isLyingOnTopOfSleepingPlayer: a sleeping player within the cat's block box inflated by 2. */
private static boolean sleepingPlayerNear(Entity cat) { private static boolean sleepingPlayerNear(Entity cat) {
Location l = cat.getLocation(); Location l = cat.getLocation();
org.bukkit.util.BoundingBox box = new org.bukkit.util.BoundingBox( BoundingBox box = new BoundingBox(
l.getBlockX() - 2, l.getBlockX() - 2,
l.getBlockY() - 2, l.getBlockY() - 2,
l.getBlockZ() - 2, l.getBlockZ() - 2,
@@ -542,30 +521,48 @@ public final class EntitySnapshotBuilder {
*/ */
private static double standingEyeHeight(LivingEntity le) { private static double standingEyeHeight(LivingEntity le) {
try { try {
Object handle = le.getClass().getMethod("getHandle").invoke(le); Object handle = handle(le);
for (java.lang.reflect.Method m : handle.getClass().getMethods()) { StandingEyeHeight m = STANDING_EYE_HEIGHT.get(handle.getClass());
if (!m.getName().equals("getEyeHeight") || m.getParameterCount() != 1) continue; if (m != null) return ((Number) m.method.invoke(handle, m.standing)).doubleValue();
Class<?> pose = m.getParameterTypes()[0];
if (!pose.isEnum()) continue;
for (Object c : pose.getEnumConstants()) {
if (((Enum<?>) c).name().equals("STANDING")) return ((Number) m.invoke(handle, c)).doubleValue();
}
}
} catch (ReflectiveOperationException | RuntimeException ignored) { } catch (ReflectiveOperationException | RuntimeException ignored) {
// fall through
} }
return le.getEyeHeight(true); return le.getEyeHeight(true);
} }
private record StandingEyeHeight(Method method, Object standing) {}
/** Per NMS entity class: its {@code getEyeHeight(Pose)} with the STANDING constant, or null. */
private static final ClassValue<StandingEyeHeight> STANDING_EYE_HEIGHT = new ClassValue<>() {
@Override
protected StandingEyeHeight computeValue(Class<?> c) {
for (Method m : c.getMethods()) {
if (!m.getName().equals("getEyeHeight") || m.getParameterCount() != 1) continue;
Class<?> pose = m.getParameterTypes()[0];
if (!pose.isEnum()) continue;
for (Object p : pose.getEnumConstants()) {
if (((Enum<?>) p).name().equals("STANDING")) return new StandingEyeHeight(m, p);
}
}
return null;
}
};
private static final ClassValue<Method> GET_HANDLE = Reflection.method("getHandle"); // Craft entity
private static Object handle(Entity e) throws ReflectiveOperationException {
Method m = GET_HANDLE.get(e.getClass());
if (m == null) throw new NoSuchMethodException("getHandle");
return m.invoke(e);
}
/** Facing of the bed the entity sleeps in (LivingEntity.getBedOrientation = the bed block's FACING), or null. */ /** Facing of the bed the entity sleeps in (LivingEntity.getBedOrientation = the bed block's FACING), or null. */
private static org.bukkit.block.BlockFace bedFacing(Entity e) { private static BlockFace bedFacing(Entity e) {
org.bukkit.block.data.BlockData bd = e.getLocation().getBlock().getBlockData(); return e.getLocation().getBlock().getBlockData() instanceof Bed bed ? bed.getFacing() : null;
return bd instanceof org.bukkit.block.data.type.Bed bed ? bed.getFacing() : null;
} }
/** Armor-stand part poses in degrees, keyed by the vanilla render-state field (dump rotation bindings). */ /** Armor-stand part poses in degrees, keyed by the vanilla render-state field (dump rotation bindings). */
private static java.util.Map<String, float[]> armorStandPoses(ArmorStand as) { private static Map<String, float[]> armorStandPoses(ArmorStand as) {
java.util.Map<String, float[]> out = new java.util.HashMap<>(); Map<String, float[]> out = new HashMap<>();
out.put("headPose", deg(as.getHeadPose())); out.put("headPose", deg(as.getHeadPose()));
out.put("bodyPose", deg(as.getBodyPose())); out.put("bodyPose", deg(as.getBodyPose()));
out.put("leftArmPose", deg(as.getLeftArmPose())); out.put("leftArmPose", deg(as.getLeftArmPose()));
@@ -575,7 +572,7 @@ public final class EntitySnapshotBuilder {
return out; return out;
} }
private static float[] deg(org.bukkit.util.EulerAngle a) { private static float[] deg(EulerAngle a) {
return new float[] { return new float[] {
(float) Math.toDegrees(a.getX()), (float) Math.toDegrees(a.getY()), (float) Math.toDegrees(a.getZ()) (float) Math.toDegrees(a.getX()), (float) Math.toDegrees(a.getY()), (float) Math.toDegrees(a.getZ())
}; };
@@ -592,11 +589,9 @@ public final class EntitySnapshotBuilder {
} }
} }
/** Worn armor (4 slots) from a humanoid wearer; null when nothing is equipped. */ /** Worn armor; null when nothing is equipped. */
private static EntityState.Equipment captureEquipment(LivingEntity le) { private static EntityState.Equipment captureEquipment(EntityEquipment eq) {
try { try {
EntityEquipment eq = le.getEquipment();
if (eq == null) return null;
EntityState.Equipment equip = new EntityState.Equipment( EntityState.Equipment equip = new EntityState.Equipment(
armorPiece(eq.getHelmet()), armorPiece(eq.getChestplate()), armorPiece(eq.getHelmet()), armorPiece(eq.getChestplate()),
armorPiece(eq.getLeggings()), armorPiece(eq.getBoots())); armorPiece(eq.getLeggings()), armorPiece(eq.getBoots()));
@@ -626,20 +621,22 @@ public final class EntitySnapshotBuilder {
return new EntityState.EquipPiece(asset, dye, trimMat, trimPat, glint); return new EntityState.EquipPiece(asset, dye, trimMat, trimPat, glint);
} }
private static final String[] ARMOR_SUFFIXES = {"_helmet", "_chestplate", "_leggings", "_boots"};
/** Item Material -> equipment asset id (= texture name): strips the slot suffix; null if not armor. */ /** Item Material -> equipment asset id (= texture name): strips the slot suffix; null if not armor. */
private static String armorAsset(Material m) { private static String armorAsset(Material m) {
String key = m.getKey().getKey(); String key = m.getKey().getKey();
if (key.equals("elytra")) return "elytra"; if (key.equals("elytra")) return "elytra";
if (key.equals("turtle_helmet")) return "turtle_scute"; if (key.equals("turtle_helmet")) return "turtle_scute";
String base = null; for (String suf : ARMOR_SUFFIXES) {
for (String suf : new String[] {"_helmet", "_chestplate", "_leggings", "_boots"}) { if (key.endsWith(suf)) return assetMaterial(key.substring(0, key.length() - suf.length()));
if (key.endsWith(suf)) {
base = key.substring(0, key.length() - suf.length());
break;
}
} }
if (base == null) return null; return null;
return base.equals("golden") ? "gold" : base; }
/** Equipment assets name the gold material "gold", items "golden". */
private static String assetMaterial(String itemMaterial) {
return itemMaterial.equals("golden") ? "gold" : itemMaterial;
} }
/** Horse coat markings overlay key (vanilla texture suffix); null for the plain NONE style. */ /** Horse coat markings overlay key (vanilla texture suffix); null for the plain NONE style. */
@@ -652,8 +649,7 @@ public final class EntitySnapshotBuilder {
private static String horseArmorKey(Horse h) { private static String horseArmorKey(Horse h) {
ItemStack a = h.getInventory().getArmor(); ItemStack a = h.getInventory().getArmor();
if (a == null || a.getType().isAir()) return null; if (a == null || a.getType().isAir()) return null;
String k = a.getType().getKey().getKey().replace("_horse_armor", ""); return assetMaterial(a.getType().getKey().getKey().replace("_horse_armor", ""));
return k.equals("golden") ? "gold" : k;
} }
/** Llama carpet decor -> equipment/llama_body colour key; null if none. */ /** Llama carpet decor -> equipment/llama_body colour key; null if none. */
@@ -685,17 +681,7 @@ public final class EntitySnapshotBuilder {
} }
} }
/** Per NMS entity class: its public {@code shakeTime} field (AbstractArrow), or null. */ private static final ClassValue<Field> SHAKE_FIELD = Reflection.field("shakeTime"); // NMS AbstractArrow
private static final ClassValue<java.lang.reflect.Field> SHAKE_FIELD = new ClassValue<>() {
@Override
protected java.lang.reflect.Field computeValue(Class<?> c) {
try {
return c.getField("shakeTime");
} catch (NoSuchFieldException ex) {
return null;
}
}
};
/** /**
* Vanilla impact wobble of a freshly stuck arrow (ArrowModel#setupAnim, partialTick 0): the model root turns * Vanilla impact wobble of a freshly stuck arrow (ArrowModel#setupAnim, partialTick 0): the model root turns
@@ -706,8 +692,8 @@ public final class EntitySnapshotBuilder {
private static float arrowShake(Entity e) { private static float arrowShake(Entity e) {
if (!(e instanceof AbstractArrow) || e instanceof Trident) return 0; if (!(e instanceof AbstractArrow) || e instanceof Trident) return 0;
try { try {
Object handle = e.getClass().getMethod("getHandle").invoke(e); Object handle = handle(e);
java.lang.reflect.Field f = SHAKE_FIELD.get(handle.getClass()); Field f = SHAKE_FIELD.get(handle.getClass());
int shake = f == null ? 0 : f.getInt(handle); int shake = f == null ? 0 : f.getInt(handle);
return shake <= 0 ? 0 : (float) (-Math.sin(shake * 3.0) * shake); return shake <= 0 ? 0 : (float) (-Math.sin(shake * 3.0) * shake);
} catch (ReflectiveOperationException | RuntimeException ex) { } catch (ReflectiveOperationException | RuntimeException ex) {
@@ -715,24 +701,10 @@ public final class EntitySnapshotBuilder {
} }
} }
/** Per entity class: its {@code getVariant()} / {@code getWeatheringState()} getter, or null. */ private static final ClassValue<Method> VARIANT_GETTER = Reflection.method("getVariant", "getWeatheringState");
private static final ClassValue<java.lang.reflect.Method> VARIANT_GETTER = new ClassValue<>() {
@Override
protected java.lang.reflect.Method computeValue(Class<?> c) {
for (String m : new String[] {"getVariant", "getWeatheringState"}) {
try {
return c.getMethod(m);
} catch (NoSuchMethodException ignored) {
// try the next name
}
}
return null;
}
};
/** Variant key via a {@code getVariant()} / {@code getWeatheringState()} getter, if the entity has one. */
private static String genericVariant(Entity e) { private static String genericVariant(Entity e) {
java.lang.reflect.Method m = VARIANT_GETTER.get(e.getClass()); Method m = VARIANT_GETTER.get(e.getClass());
if (m == null) return null; if (m == null) return null;
try { try {
return keyOf(m.invoke(e)); return keyOf(m.invoke(e));
@@ -741,7 +713,6 @@ public final class EntitySnapshotBuilder {
} }
} }
/** Whether a horse-like mount carries a saddle in its dedicated saddle slot. */
private static boolean isSaddled(AbstractHorse h) { private static boolean isSaddled(AbstractHorse h) {
ItemStack st = h.getInventory().getSaddle(); ItemStack st = h.getInventory().getSaddle();
return st != null && !st.getType().isAir(); return st != null && !st.getType().isAir();
@@ -8,9 +8,8 @@ import org.bukkit.entity.Entity;
import org.bukkit.util.Vector; import org.bukkit.util.Vector;
/** /**
* Axis-aligned world-space bounds of the camera frustum: the min/max corner of the camera origin * Axis-aligned world-space bounds of the camera frustum (origin plus every ray end at {@code maxDistance}),
* together with every ray endpoint at {@code maxDistance}. Shared by all snapshot builders to size * computed once per photo and shared by all snapshot builders.
* their chunk/entity queries identically.
*/ */
public final class FrustumBounds { public final class FrustumBounds {
@@ -26,13 +25,14 @@ public final class FrustumBounds {
this.maxZ = maxZ; this.maxZ = maxZ;
} }
static FrustumBounds of(Vector origin, List<Vector> rayMap, double maxDistance) { public static FrustumBounds of(Location eye, List<Vector> rayMap, double maxDistance) {
double minX = origin.getX(), minY = origin.getY(), minZ = origin.getZ(); double ox = eye.getX(), oy = eye.getY(), oz = eye.getZ();
double maxX = origin.getX(), maxY = origin.getY(), maxZ = origin.getZ(); double minX = ox, minY = oy, minZ = oz;
double maxX = ox, maxY = oy, maxZ = oz;
for (Vector ray : rayMap) { for (Vector ray : rayMap) {
double fx = origin.getX() + ray.getX() * maxDistance; double fx = ox + ray.getX() * maxDistance;
double fy = origin.getY() + ray.getY() * maxDistance; double fy = oy + ray.getY() * maxDistance;
double fz = origin.getZ() + ray.getZ() * maxDistance; double fz = oz + ray.getZ() * maxDistance;
minX = Math.min(minX, fx); minX = Math.min(minX, fx);
maxX = Math.max(maxX, fx); maxX = Math.max(maxX, fx);
minY = Math.min(minY, fy); minY = Math.min(minY, fy);
@@ -43,20 +43,15 @@ public final class FrustumBounds {
return new FrustumBounds(minX, minY, minZ, maxX, maxY, maxZ); return new FrustumBounds(minX, minY, minZ, maxX, maxY, maxZ);
} }
/** All entities whose centre falls within these bounds expanded by {@code margin} on every side. */
/** /**
* All entities near the view frustum, queried ONCE per photo and shared by the entity, decoration and * Entities near the frustum (2-block margin), queried once per photo and shared by the entity, decoration
* object snapshot builders. MUST run on the main thread. * and object builders. MUST run on the main thread.
*/ */
public static Collection<Entity> nearbyEntities(Location eye, List<Vector> rayMap, double maxDistance) { public Collection<Entity> nearbyEntities(World world) {
return of(eye.toVector(), rayMap, maxDistance).nearbyEntities(eye.getWorld(), 2);
}
Collection<Entity> nearbyEntities(World world, double margin) {
Location center = new Location(world, (minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2); Location center = new Location(world, (minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2);
double hx = (maxX - minX) / 2 + margin; double hx = (maxX - minX) / 2 + 2;
double hy = (maxY - minY) / 2 + margin; double hy = (maxY - minY) / 2 + 2;
double hz = (maxZ - minZ) / 2 + margin; double hz = (maxZ - minZ) / 2 + 2;
return world.getNearbyEntities(center, hx, hy, hz); return world.getNearbyEntities(center, hx, hy, hz);
} }
} }
@@ -9,17 +9,17 @@ import org.bukkit.block.data.BlockData;
import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.ItemStack;
/** Builds {@link ItemSpec}s from Bukkit item stacks. MUST run on the main thread. */ /** Builds {@link ItemSpec}s from Bukkit item stacks. MUST run on the main thread. */
public final class ItemSpecs { final class ItemSpecs {
private ItemSpecs() {} private ItemSpecs() {}
/** Block data + block-entity state depend only on the material: computed once per material. */ /** Block data + block-entity state depend only on the material. */
private record BlockInfo(BlockData data, BlockEntityState blockEntity) {} private record BlockInfo(BlockData data, BlockEntityState blockEntity) {}
private static final Map<Material, BlockInfo> BLOCKS = new ConcurrentHashMap<>(); private static final Map<Material, BlockInfo> BLOCKS = new ConcurrentHashMap<>();
/** The spec for a stack, or null for empty/air. */ /** The spec for a stack, or null for empty/air. */
public static ItemSpec of(ItemStack stack) { static ItemSpec of(ItemStack stack) {
if (stack == null || stack.getType().isAir()) return null; if (stack == null || stack.getType().isAir()) return null;
Material m = stack.getType(); Material m = stack.getType();
BlockInfo b = BLOCKS.computeIfAbsent(m, ItemSpecs::blockInfo); BlockInfo b = BLOCKS.computeIfAbsent(m, ItemSpecs::blockInfo);
@@ -30,11 +30,10 @@ public final class ItemSpecs {
glint = false; glint = false;
} }
BlockEntityState be = b.blockEntity; BlockEntityState be = b.blockEntity;
// Head skins / banner patterns live on the stack, not the material (only those kinds pay for the meta). // Head skins / banner patterns live on the stack: only those kinds pay for the meta copy.
if (be != null && ("player".equals(be.headType()) || be.kind() == BlockEntityState.Kind.BANNER)) { if (be != null && ("player".equals(be.headType()) || be.kind() == BlockEntityState.Kind.BANNER)) {
be = BlockEntitySnapshotBuilder.withItemMeta(be, stack.getItemMeta()); be = BlockEntitySnapshotBuilder.withItemMeta(be, stack.getItemMeta());
} else if (m == Material.SHIELD) { } else if (m == Material.SHIELD) {
// Shield banner patterns + base colour (ShieldSpecialRenderer), carried as a BANNER state.
be = BlockEntitySnapshotBuilder.shieldPatterns(stack.getItemMeta()); be = BlockEntitySnapshotBuilder.shieldPatterns(stack.getItemMeta());
} }
return new ItemSpec(m.getKey().getKey(), b.data, be, glint); return new ItemSpec(m.getKey().getKey(), b.data, be, glint);
@@ -5,7 +5,9 @@ import eu.mhsl.minecraft.pixelpics.utils.ImageMapRenderer;
import eu.mhsl.minecraft.pixelpics.utils.MapColorPalette; import eu.mhsl.minecraft.pixelpics.utils.MapColorPalette;
import eu.mhsl.minecraft.pixelpics.utils.MapManager; import eu.mhsl.minecraft.pixelpics.utils.MapManager;
import java.lang.reflect.Field; import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.ItemStack;
@@ -14,22 +16,30 @@ import org.bukkit.map.MapRenderer;
import org.bukkit.map.MapView; import org.bukkit.map.MapView;
/** /**
* Captures a filled map's 128x128 colours for rendering it in an item frame. PixelPics photo maps come from * Captures a filled map's 128x128 colours (and vanilla frame markers) for rendering it in an item frame.
* their {@link ImageMapRenderer} (or the on-disk index cache), other maps from the server's map data * PixelPics photo maps come from their {@link ImageMapRenderer} (or the on-disk index cache), other maps from
* ({@code MapItemSavedData.colors}, reflectively — Bukkit has no API to read it). MUST run on the main thread. * the server's {@code MapItemSavedData} via reflection (Bukkit has no API to read it). MUST run on the main
* thread.
*/ */
public final class MapPixels { final class MapPixels {
private static final int SIZE = ImageMapRenderer.MAP_SIZE; private static final int SIZE = ImageMapRenderer.MAP_SIZE;
private MapPixels() {} private MapPixels() {}
/** ARGB {@code [y][x]}, or null when the stack is no map or has no map data (vanilla then draws the item). */ /** The stack's map view, or null when it has none. */
public static int[][] of(ItemStack stack) { static MapView view(ItemStack stack) {
try {
return stack.getItemMeta() instanceof MapMeta meta && meta.hasMapView() ? meta.getMapView() : null;
} catch (Throwable t) {
return null;
}
}
/** ARGB {@code [y][x]}, or null without map data (vanilla then draws the item). */
static int[][] of(MapView view) {
if (view == null) return null;
try { try {
if (!(stack.getItemMeta() instanceof MapMeta meta) || !meta.hasMapView()) return null;
MapView view = meta.getMapView();
if (view == null) return null;
byte[] colors = colors(view); byte[] colors = colors(view);
if (colors == null || colors.length != SIZE * SIZE) return null; if (colors == null || colors.length != SIZE * SIZE) return null;
int[][] out = new int[SIZE][SIZE]; int[][] out = new int[SIZE][SIZE];
@@ -53,26 +63,25 @@ public final class MapPixels {
byte[] cached = MapManager.readIndices(view.getId()); byte[] cached = MapManager.readIndices(view.getId());
if (cached != null) return cached; if (cached != null) return cached;
} }
return worldMapColors(view); Object data = worldMap(view);
Field colors = data == null ? null : COLORS.get(data.getClass());
return colors == null ? null : (byte[]) colors.get(data);
} }
/** A photo still being rendered shows blank film, as its map does. */ /** A photo still being rendered shows blank film, as its map does. */
private static byte[] film() { private static byte[] film() {
byte[] b = new byte[SIZE * SIZE]; byte[] b = new byte[SIZE * SIZE];
java.util.Arrays.fill(b, ImageMapRenderer.filmColor()); Arrays.fill(b, ImageMapRenderer.filmColor());
return b; return b;
} }
/** /**
* The map's decorations an item frame shows (MapRenderer with showOnlyFrame: MapDecorationType.showOnItemFrame * The decorations an item frame shows (MapDecorationType.showOnItemFrame: banners, frame/structure/target
* — banners, frame markers, structure/target markers; never players), in the map data's order. Only for maps * markers; never players), in the map data's order. Only for maps drawn by the vanilla CraftMapRenderer,
* drawn by the vanilla renderer (CraftMapRenderer sends {@code MapItemSavedData.decorations} as cursors): * which sends {@code MapItemSavedData.decorations} as cursors; plugin-rendered maps (photos) get none.
* PixelPics photo maps and other plugin-rendered maps get none.
*/ */
public static List<DecorationState.MapMarker> markers(ItemStack stack) { static List<DecorationState.MapMarker> markers(MapView view) {
try { try {
if (!(stack.getItemMeta() instanceof MapMeta meta) || !meta.hasMapView()) return List.of();
MapView view = meta.getMapView();
if (view == null || MapManager.isManaged(view.getId())) return List.of(); if (view == null || MapManager.isManaged(view.getId())) return List.of();
boolean vanilla = false; boolean vanilla = false;
for (MapRenderer r : view.getRenderers()) { for (MapRenderer r : view.getRenderers()) {
@@ -81,25 +90,17 @@ public final class MapPixels {
} }
if (!vanilla) return List.of(); if (!vanilla) return List.of();
Object data = worldMap(view); Object data = worldMap(view);
if (data == null) return List.of(); Field f = data == null ? null : DECORATIONS.get(data.getClass());
Field f = decorationsField; if (f == null || !(f.get(data) instanceof Map<?, ?> decorations) || decorations.isEmpty()) return List.of();
if (f == null || f.getDeclaringClass() != data.getClass()) {
f = data.getClass().getDeclaredField("decorations");
f.setAccessible(true);
decorationsField = f;
}
if (!(f.get(data) instanceof Map<?, ?> decorations) || decorations.isEmpty()) return List.of();
List<DecorationState.MapMarker> out = new ArrayList<>(); List<DecorationState.MapMarker> out = new ArrayList<>();
for (Object d : decorations.values()) { for (Object d : decorations.values()) {
Class<?> c = d.getClass(); Method[] m = DECORATION_METHODS.get(d.getClass());
if (!(Boolean) c.getMethod("renderOnFrame").invoke(d)) continue; if (m == null) return List.of();
String sprite = String.valueOf(c.getMethod("getSpriteLocation").invoke(d)); if (!(Boolean) m[0].invoke(d)) continue;
String asset = sprite.substring(sprite.indexOf(':') + 1); String sprite = String.valueOf(m[1].invoke(d));
out.add(new DecorationState.MapMarker( out.add(new DecorationState.MapMarker(
asset, sprite.substring(sprite.indexOf(':') + 1), (Byte) m[2].invoke(d), (Byte) m[3].invoke(d), (Byte)
(Byte) c.getMethod("x").invoke(d), m[4].invoke(d)));
(Byte) c.getMethod("y").invoke(d),
(Byte) c.getMethod("rot").invoke(d)));
} }
return out; return out;
} catch (Throwable t) { } catch (Throwable t) {
@@ -107,27 +108,14 @@ public final class MapPixels {
} }
} }
private static volatile Field worldMapField, colorsField, decorationsField;
private static Object worldMap(MapView view) throws ReflectiveOperationException { private static Object worldMap(MapView view) throws ReflectiveOperationException {
Field wm = worldMapField; Field f = WORLD_MAP.get(view.getClass());
if (wm == null || wm.getDeclaringClass() != view.getClass()) { return f == null ? null : f.get(view);
wm = view.getClass().getDeclaredField("worldMap");
wm.setAccessible(true);
worldMapField = wm;
}
return wm.get(view);
} }
/** CraftMapView#worldMap → MapItemSavedData#colors (what the vanilla CraftMapRenderer sends). */ private static final ClassValue<Field> WORLD_MAP = Reflection.field("worldMap"); // CraftMapView
private static byte[] worldMapColors(MapView view) throws ReflectiveOperationException { private static final ClassValue<Field> DECORATIONS = Reflection.field("decorations"); // MapItemSavedData
Object data = worldMap(view); private static final ClassValue<Field> COLORS = Reflection.field("colors"); // MapItemSavedData
if (data == null) return null; private static final ClassValue<Method[]> DECORATION_METHODS =
Field c = colorsField; Reflection.methods("renderOnFrame", "getSpriteLocation", "x", "y", "rot"); // MapDecoration
if (c == null || c.getDeclaringClass() != data.getClass()) {
c = data.getClass().getField("colors");
colorsField = c;
}
return (byte[]) c.get(data);
}
} }
@@ -1,13 +1,14 @@
package eu.mhsl.minecraft.pixelpics.render.snapshot; package eu.mhsl.minecraft.pixelpics.render.snapshot;
import eu.mhsl.minecraft.pixelpics.render.entity.Affine; import eu.mhsl.minecraft.pixelpics.render.entity.Affine;
import eu.mhsl.minecraft.pixelpics.render.entity.BlockEntityState;
import eu.mhsl.minecraft.pixelpics.render.entity.ItemSpec; import eu.mhsl.minecraft.pixelpics.render.entity.ItemSpec;
import eu.mhsl.minecraft.pixelpics.render.entity.ObjectState; import eu.mhsl.minecraft.pixelpics.render.entity.ObjectState;
import java.lang.reflect.Method;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.Locale;
import net.kyori.adventure.text.Component; import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.TextColor; import net.kyori.adventure.text.format.TextColor;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
@@ -32,15 +33,14 @@ import org.bukkit.util.Transformation;
import org.joml.Quaternionf; import org.joml.Quaternionf;
/** /**
* Captures block/item/text content objects into {@link ObjectState}s on the main thread: display * Captures display entities (transformation, billboard resolved against the camera), dropped items, falling
* entities (with their transformation and billboard mode, resolved against the camera), dropped items, * blocks, primed TNT, minecart blocks and leash ropes into {@link ObjectState}s. MUST run on the main thread.
* falling blocks, primed TNT and the block shown inside minecarts.
*/ */
public final class ObjectSnapshotBuilder { public final class ObjectSnapshotBuilder {
private ObjectSnapshotBuilder() {} private ObjectSnapshotBuilder() {}
/** {@code nearby}: {@link FrustumBounds#nearbyEntities} of this photo; {@code eye} orients billboards. */ /** {@code eye} orients billboards. */
public static List<ObjectState> build(Collection<Entity> nearby, Location eye) { public static List<ObjectState> build(Collection<Entity> nearby, Location eye) {
List<ObjectState> out = new ArrayList<>(); List<ObjectState> out = new ArrayList<>();
for (Entity e : nearby) { for (Entity e : nearby) {
@@ -48,13 +48,11 @@ public final class ObjectSnapshotBuilder {
ObjectState s = toState(e, eye); ObjectState s = toState(e, eye);
if (s != null) out.add(s); if (s != null) out.add(s);
} catch (Throwable ignored) { } catch (Throwable ignored) {
// API differences on other server versions: skip this object
} }
try { try {
ObjectState leash = leash(e); ObjectState leash = leash(e);
if (leash != null) out.add(leash); if (leash != null) out.add(leash);
} catch (Throwable ignored) { } catch (Throwable ignored) {
// not leashable on this version
} }
} }
return out; return out;
@@ -64,12 +62,12 @@ public final class ObjectSnapshotBuilder {
Location l = e.getLocation(); Location l = e.getLocation();
Affine at = Affine.translation(l.getX(), l.getY(), l.getZ()); Affine at = Affine.translation(l.getX(), l.getY(), l.getZ());
if (e instanceof Display d) { if (e instanceof Display d) {
Affine base = at.mul(billboard(d, eye)).mul(transformation(d.getTransformation())); Affine base = at.mul(billboard(d, l, eye)).mul(transformation(d.getTransformation()));
if (d instanceof BlockDisplay bd) { if (d instanceof BlockDisplay bd) {
return block(base, bd.getBlock()); return block(base, bd.getBlock());
} }
if (d instanceof ItemDisplay id) { if (d instanceof ItemDisplay id) {
String ctx = id.getItemDisplayTransform().name().toLowerCase(java.util.Locale.ROOT); String ctx = id.getItemDisplayTransform().name().toLowerCase(Locale.ROOT);
// ItemDisplayRenderer.submitInner turns the item by 180° about Y before submitting it. // ItemDisplayRenderer.submitInner turns the item by 180° about Y before submitting it.
return item(base.mul(Affine.rotY(Math.PI)), id.getItemStack(), ctx, false); return item(base.mul(Affine.rotY(Math.PI)), id.getItemStack(), ctx, false);
} }
@@ -78,22 +76,17 @@ public final class ObjectSnapshotBuilder {
} }
return null; return null;
} }
if (e instanceof FallingBlock fb) { if (e instanceof FallingBlock || e instanceof TNTPrimed) {
return block(at.mul(Affine.translation(-0.5, 0, -0.5)), fb.getBlockData()); BlockData data = e instanceof FallingBlock fb ? fb.getBlockData() : Material.TNT.createBlockData();
} return block(at.mul(Affine.translation(-0.5, 0, -0.5)), data);
if (e instanceof TNTPrimed) {
return block(at.mul(Affine.translation(-0.5, 0, -0.5)), Material.TNT.createBlockData());
} }
if (e instanceof Item it) { if (e instanceof Item it) {
// Dropped item: vanilla GROUND display transform, lifted by the baker. The spin (vanilla: age/20 + // GROUND transform, lifted by the baker. The spin (vanilla: age/20 + random bobOffset) is frozen from
// a random bobOffset) is frozen per entity from its yaw, which ItemEntity also randomises at creation // the yaw, which ItemEntity also randomises at creation (tests fix it with setRotation).
// (so items differ like in vanilla, and a test can fix it with setRotation).
double spin = Math.toRadians(l.getYaw()); double spin = Math.toRadians(l.getYaw());
return item(at.mul(Affine.rotY(spin)), it.getItemStack(), "ground", true); return item(at.mul(Affine.rotY(spin)), it.getItemStack(), "ground", true);
} }
if (e instanceof Minecart mc) { if (e instanceof Minecart mc) {
BlockData data = mc.getDisplayBlockData();
if (data == null || data.getMaterial().isAir()) return null;
// Vanilla AbstractMinecartRenderer: cart pivot 0.375 up, yaw/pitch, then the block at 3/4 size. // Vanilla AbstractMinecartRenderer: cart pivot 0.375 up, yaw/pitch, then the block at 3/4 size.
Affine base = at.mul(Affine.translation(0, 0.375, 0)) Affine base = at.mul(Affine.translation(0, 0.375, 0))
.mul(Affine.rotY(Math.PI - Math.toRadians(l.getYaw()))) .mul(Affine.rotY(Math.PI - Math.toRadians(l.getYaw())))
@@ -101,15 +94,14 @@ public final class ObjectSnapshotBuilder {
.mul(Affine.scale(0.75)) .mul(Affine.scale(0.75))
.mul(Affine.translation(-0.5, (mc.getDisplayBlockOffset() - 8) / 16.0, 0.5)) .mul(Affine.translation(-0.5, (mc.getDisplayBlockOffset() - 8) / 16.0, 0.5))
.mul(Affine.rotY(Math.PI / 2)); .mul(Affine.rotY(Math.PI / 2));
return block(base, data); return block(base, mc.getDisplayBlockData());
} }
return null; return null;
} }
private static ObjectState block(Affine toWorld, BlockData data) { private static ObjectState block(Affine toWorld, BlockData data) {
if (data == null || data.getMaterial().isAir()) return null; if (data == null || data.getMaterial().isAir()) return null;
BlockEntityState be = BlockEntitySnapshotBuilder.fromBlockData(data); // chest/banner/head/… content return ObjectState.block(toWorld, data, BlockEntitySnapshotBuilder.fromBlockData(data));
return ObjectState.block(toWorld, data, be);
} }
private static ObjectState item(Affine toWorld, ItemStack stack, String context, boolean dropped) { private static ObjectState item(Affine toWorld, ItemStack stack, String context, boolean dropped) {
@@ -119,22 +111,12 @@ public final class ObjectSnapshotBuilder {
} }
/** Per class: {isLeashed, getLeashHolder} for non-living leashables (boats, …), or null. */ /** Per class: {isLeashed, getLeashHolder} for non-living leashables (boats, …), or null. */
private static final ClassValue<java.lang.reflect.Method[]> LEASH_METHODS = new ClassValue<>() { private static final ClassValue<Method[]> LEASH_METHODS = Reflection.methods("isLeashed", "getLeashHolder");
@Override
protected java.lang.reflect.Method[] computeValue(Class<?> c) {
try {
return new java.lang.reflect.Method[] {c.getMethod("isLeashed"), c.getMethod("getLeashHolder")};
} catch (NoSuchMethodException e) {
return null;
}
}
};
/** /**
* A leash rope from a leashed entity to its holder (vanilla LeashRenderer endpoints, approximated): the * A leash rope from a leashed entity to its holder (vanilla LeashRenderer endpoints, approximated): the
* entity end at eye height, 0.4·width in front; the holder end at a knot's centre, a humanoid holder's * entity end at eye height, 0.4·width in front; the holder end at a knot's centre, a humanoid holder's
* right hand, or 0.7·eye height for other holders. Works for any entity exposing * right hand, or 0.7·eye height for other holders.
* {@code isLeashed()/getLeashHolder()} (mobs, and boats on versions where they are leashable).
*/ */
private static ObjectState leash(Entity e) throws ReflectiveOperationException { private static ObjectState leash(Entity e) throws ReflectiveOperationException {
Entity holder; Entity holder;
@@ -142,34 +124,42 @@ public final class ObjectSnapshotBuilder {
if (!le.isLeashed()) return null; if (!le.isLeashed()) return null;
holder = le.getLeashHolder(); holder = le.getLeashHolder();
} else { } else {
java.lang.reflect.Method[] m = LEASH_METHODS.get(e.getClass()); Method[] m = LEASH_METHODS.get(e.getClass());
if (m == null || !(Boolean) m[0].invoke(e)) return null; if (m == null || !(Boolean) m[0].invoke(e)) return null;
holder = (Entity) m[1].invoke(e); holder = (Entity) m[1].invoke(e);
} }
if (holder == null) return null; if (holder == null) return null;
Location l = e.getLocation(); Location l = e.getLocation();
double yaw = Math.toRadians(e instanceof LivingEntity liv ? liv.getBodyYaw() : l.getYaw()); double yaw = Math.toRadians(bodyYaw(e, l));
double eye = e instanceof LivingEntity liv ? liv.getEyeHeight() : e.getHeight() * 0.5;
double fwd = e.getWidth() * 0.4; double fwd = e.getWidth() * 0.4;
double[] from = {l.getX() - Math.sin(yaw) * fwd, l.getY() + eye, l.getZ() + Math.cos(yaw) * fwd}; double[] p = new double[6];
p[0] = l.getX() - Math.sin(yaw) * fwd;
p[1] = l.getY() + eyeHeight(e);
p[2] = l.getZ() + Math.cos(yaw) * fwd;
Location h = holder.getLocation(); Location h = holder.getLocation();
double[] to; p[3] = h.getX();
p[5] = h.getZ();
if (holder instanceof LeashHitch) { if (holder instanceof LeashHitch) {
to = new double[] {h.getX(), h.getY() + 0.2, h.getZ()}; p[4] = h.getY() + 0.2;
} else if (holder instanceof HumanEntity } else if (holder instanceof HumanEntity
|| holder.getType().getKey().getKey().equals("mannequin")) { || holder.getType().getKey().getKey().equals("mannequin")) {
double hy = Math.toRadians(holder instanceof LivingEntity hl ? hl.getBodyYaw() : h.getYaw()); double hy = Math.toRadians(bodyYaw(holder, h));
double side = 0.35, f = 0.1; double side = 0.35, f = 0.1;
to = new double[] { p[3] = h.getX() - Math.cos(hy) * side - Math.sin(hy) * f;
h.getX() - Math.cos(hy) * side - Math.sin(hy) * f, p[4] = h.getY() + holder.getHeight() * 0.42;
h.getY() + holder.getHeight() * 0.42, p[5] = h.getZ() - Math.sin(hy) * side + Math.cos(hy) * f;
h.getZ() - Math.sin(hy) * side + Math.cos(hy) * f
};
} else { } else {
double he = holder instanceof LivingEntity hl ? hl.getEyeHeight() : holder.getHeight() * 0.5; p[4] = h.getY() + eyeHeight(holder) * 0.7;
to = new double[] {h.getX(), h.getY() + he * 0.7, h.getZ()};
} }
return ObjectState.leash(new double[] {from[0], from[1], from[2], to[0], to[1], to[2]}); return ObjectState.leash(p);
}
private static double bodyYaw(Entity e, Location l) {
return e instanceof LivingEntity le ? le.getBodyYaw() : l.getYaw();
}
private static double eyeHeight(Entity e) {
return e instanceof LivingEntity le ? le.getEyeHeight() : e.getHeight() * 0.5;
} }
private static ObjectState text(Affine base, TextDisplay td) { private static ObjectState text(Affine base, TextDisplay td) {
@@ -178,24 +168,26 @@ public final class ObjectSnapshotBuilder {
if (plain.isBlank()) return null; if (plain.isBlank()) return null;
TextColor color = c.color(); TextColor color = c.color();
int opacity = td.getTextOpacity() & 0xFF; int opacity = td.getTextOpacity() & 0xFF;
if (opacity == 0) opacity = 255; // -1 (default) = fully opaque if (opacity < 26) opacity = 255; // vanilla: -1 (default) and very low values render opaque
if (opacity < 26) opacity = 255; // vanilla treats very low values as opaque too
int rgb = color == null ? 0xFFFFFF : color.value(); int rgb = color == null ? 0xFFFFFF : color.value();
int textArgb = (opacity << 24) | rgb; int textArgb = (opacity << 24) | rgb;
Color bg = td.getBackgroundColor(); Color bg = td.getBackgroundColor();
int bgArgb = td.isDefaultBackground() || bg == null ? 0x40000000 : bg.asARGB(); int bgArgb = td.isDefaultBackground() || bg == null ? 0x40000000 : bg.asARGB();
// Vanilla text displays render font pixels at 1/40 block. // Vanilla text displays render font pixels at 1/40 block.
Affine toWorld = base.mul(Affine.scale(0.025));
return ObjectState.text( return ObjectState.text(
toWorld, Arrays.asList(plain.split("\n", -1)), td.getLineWidth(), textArgb, bgArgb, td.isShadowed()); base.mul(Affine.scale(0.025)),
Arrays.asList(plain.split("\n", -1)),
td.getLineWidth(),
textArgb,
bgArgb,
td.isShadowed());
} }
/** /**
* Display orientation (vanilla DisplayRenderer): FIXED uses the entity yaw/pitch; CENTER faces the * Display orientation (vanilla DisplayRenderer): FIXED uses the entity yaw/pitch; CENTER faces the
* camera; VERTICAL turns only around Y towards it; HORIZONTAL keeps the entity yaw, tilts with the camera. * camera; VERTICAL turns only around Y towards it; HORIZONTAL keeps the entity yaw, tilts with the camera.
*/ */
private static Affine billboard(Display d, Location eye) { private static Affine billboard(Display d, Location l, Location eye) {
Location l = d.getLocation();
double yaw = Math.toRadians(l.getYaw()), pitch = Math.toRadians(l.getPitch()); double yaw = Math.toRadians(l.getYaw()), pitch = Math.toRadians(l.getPitch());
double camYaw = Math.toRadians(eye.getYaw()), camPitch = Math.toRadians(eye.getPitch()); double camYaw = Math.toRadians(eye.getYaw()), camPitch = Math.toRadians(eye.getPitch());
return switch (d.getBillboard()) { return switch (d.getBillboard()) {
@@ -0,0 +1,65 @@
package eu.mhsl.minecraft.pixelpics.render.snapshot;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
/**
* Per-class cached reflection handles for server internals Bukkit has no API for (null when absent, so callers
* fall back). Looked up once per runtime class, not per entity/photo.
*/
final class Reflection {
private Reflection() {}
/** The first of the public no-arg methods {@code names} the class has, or null. */
static ClassValue<Method> method(String... names) {
return new ClassValue<>() {
@Override
protected Method computeValue(Class<?> c) {
for (String n : names) {
try {
return c.getMethod(n);
} catch (NoSuchMethodException ignored) {
}
}
return null;
}
};
}
/** All the public no-arg methods {@code names}, or null unless the class has every one. */
static ClassValue<Method[]> methods(String... names) {
return new ClassValue<>() {
@Override
protected Method[] computeValue(Class<?> c) {
Method[] m = new Method[names.length];
try {
for (int i = 0; i < names.length; i++) m[i] = c.getMethod(names[i]);
} catch (NoSuchMethodException e) {
return null;
}
return m;
}
};
}
/** A public (possibly inherited) field, else a field declared by the class itself (made accessible), or null. */
static ClassValue<Field> field(String name) {
return new ClassValue<>() {
@Override
protected Field computeValue(Class<?> c) {
try {
return c.getField(name);
} catch (NoSuchFieldException ignored) {
}
try {
Field f = c.getDeclaredField(name);
f.setAccessible(true);
return f;
} catch (ReflectiveOperationException | RuntimeException e) {
return null;
}
}
};
}
}
@@ -1,36 +1,26 @@
package eu.mhsl.minecraft.pixelpics.render.snapshot; package eu.mhsl.minecraft.pixelpics.render.snapshot;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.logging.Logger; import java.util.logging.Logger;
import org.bukkit.ChunkSnapshot; import org.bukkit.ChunkSnapshot;
import org.bukkit.Location;
import org.bukkit.Material; import org.bukkit.Material;
import org.bukkit.World; import org.bukkit.World;
import org.bukkit.util.Vector;
/** /**
* Captures the world region covered by the camera frustum into a {@link WorldSnapshot}. * Captures the world region covered by the camera frustum into a {@link WorldSnapshot}. MUST run on the main
* * thread. Only already-loaded chunks are captured (no forced loading), rays into unloaded areas hit sky.
* <p>MUST be called on the main server thread: it reads live chunks. Only already-loaded chunks are
* captured (no forced generation), so the call is cheap and rays into unloaded areas hit sky.
*/ */
public final class SnapshotBuilder { public final class SnapshotBuilder {
/** Safety cap on captured chunks to avoid pathological memory/latency. */ /** Safety cap on captured chunks (memory/latency). */
private static final int MAX_CHUNKS = 4096; private static final int MAX_CHUNKS = 4096;
private SnapshotBuilder() {} private SnapshotBuilder() {}
public static WorldSnapshot build(Location eye, List<Vector> rayMap, double maxDistance, Logger logger) { public static WorldSnapshot build(World world, FrustumBounds bounds, Logger logger) {
World world = eye.getWorld(); int minY = Math.max(world.getMinHeight(), (int) Math.floor(bounds.minY) - 1);
FrustumBounds bounds = FrustumBounds.of(eye.toVector(), rayMap, maxDistance); int maxY = Math.min(world.getMaxHeight(), (int) Math.ceil(bounds.maxY) + 1);
int worldMinY = world.getMinHeight();
int worldMaxY = world.getMaxHeight();
int clampedMinY = Math.max(worldMinY, (int) Math.floor(bounds.minY) - 1);
int clampedMaxY = Math.min(worldMaxY, (int) Math.ceil(bounds.maxY) + 1);
int minCX = (int) Math.floor(bounds.minX) >> 4; int minCX = (int) Math.floor(bounds.minX) >> 4;
int maxCX = (int) Math.floor(bounds.maxX) >> 4; int maxCX = (int) Math.floor(bounds.maxX) >> 4;
@@ -38,29 +28,21 @@ public final class SnapshotBuilder {
int maxCZ = (int) Math.floor(bounds.maxZ) >> 4; int maxCZ = (int) Math.floor(bounds.maxZ) >> 4;
Map<Long, ChunkSnapshot> chunks = new HashMap<>(); Map<Long, ChunkSnapshot> chunks = new HashMap<>();
int captured = 0;
int skipped = 0; int skipped = 0;
for (int cx = minCX; cx <= maxCX; cx++) { for (int cx = minCX; cx <= maxCX; cx++) {
for (int cz = minCZ; cz <= maxCZ; cz++) { for (int cz = minCZ; cz <= maxCZ; cz++) {
if (captured >= MAX_CHUNKS) { if (chunks.size() >= MAX_CHUNKS || !world.isChunkLoaded(cx, cz)) {
skipped++; skipped++;
continue; continue;
} }
if (!world.isChunkLoaded(cx, cz)) { chunks.put(
skipped++; WorldSnapshot.chunkKey(cx, cz), world.getChunkAt(cx, cz).getChunkSnapshot(false, true, false));
continue;
}
ChunkSnapshot cs = world.getChunkAt(cx, cz).getChunkSnapshot(false, true, false);
chunks.put(WorldSnapshot.chunkKey(cx, cz), cs);
captured++;
} }
} }
if (skipped > 0) { if (skipped > 0) {
logger.fine( logger.fine(String.format(
String.format("Snapshot captured %d chunks, skipped %d (unloaded or over cap)", captured, skipped)); "Snapshot captured %d chunks, skipped %d (unloaded or over cap)", chunks.size(), skipped));
} }
return new WorldSnapshot(chunks, minY, maxY, Material.AIR.createBlockData());
return new WorldSnapshot(chunks, clampedMinY, clampedMaxY, Material.AIR.createBlockData());
} }
} }
@@ -6,14 +6,11 @@ import org.bukkit.block.Biome;
import org.bukkit.block.data.BlockData; import org.bukkit.block.data.BlockData;
/** /**
* An immutable, thread-safe view of a bounded region of the world, backed by {@link ChunkSnapshot}s. * Immutable, thread-safe view of the captured chunks; lookups outside them return air/null.
* Block/biome lookups outside the captured region return air/null so rays simply terminate there.
* *
* <p>The chunks live in a primitive open-addressing table (linear probing, Fibonacci hashing of the full * <p>Performance: the chunks live in a primitive open-addressing table (linear probing, Fibonacci hash of the
* {@link #chunkKey}), not a {@code HashMap<Long, …>}: {@code Long.hashCode} of the key is {@code cx ^ cz}, so * full {@link #chunkKey}), not a {@code HashMap<Long, …>}: {@code Long.hashCode} is {@code cx ^ cz}, so every
* every diagonal of the captured rectangle collided into treeified bins and each block lookup paid for * diagonal collided into treeified bins and each block lookup paid for boxing and {@code TreeNode.find}.
* boxing, {@code TreeNode.find} and its reflective {@code comparableClassFor}. Keys are compared in full,
* so lookups stay exact.
*/ */
public final class WorldSnapshot { public final class WorldSnapshot {
@@ -35,7 +32,6 @@ public final class WorldSnapshot {
this.mask = cap - 1; this.mask = cap - 1;
this.shift = 64 - Integer.numberOfTrailingZeros(cap); this.shift = 64 - Integer.numberOfTrailingZeros(cap);
for (Map.Entry<Long, ChunkSnapshot> e : chunks.entrySet()) { for (Map.Entry<Long, ChunkSnapshot> e : chunks.entrySet()) {
if (e.getValue() == null) continue;
long key = e.getKey(); long key = e.getKey();
int i = slot(key); int i = slot(key);
while (values[i] != null) i = (i + 1) & mask; while (values[i] != null) i = (i + 1) & mask;