From 2a3db3d7aaa0244c825cfb22586143edf12f53b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Wed, 4 Nov 2026 23:41:05 +0100 Subject: [PATCH] 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 --- .../render/render/DefaultScreenRenderer.java | 7 +- .../snapshot/BlockEntitySnapshotBuilder.java | 343 +++++++----------- .../snapshot/DecorationSnapshotBuilder.java | 87 +++-- .../snapshot/EntitySnapshotBuilder.java | 293 +++++++-------- .../render/snapshot/FrustumBounds.java | 35 +- .../pixelpics/render/snapshot/ItemSpecs.java | 9 +- .../pixelpics/render/snapshot/MapPixels.java | 96 +++-- .../snapshot/ObjectSnapshotBuilder.java | 102 +++--- .../pixelpics/render/snapshot/Reflection.java | 65 ++++ .../render/snapshot/SnapshotBuilder.java | 42 +-- .../render/snapshot/WorldSnapshot.java | 12 +- 11 files changed, 504 insertions(+), 587 deletions(-) create mode 100644 src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/Reflection.java diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/DefaultScreenRenderer.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/DefaultScreenRenderer.java index 9e6cd28..4619a39 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/DefaultScreenRenderer.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/render/DefaultScreenRenderer.java @@ -116,10 +116,11 @@ public class DefaultScreenRenderer { != FluidRenderer.NONE; List rayMap = buildRayMap(eyeLocation, superW, superH, inFluid ? V_FOV_DEG * FLUID_FOV_SCALE : V_FOV_DEG); - WorldSnapshot snapshot = SnapshotBuilder.build(eyeLocation, rayMap, MAX_DISTANCE, logger); - Collection nearby = FrustumBounds.nearbyEntities(eyeLocation, rayMap, MAX_DISTANCE); + FrustumBounds bounds = FrustumBounds.of(eyeLocation, rayMap, MAX_DISTANCE); + WorldSnapshot snapshot = SnapshotBuilder.build(world, bounds, logger); + Collection nearby = bounds.nearbyEntities(world); List entities = EntitySnapshotBuilder.build(nearby, shooter); - List blockEntities = BlockEntitySnapshotBuilder.build(eyeLocation, rayMap, MAX_DISTANCE); + List blockEntities = BlockEntitySnapshotBuilder.build(eyeLocation, bounds); List decorations = DecorationSnapshotBuilder.build(nearby); List objects = ObjectSnapshotBuilder.build(nearby, eyeLocation); diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/BlockEntitySnapshotBuilder.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/BlockEntitySnapshotBuilder.java index 083ada6..6e20bf9 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/BlockEntitySnapshotBuilder.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/BlockEntitySnapshotBuilder.java @@ -12,6 +12,7 @@ import java.util.EnumMap; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.regex.Pattern; import org.bukkit.Bukkit; import org.bukkit.DyeColor; import org.bukkit.HeightMap; @@ -20,6 +21,7 @@ import org.bukkit.Material; import org.bukkit.World; import org.bukkit.block.Banner; import org.bukkit.block.Beacon; +import org.bukkit.block.Block; import org.bukkit.block.BlockFace; import org.bukkit.block.BlockState; import org.bukkit.block.Campfire; @@ -31,11 +33,11 @@ import org.bukkit.block.Sign; import org.bukkit.block.Skull; import org.bukkit.block.TrialSpawner; import org.bukkit.block.Vault; -import org.bukkit.block.banner.Pattern; import org.bukkit.block.data.BlockData; import org.bukkit.block.data.Directional; import org.bukkit.block.data.Rotatable; 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.sign.Side; 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.SkullMeta; 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 - * {@link BlockEntityState}s. MUST run on the main thread (live {@link BlockState} access). Reads each - * loaded chunk's tile-entities rather than scanning every block, then keeps only those inside the - * camera's bounding box. + * 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 loaded + * chunk's tile entities instead of scanning every block. */ public final class BlockEntitySnapshotBuilder { private BlockEntitySnapshotBuilder() {} - public static List build(Location eye, List rayMap, double maxDistance) { + /** {@code eye}: camera position for the camera-dependent parts (enchanting-table book, beacon beam width). */ + public static List build(Location eye, FrustumBounds bounds) { 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. 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; @@ -76,14 +77,16 @@ public final class BlockEntitySnapshotBuilder { for (BlockState ts : world.getChunkAt(cx, cz).getTileEntities(false)) { int bx = ts.getX(), by = ts.getY(), bz = ts.getZ(); // A beacon's beam rises from below: keep beacons under the view box too. - boolean below = by < bMinY && ts instanceof Beacon; - if (bx < bMinX || bx > bMaxX || (by < bMinY && !below) || by > bMaxY || bz < bMinZ || bz > bMaxZ) - continue; + if (bx < bMinX + || bx > bMaxX + || (by < bMinY && !(ts instanceof Beacon)) + || by > bMaxY + || bz < bMinZ + || bz > bMaxZ) continue; try { BlockEntityState s = classify(ts.getBlockData(), bx, by, bz, ts, eye); if (s != null) out.add(s); } 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 - * blocks shown by BLOCK DISPLAYS, minecarts and special ITEMS (chest/head/banner/shulker/… items). - * 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. + * A state from bare block data (no tile) at cell 0 for block displays, minecarts and special items; without + * tile-only details (patterns, sign text, skins). Null for blocks that aren't block-entity rendered. */ - public static BlockEntityState fromBlockData(BlockData data) { + static BlockEntityState fromBlockData(BlockData data) { try { return classify(data, 0, 0, 0, null, null); } 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 - * of a player head ({@code SkullMeta} profile) and banner patterns ({@code BannerMeta}). Other kinds are - * returned unchanged. MUST run on the main thread. + * Adds the stack details a placed tile would carry to a {@link #fromBlockData} state: a player head's skin + * ({@code SkullMeta}) and banner patterns ({@code BannerMeta}). */ - public static BlockEntityState withItemMeta(BlockEntityState s, ItemMeta meta) { + static BlockEntityState withItemMeta(BlockEntityState s, ItemMeta meta) { if (s == null || meta == null) return s; try { if (meta instanceof SkullMeta skull && "player".equals(s.headType())) { - PlayerProfile profile = skull.getOwnerProfile(); - if (profile == null || profile.getTextures().getSkin() == null) return s; - return copy(s, profile.getTextures().getSkin().toString(), s.patterns()); + String skin = skinUrl(skull.getOwnerProfile()); + return skin == null ? s : copy(s, skin, s.patterns()); } if (meta instanceof BannerMeta banner && !banner.getPatterns().isEmpty()) { - List pats = new ArrayList<>(); - for (Pattern p : banner.getPatterns()) { - pats.add(new BannerPattern(patternKey(p), ColorUtil.dyeArgb(p.getColor(), 0xFFFFFFFF))); - } - return copy(s, s.skinUrl(), pats); + return copy(s, s.skinUrl(), patterns(banner.getPatterns())); } } catch (Throwable ignored) { - // API drift: keep the plain block state } return s; } - /** - * A shield's banner data (ShieldMeta: base colour + patterns) as a {@code BANNER} state at cell 0 for the - * shield special model; null without patterns and base colour (plain shield). - */ - public static BlockEntityState shieldPatterns(ItemMeta meta) { + /** A shield's base colour + patterns as a BANNER state for the shield special model; null for a plain shield. */ + static BlockEntityState shieldPatterns(ItemMeta meta) { try { if (!(meta instanceof ShieldMeta shield)) return null; DyeColor baseColor = shield.getBaseColor(); - List pats = new ArrayList<>(); - for (Pattern p : shield.getPatterns()) { - pats.add(new BannerPattern(patternKey(p), ColorUtil.dyeArgb(p.getColor(), 0xFFFFFFFF))); - } + List pats = patterns(shield.getPatterns()); if (baseColor == null && pats.isEmpty()) return null; - return new BlockEntityState( - Kind.BANNER, - 0, - 0, - 0, - 0, - null, - false, - null, - ColorUtil.dyeArgb(baseColor, 0), - null, - null, - null, - pats, - List.of(), - null, - null); + return base(Kind.BANNER, 0, 0, 0, 0) + .baseColorArgb(ColorUtil.dyeArgb(baseColor, 0)) + .patterns(pats) + .build(); } catch (Throwable ignored) { - return null; // API drift: plain shield + return null; } } @@ -190,42 +165,30 @@ public final class BlockEntitySnapshotBuilder { s.pose()); } - /** - * {@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). - */ + /** {@code ts} = the live tile (null for bare block data), {@code eye} = camera position or null. */ private static BlockEntityState classify(BlockData data, int bx, int by, int bz, BlockState ts, Location eye) { Material mat = data.getMaterial(); String n = mat.name(); - // --- chests --- - boolean copper = n.endsWith("COPPER_CHEST"); - if (mat == Material.CHEST || mat == Material.TRAPPED_CHEST || mat == Material.ENDER_CHEST || copper) { - Kind kind = copper - ? Kind.COPPER_CHEST - : mat == Material.TRAPPED_CHEST - ? Kind.TRAPPED_CHEST - : mat == Material.ENDER_CHEST ? Kind.ENDER_CHEST : Kind.CHEST; - ChestKind ck = ChestKind.SINGLE; - if (data instanceof Chest cd) { - ck = switch (cd.getType()) { - case LEFT -> ChestKind.LEFT; - case RIGHT -> ChestKind.RIGHT; - 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)); - } + Kind chest = n.endsWith("COPPER_CHEST") + ? Kind.COPPER_CHEST + : switch (mat) { + case CHEST -> Kind.CHEST; + case TRAPPED_CHEST -> Kind.TRAPPED_CHEST; + case ENDER_CHEST -> Kind.ENDER_CHEST; + default -> null; + }; + if (chest != null) { + ChestKind ck = + data instanceof Chest cd ? ChestKind.valueOf(cd.getType().name()) : ChestKind.SINGLE; + Builder b = base(chest, bx, by, bz, facingYaw(data)).chestKind(ck); + if (chest == Kind.COPPER_CHEST) b.colorName(copperStage(n, "COPPER_CHEST")); return b.build(); } - // --- shulker boxes --- 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"; return base(Kind.SHULKER_BOX, bx, by, bz, 0) .direction(dir) @@ -233,47 +196,24 @@ public final class BlockEntitySnapshotBuilder { .build(); } - // --- banners --- if (n.endsWith("_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, 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 - } - } + Builder b = base(wall ? Kind.WALL_BANNER : Kind.BANNER, bx, by, bz, facingYaw(data)); if (ts instanceof Banner banner) { - DyeColor base = banner.getBaseColor(); - b.baseColorArgb(ColorUtil.dyeArgb(base, 0xFFFFFFFF)); - List pats = new ArrayList<>(); - for (Pattern p : banner.getPatterns()) { - pats.add(new BannerPattern(patternKey(p), ColorUtil.dyeArgb(p.getColor(), 0xFFFFFFFF))); - } - b.patterns(pats); + b.baseColorArgb(ColorUtil.dyeArgb(banner.getBaseColor(), 0xFFFFFFFF)); + b.patterns(patterns(banner.getPatterns())); + } else { // no tile: the base colour is in the block name + DyeColor dye = dye(prefix(n, wall ? "_WALL_BANNER" : "_BANNER")); + if (dye != null) b.baseColorArgb(ColorUtil.dyeArgb(dye, 0xFFFFFFFF)); } return b.build(); } - // --- signs --- if (n.endsWith("_SIGN")) { - Kind kind; - float yaw; - if (n.endsWith("_WALL_SIGN")) { - kind = Kind.WALL_SIGN; - yaw = facingYaw(data); - } else if (n.endsWith("_HANGING_SIGN")) { - kind = Kind.HANGING_SIGN; - yaw = n.endsWith("_WALL_HANGING_SIGN") ? facingYaw(data) : rotationYaw(data); - } else { - kind = Kind.SIGN; - yaw = rotationYaw(data); - } - Builder b = base(kind, bx, by, bz, yaw); + Kind kind = n.endsWith("_WALL_SIGN") + ? Kind.WALL_SIGN + : n.endsWith("_HANGING_SIGN") ? Kind.HANGING_SIGN : Kind.SIGN; + Builder b = base(kind, bx, by, bz, facingYaw(data)); if (ts instanceof Sign sign) { b.frontText(signText(sign.getSide(Side.FRONT))); b.backText(signText(sign.getSide(Side.BACK))); @@ -281,50 +221,47 @@ public final class BlockEntitySnapshotBuilder { return b.build(); } - // --- heads / skulls --- if (n.endsWith("_SKULL") || n.endsWith("_HEAD")) { boolean wall = n.contains("_WALL_"); String headType = headType(n); - float yaw = wall ? facingYaw(data) : rotationYaw(data); - Builder b = base(wall ? Kind.WALL_HEAD : Kind.HEAD, bx, by, bz, yaw).headType(headType); + Builder b = base(wall ? Kind.WALL_HEAD : Kind.HEAD, bx, by, bz, facingYaw(data)) + .headType(headType); if ("player".equals(headType) && ts instanceof Skull skull) { - b.skinUrl(skinUrl(skull)); + try { + b.skinUrl(skinUrl(skull.getOwnerProfile())); + } catch (Throwable ignored) { + } } return b.build(); } - // --- conduit --- if (mat == Material.CONDUIT) { return base(Kind.CONDUIT, bx, by, bz, 0) .active(ts instanceof Conduit c && c.isActive()) .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) { return base(Kind.LECTERN, bx, by, bz, facingYaw(data)) .active(lectern.hasBook()) .build(); } - // --- decorated pot --- if (mat == Material.DECORATED_POT) { Builder b = base(Kind.DECORATED_POT, bx, by, bz, facingYaw(data)); if (ts instanceof DecoratedPot pot) b.sherds(sherds(pot)); return b.build(); } - // --- bell (frame = block model; the renderer draws the bell body) --- if (mat == Material.BELL) { return base(Kind.BELL, bx, by, bz, 0).build(); } - // --- enchanting table: the floating book (EnchantingTableBlockEntity.bookAnimationTick) --- if (mat == Material.ENCHANTING_TABLE) { return enchantingTable(bx, by, bz, eye); } - // --- campfire items (CampfireRenderer) --- if (data instanceof org.bukkit.block.data.type.Campfire) { Builder b = base(Kind.CAMPFIRE, bx, by, bz, facingYaw(data)); if (ts instanceof Campfire c) { @@ -335,7 +272,7 @@ public final class BlockEntitySnapshotBuilder { 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) { Builder b = base(Kind.SHELF, bx, by, bz, facingYaw(data)); if (ts instanceof Shelf shelf) { @@ -347,24 +284,20 @@ public final class BlockEntitySnapshotBuilder { return b.build(); } - // --- copper golem statue: [WAXED_][EXPOSED_|WEATHERED_|OXIDIZED_]COPPER_GOLEM_STATUE --- - if (data instanceof org.bukkit.block.data.type.CopperGolemStatue statue) { - String stage = - n.replace("WAXED_", "").replace("COPPER_GOLEM_STATUE", "").replace("_", ""); + if (data instanceof CopperGolemStatue statue) { 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)) .build(); } - // --- spawners: the spinning mini mob (SpawnerRenderer / TrialSpawnerRenderer) --- if (mat == Material.SPAWNER || n.equals("TRIAL_SPAWNER")) { return base(Kind.SPAWNER, bx, by, bz, 0) - .displayEntity(ts == null ? null : spawnerEntity(ts, data)) + .displayEntity(spawnerEntity(ts, data)) .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")) { Builder b = base(Kind.VAULT, bx, by, bz, facingYaw(data)); if (ts instanceof Vault vault && !vault.getConnectedPlayers().isEmpty()) { @@ -374,14 +307,13 @@ public final class BlockEntitySnapshotBuilder { 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) { 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)); return b.build(); } - // --- end portal / gateway (TheEndPortalRenderer: top + bottom; gateway: unoccluded faces) --- if (mat == Material.END_PORTAL) { return base(Kind.END_PORTAL, bx, by, bz, 0).build(); } @@ -394,8 +326,6 @@ public final class BlockEntitySnapshotBuilder { return null; } - // --- contents helpers --- - /** * 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). @@ -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 - * snapshot (vanilla BaseSpawner/TrialSpawnerStateData.getOrCreateDisplayEntity loads the same NBT; - * {@code createEntity(World)} loads it without spawning or finalizeSpawn randomisation), else a bare - * 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. + * The spawner's display mob (vanilla BaseSpawner/TrialSpawnerStateData.getOrCreateDisplayEntity loads the + * same NBT): the spawn data's entity snapshot, else a bare entity of the spawned type. Trial spawners show + * it only while waiting/active, from the ominous configuration when ominous. Null when empty or no tile. */ private static EntityState spawnerEntity(BlockState ts, BlockData data) { try { - org.bukkit.spawner.BaseSpawner spawner; + BaseSpawner spawner; if (ts instanceof CreatureSpawner cs) { spawner = cs; } 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() + "\"}"); } if (snap == null) return null; - // 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), NOT createEntity(Location): the Location variant ADDS the entity to the world + // (createEntity(World) also skips finalizeSpawn randomisation). The baker moves the state to the cage. Entity e = snap.createEntity(ts.getWorld()); - return e == null ? null : EntitySnapshotBuilder.displayState(e); + return e == null ? null : EntitySnapshotBuilder.toState(e); } 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); List sections = new ArrayList<>(); // {argb, height} 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); if (color != 0) { 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); 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. */ @@ -492,12 +420,7 @@ public final class BlockEntitySnapshotBuilder { if (m == Material.BEACON) return ColorUtil.dyeArgb(DyeColor.WHITE, 0); String n = m.name(); int i = n.indexOf("_STAINED_GLASS"); - if (i < 0) return 0; - try { - return ColorUtil.dyeArgb(DyeColor.valueOf(n.substring(0, i)), 0); - } catch (IllegalArgumentException e) { - return 0; - } + return i < 0 ? 0 : ColorUtil.dyeArgb(dye(n.substring(0, i)), 0); } /** 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. */ private static String gatewayFaces(BlockState ts) { + Block block = ts.getBlock(); StringBuilder sb = new StringBuilder(); - for (BlockFace f : new BlockFace[] { - BlockFace.DOWN, BlockFace.UP, BlockFace.NORTH, BlockFace.SOUTH, BlockFace.WEST, BlockFace.EAST - }) { - if (ts.getBlock().getRelative(f).getType().isOccluding()) continue; + for (BlockFace f : GATEWAY_FACES) { + if (block.getRelative(f).getType().isOccluding()) continue; if (!sb.isEmpty()) sb.append('+'); sb.append(f.name().toLowerCase(Locale.ROOT)); } 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) { - if (data instanceof Directional d) return faceToYaw(d.getFacing()); - if (data instanceof Rotatable r) return faceToYaw(r.getRotation()); - return 0; - } - - /** 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); + BlockFace face = + data instanceof Directional d ? d.getFacing() : data instanceof Rotatable r ? r.getRotation() : null; + Float y = face == null ? null : FACE_YAW.get(face); return y == null ? 0 : y; } @@ -571,19 +487,42 @@ public final class BlockEntitySnapshotBuilder { return m; } - // --- data extraction helpers --- - - private static String stripColor(String name, String suffix) { - return name.substring(0, name.length() - suffix.length()).toLowerCase(Locale.ROOT); + private static String prefix(String name, String suffix) { + return name.substring(0, name.length() - suffix.length()); } - /** 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_]}; 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 patterns(List patterns) { + List 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) { String[] raw = side.getLines(); List lines = new ArrayList<>(raw.length); boolean any = false; 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; 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 sherds(DecoratedPot pot) { - // Order: front, left, right, back — the DecoratedPotRenderer side parts (BlockEntityModels.potParts). List out = new ArrayList<>(4); - for (DecoratedPot.Side side : new DecoratedPot.Side[] { - DecoratedPot.Side.FRONT, DecoratedPot.Side.LEFT, DecoratedPot.Side.RIGHT, DecoratedPot.Side.BACK - }) { - Material m = pot.getSherd(side); - out.add(m.name().toLowerCase(Locale.ROOT)); - } + for (DecoratedPot.Side side : SHERD_SIDES) + out.add(pot.getSherd(side).name().toLowerCase(Locale.ROOT)); return out; } - private static String skinUrl(Skull skull) { - try { - PlayerProfile profile = skull.getOwnerProfile(); - if (profile != null && profile.getTextures().getSkin() != null) { - return profile.getTextures().getSkin().toString(); - } - } catch (Throwable ignored) { - } - return null; + private static String skinUrl(PlayerProfile profile) { + return profile == null || profile.getTextures().getSkin() == null + ? null + : profile.getTextures().getSkin().toString(); } - /** The banner pattern's path key (e.g. "stripe_bottom"); every PatternType key accessor is marked - * for removal in this API with no stable replacement, so the warning is suppressed here. */ + /** Every PatternType key accessor is marked for removal with no stable replacement. */ @SuppressWarnings("removal") - private static String patternKey(Pattern p) { + private static String patternKey(org.bukkit.block.banner.Pattern p) { 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) { return new Builder(kind, bx, by, bz, yaw); } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/DecorationSnapshotBuilder.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/DecorationSnapshotBuilder.java index 975dbff..f23bef3 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/DecorationSnapshotBuilder.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/DecorationSnapshotBuilder.java @@ -12,18 +12,17 @@ import org.bukkit.entity.GlowItemFrame; import org.bukkit.entity.ItemFrame; import org.bukkit.entity.Painting; import org.bukkit.inventory.ItemStack; +import org.bukkit.map.MapView; import org.bukkit.util.BoundingBox; /** - * Captures flat wall decorations (paintings + item frames) near the view frustum into immutable - * {@link DecorationState}s. MUST run on the main thread. Uses each entity's world bounding box directly, - * which already encodes vanilla's painting placement offsets. + * Captures paintings and item frames into {@link DecorationState}s. MUST run on the main thread. The entity's + * world bounding box already encodes vanilla's painting placement offsets. */ public final class DecorationSnapshotBuilder { private DecorationSnapshotBuilder() {} - /** {@code nearby}: {@link FrustumBounds#nearbyEntities} of this photo. */ public static List build(Collection nearby) { List out = new ArrayList<>(); for (Entity e : nearby) { @@ -37,50 +36,48 @@ public final class DecorationSnapshotBuilder { } private static DecorationState toState(Entity e) { + DecorationState.Kind kind; + String art = null; + ItemSpec item = null; + int[][] map = null; + List markers = List.of(); + int rotation = 0; + boolean glow = false, invisible = false; if (e instanceof Painting painting) { - BoundingBox bb = e.getBoundingBox(); - String art = painting.getArt().assetId().value(); - return new DecorationState( - DecorationState.Kind.PAINTING, - 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"); + kind = DecorationState.Kind.PAINTING; + art = painting.getArt().assetId().value(); + } else if (e instanceof ItemFrame frame) { + kind = DecorationState.Kind.ITEM_FRAME; ItemStack stack = frame.getItem(); - ItemSpec item = ItemSpecs.of(stack); - int[][] map = item != null && stack.getType() == Material.FILLED_MAP ? MapPixels.of(stack) : null; - return new DecorationState( - DecorationState.Kind.ITEM_FRAME, - bb.getMinX(), - bb.getMinY(), - bb.getMinZ(), - bb.getMaxX(), - bb.getMaxY(), - bb.getMaxZ(), - facing(e.getFacing()), - null, - item, - map, - frame.getRotation().ordinal(), - glow, - !frame.isVisible(), - map != null ? MapPixels.markers(stack) : List.of()); + item = ItemSpecs.of(stack); + if (stack.getType() == Material.FILLED_MAP) { + MapView view = MapPixels.view(stack); + map = MapPixels.of(view); + if (map != null) markers = MapPixels.markers(view); + } + rotation = frame.getRotation().ordinal(); + glow = e instanceof GlowItemFrame; + invisible = !frame.isVisible(); + } else { + return null; } - 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) { diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/EntitySnapshotBuilder.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/EntitySnapshotBuilder.java index 7ad1138..78490f4 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/EntitySnapshotBuilder.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/EntitySnapshotBuilder.java @@ -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.util.ColorUtil; import io.papermc.paper.datacomponent.DataComponentTypes; +import java.lang.reflect.Field; +import java.lang.reflect.Method; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Base64; import java.util.Collection; +import java.util.HashMap; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Set; +import java.util.TreeSet; import java.util.UUID; 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.Keyed; import org.bukkit.Location; import org.bukkit.Material; +import org.bukkit.Tag; import org.bukkit.attribute.Attribute; 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.AbstractHorse; import org.bukkit.entity.Ageable; import org.bukkit.entity.ArmorStand; +import org.bukkit.entity.Arrow; import org.bukkit.entity.Axolotl; import org.bukkit.entity.Bat; import org.bukkit.entity.Cat; import org.bukkit.entity.ChestedHorse; +import org.bukkit.entity.Creaking; import org.bukkit.entity.Creeper; import org.bukkit.entity.Entity; import org.bukkit.entity.Fox; import org.bukkit.entity.Horse; import org.bukkit.entity.HumanEntity; +import org.bukkit.entity.Illusioner; import org.bukkit.entity.LivingEntity; import org.bukkit.entity.Llama; import org.bukkit.entity.Mob; import org.bukkit.entity.MushroomCow; import org.bukkit.entity.Panda; 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.PufferFish; import org.bukkit.entity.Rabbit; @@ -50,10 +67,12 @@ import org.bukkit.entity.Sittable; import org.bukkit.entity.Slime; import org.bukkit.entity.Sniffer; import org.bukkit.entity.Snowman; +import org.bukkit.entity.Spellcaster; import org.bukkit.entity.TraderLlama; import org.bukkit.entity.Trident; import org.bukkit.entity.TropicalFish; import org.bukkit.entity.Villager; +import org.bukkit.entity.Vindicator; import org.bukkit.entity.Wither; import org.bukkit.entity.Wolf; import org.bukkit.entity.ZombieVillager; @@ -62,20 +81,20 @@ import org.bukkit.inventory.EquipmentSlot; import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.MainHand; import org.bukkit.inventory.meta.ArmorMeta; +import org.bukkit.inventory.meta.CrossbowMeta; import org.bukkit.inventory.meta.ItemMeta; import org.bukkit.inventory.meta.LeatherArmorMeta; import org.bukkit.potion.PotionEffectType; +import org.bukkit.util.BoundingBox; +import org.bukkit.util.EulerAngle; -/** - * 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. - */ +/** Captures entities into {@link EntityState}s. MUST run on the main thread (live entity access). */ public final class EntitySnapshotBuilder { private EntitySnapshotBuilder() {} - // Technical / non-mob entity types that have no meaningful geometry; rendering them would only - // produce stray fallback boxes. Markers, displays, item frames, paintings, projectiles, drops, etc. + // Types without a mob model (technical entities, or drawn by the decoration/object builders); they would + // otherwise render as fallback boxes. private static final Set NON_RENDERABLE = Set.of( "area_effect_cloud", "marker", @@ -102,7 +121,7 @@ public final class EntitySnapshotBuilder { // Mobs whose main-hand item is drawn in the mouth (FoxHeldItemLayer, DolphinCarryingItemLayer). private static final Set MOUTH_CARRIERS = Set.of("fox", "dolphin"); - /** {@code nearby}: {@link FrustumBounds#nearbyEntities} of this photo. */ + /** {@code shooter}: the photographer, skipped. */ public static List build(Collection nearby, UUID shooter) { List states = new ArrayList<>(); for (Entity e : nearby) { @@ -113,27 +132,13 @@ public final class EntitySnapshotBuilder { return states; } - /** - * A not-spawned display entity (spawner mob) as a state at its own position; null for technical types. - * 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. + /** The entity's state, or null for non-renderable types. Also used for unspawned spawner display mobs. */ + static EntityState toState(Entity e) { 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; + 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()); // 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; String skinUrl = null; boolean slim = false; - if (player) { - String[] skin = resolveSkin((Player) e); + if (e instanceof Player p) { + String[] skin = resolveSkin(p); skinUrl = skin[0]; slim = "slim".equals(skin[1]); } @@ -169,9 +174,8 @@ public final class EntitySnapshotBuilder { int collar = 0; boolean powered = false; try { - // Slime & magma cube (MagmaCube extends Slime) scale their model by size (1/2/4). - if (e instanceof Slime sl) sizeScale = sl.getSize(); - // MushroomCow extends Cow, ZombieVillager does not extend Villager — order matters. + if (e instanceof Slime sl) sizeScale = sl.getSize(); // incl. magma cubes + // Order matters for subtypes (MushroomCow extends Cow, Llama extends ChestedHorse extends AbstractHorse). if (e instanceof Sheep sh) { tint = isJebSheep(sh) ? jebWoolColor(sh.getTicksLived()) : sheepWoolColor(sh.getColor()); if (sh.isSheared()) variant = "sheared"; @@ -185,7 +189,7 @@ public final class EntitySnapshotBuilder { case 1 -> "mid"; default -> "big";}; } 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); } else if (e instanceof Wolf w) { variant = keyOf(w.getVariant()); @@ -213,14 +217,11 @@ public final class EntitySnapshotBuilder { } else if (e instanceof Llama l) { variant = keyOf(l.getColor()); chest = l.isCarryingChest(); - // Trader llamas wear a fixed decor; normal llamas carry a dyed carpet in the decor slot. bodyEquip = (e instanceof TraderLlama) ? "trader_llama" : carpetKey(l); } else if (e instanceof ChestedHorse ch) { - // Donkey & mule (llama already handled above). chest = ch.isCarryingChest(); saddle = isSaddled(ch); } else if (e instanceof AbstractHorse ah) { - // Skeleton/zombie horse: only saddle (no colour/markings/armor variants). saddle = isSaddled(ah); } else if (e instanceof Fox f) { variant = keyOf(f.getFoxType()); @@ -229,26 +230,23 @@ public final class EntitySnapshotBuilder { } else if (e instanceof Panda pa) { variant = keyOf(pa.getMainGene()); } else if (e instanceof Shulker s) { - variant = s.getColor() == null ? null : keyOf(s.getColor()); + variant = keyOf(s.getColor()); } else if (e instanceof ZombieVillager zv) { variant = keyOf(zv.getVillagerType()); - // ZombieVillager exposes no level via Bukkit -> no profession-level badge (matches vanilla). } else if (e instanceof Villager vi) { variant = keyOf(vi.getVillagerType()); profession = keyOf(vi.getProfession()); villagerLevel = vi.getVillagerLevel(); } if (e instanceof ArmorStand st) arms = st.hasArms(); - // Any other mob with a variant/weathering getter (frog, cow, pig, chicken, zombie nautilus, copper - // golem, new mobs, …). Reflective on purpose: the API's compile-time declaring interface of - // getVariant() (AbstractCow) doesn't exist at runtime on every server build (NoSuchMethodError). + // Any other mob with a variant/weathering getter (frog, cow, pig, copper golem, new mobs, …). + // Reflective on purpose: the compile-time declaring interface of getVariant() (AbstractCow) doesn't + // exist at runtime on every server build (NoSuchMethodError). if (variant == null) variant = genericVariant(e); } 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, …) - // for every mount — including ones added in newer versions — plus held items (by hand side, like - // vanilla: a left-handed mob holds its main-hand item left) and a non-armor head item. + // Saddle + BODY slot for every mount (horse armor, carpet, harness, wolf armor, …), held items by hand + // side (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; EntityEquipment eq = e instanceof LivingEntity le ? le.getEquipment() : null; if (eq != null) { @@ -257,10 +255,12 @@ public final class EntitySnapshotBuilder { if (sd != null && !sd.getType().isAir()) saddle = true; ItemStack body = eq.getItem(EquipmentSlot.BODY); String asset = equipAsset(body); - if (asset != null && bodyEquip == null) bodyEquip = asset; - if (asset != null) bodyDye = dyeOf(body); + if (asset != null) { + if (bodyEquip == null) bodyEquip = asset; + bodyDye = dyeOf(body); + } } catch (Throwable ignored) { - // slot not supported by this entity / server version + // slot not supported by this entity } try { boolean leftHanded = (e instanceof Mob mob && mob.isLeftHanded()) @@ -268,7 +268,7 @@ public final class EntitySnapshotBuilder { ItemSpec main = ItemSpecs.of(eq.getItemInMainHand()); ItemSpec off = ItemSpecs.of(eq.getItemInOffHand()); if (MOUTH_CARRIERS.contains(type)) { - mouthItem = main; // fox/dolphin carry their main-hand item in the mouth, not in a hand + mouthItem = main; } else { rightHand = leftHanded ? off : main; leftHand = leftHanded ? main : off; @@ -282,20 +282,18 @@ public final class EntitySnapshotBuilder { headItem = ItemSpecs.of(new ItemStack(Material.CARVED_PUMPKIN)); } } 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 - // (vanilla dump), so the wearer list follows the game version. - EntityState.Equipment equipment = e instanceof LivingEntity wearer ? captureEquipment(wearer) : null; + // Worn armor of every living entity; the baker draws it only for renderers with a HumanoidArmorLayer. + EntityState.Equipment equipment = eq == null ? null : captureEquipment(eq); String pose = poseFlags(e); double x = loc.getX(), z = loc.getZ(); 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 // 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) { bodyYaw = switch (bed) { case SOUTH -> 90; @@ -307,7 +305,7 @@ public final class EntitySnapshotBuilder { z -= bed.getModZ() * off; } } - java.util.Map partPoses = e instanceof ArmorStand as ? armorStandPoses(as) : null; + Map partPoses = e instanceof ArmorStand as ? armorStandPoses(as) : null; String shoulderLeft = null, shoulderRight = null; if (e instanceof HumanEntity he) { 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): - * the generic {@link Entity#getPose()} (sleeping, crouching, swimming/crawling, fall_flying, sitting, - * 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. + * State-pose flags (names of the vanilla dump's STATE_POSES, sorted, "+"-joined; null = idle): the generic + * pose plus the per-mob flags vanilla's renderers read from the entity. The baker ignores unknown flags. */ private static String poseFlags(Entity e) { - java.util.TreeSet f = new java.util.TreeSet<>(); + TreeSet f = new TreeSet<>(); try { switch (e.getPose()) { case SLEEPING -> f.add("sleeping"); @@ -400,68 +395,57 @@ public final class EntitySnapshotBuilder { } renderFlags(e, f); } 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); } - /** - * Render-state flags beyond poses (the baker ignores those a type doesn't use): mob arm poses - * ({@code arms_}, 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 f) { + /** Render-state flags beyond poses: arm poses ({@code arms_}), textures and layers. */ + private static void renderFlags(Entity e, Set f) { String arms = armPose(e); if (arms != null) f.add("arms_" + arms); if (e instanceof Wolf w && w.isAngry()) f.add("angry"); // 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. - if (e instanceof org.bukkit.entity.Arrow ar - && ar.getColor() != null - && ar.getColor().asARGB() > 0) { - f.add("tipped"); - } + if (e instanceof Arrow ar && ar.getColor() != null && ar.getColor().asARGB() > 0) f.add("tipped"); if (e instanceof Wither wi) { // WitherBossRenderer.getTextureLocation: invulnerable texture above 80 ticks, then blinking every 5. int t = wi.getInvulnerableTicks(); 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. 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 - * pose): Piglin/PiglinBrute.getArmPose (dancing, admiring a loved off-hand item, aggressive with a melee - * 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). + * Arm pose (lower-case IllagerArmPose / PiglinArmPose constant; null = idle) like Piglin/PiglinBrute.getArmPose + * and the illagers' getArmPose. */ private static String armPose(Entity e) { 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(); ItemStack main = eq == null ? null : eq.getItemInMainHand(); ItemStack off = eq == null ? null : eq.getItemInOffHand(); 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 (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 (p.isChargingCrossbow()) return "crossbow_charge"; boolean holding = (main != null && main.getType() == Material.CROSSBOW) || (off != null && off.getType() == Material.CROSSBOW); - boolean charged = main != null - && main.getItemMeta() instanceof org.bukkit.inventory.meta.CrossbowMeta cm - && cm.hasChargedProjectiles(); - return holding && charged ? "crossbow_hold" : null; + return holding + && main != null + && main.getItemMeta() instanceof CrossbowMeta cm + && cm.hasChargedProjectiles() + ? "crossbow_hold" + : 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"; EntityEquipment eq = pi.getEquipment(); boolean crossbow = eq != null @@ -469,12 +453,12 @@ public final class EntitySnapshotBuilder { || eq.getItemInOffHand().getType() == Material.CROSSBOW); 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; } - if (e instanceof org.bukkit.entity.Spellcaster sc) { - if (sc.getSpell() != org.bukkit.entity.Spellcaster.Spell.NONE) return "spellcasting"; - if (e instanceof org.bukkit.entity.Illusioner) return aggressive ? "bow_and_arrow" : null; + if (e instanceof Spellcaster sc) { + if (sc.getSpell() != Spellcaster.Spell.NONE) return "spellcasting"; + if (e instanceof Illusioner) return aggressive ? "bow_and_arrow" : null; return sc.isCelebrating() ? "celebrating" : null; } return null; @@ -482,11 +466,9 @@ public final class EntitySnapshotBuilder { /** SheepRenderer: a sheep named "jeb_" (checkMagicName) cycles through all wool colours. */ private static boolean isJebSheep(Sheep sh) { - net.kyori.adventure.text.Component name = sh.customName(); + Component name = sh.customName(); return name != null - && "jeb_" - .equals(net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText() - .serialize(name)); + && "jeb_".equals(PlainTextComponentSerializer.plainText().serialize(name)); } /** @@ -494,12 +476,9 @@ public final class EntitySnapshotBuilder { */ private static int sheepWoolColor(DyeColor dye) { if (dye == null || dye == DyeColor.WHITE) return 0xFFE6E6E6; - org.bukkit.Color c = dye.getColor(); + Color c = dye.getColor(); return ColorUtil.argb( - 0xFF, - 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)); + 0xFF, (int) (c.getRed() * 0.75f), (int) (c.getGreen() * 0.75f), (int) (c.getBlue() * 0.75f)); } /** @@ -523,7 +502,7 @@ public final class EntitySnapshotBuilder { /** Cat.isLyingOnTopOfSleepingPlayer: a sleeping player within the cat's block box inflated by 2. */ private static boolean sleepingPlayerNear(Entity cat) { Location l = cat.getLocation(); - org.bukkit.util.BoundingBox box = new org.bukkit.util.BoundingBox( + BoundingBox box = new BoundingBox( l.getBlockX() - 2, l.getBlockY() - 2, l.getBlockZ() - 2, @@ -542,30 +521,48 @@ public final class EntitySnapshotBuilder { */ private static double standingEyeHeight(LivingEntity le) { try { - Object handle = le.getClass().getMethod("getHandle").invoke(le); - for (java.lang.reflect.Method m : handle.getClass().getMethods()) { - if (!m.getName().equals("getEyeHeight") || m.getParameterCount() != 1) continue; - 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(); - } - } + Object handle = handle(le); + StandingEyeHeight m = STANDING_EYE_HEIGHT.get(handle.getClass()); + if (m != null) return ((Number) m.method.invoke(handle, m.standing)).doubleValue(); } catch (ReflectiveOperationException | RuntimeException ignored) { - // fall through } 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 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 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. */ - private static org.bukkit.block.BlockFace bedFacing(Entity e) { - org.bukkit.block.data.BlockData bd = e.getLocation().getBlock().getBlockData(); - return bd instanceof org.bukkit.block.data.type.Bed bed ? bed.getFacing() : null; + private static BlockFace bedFacing(Entity e) { + return e.getLocation().getBlock().getBlockData() instanceof Bed bed ? bed.getFacing() : null; } /** Armor-stand part poses in degrees, keyed by the vanilla render-state field (dump rotation bindings). */ - private static java.util.Map armorStandPoses(ArmorStand as) { - java.util.Map out = new java.util.HashMap<>(); + private static Map armorStandPoses(ArmorStand as) { + Map out = new HashMap<>(); out.put("headPose", deg(as.getHeadPose())); out.put("bodyPose", deg(as.getBodyPose())); out.put("leftArmPose", deg(as.getLeftArmPose())); @@ -575,7 +572,7 @@ public final class EntitySnapshotBuilder { return out; } - private static float[] deg(org.bukkit.util.EulerAngle a) { + private static float[] deg(EulerAngle a) { return new float[] { (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. */ - private static EntityState.Equipment captureEquipment(LivingEntity le) { + /** Worn armor; null when nothing is equipped. */ + private static EntityState.Equipment captureEquipment(EntityEquipment eq) { try { - EntityEquipment eq = le.getEquipment(); - if (eq == null) return null; EntityState.Equipment equip = new EntityState.Equipment( armorPiece(eq.getHelmet()), armorPiece(eq.getChestplate()), armorPiece(eq.getLeggings()), armorPiece(eq.getBoots())); @@ -626,20 +621,22 @@ public final class EntitySnapshotBuilder { 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. */ private static String armorAsset(Material m) { String key = m.getKey().getKey(); if (key.equals("elytra")) return "elytra"; if (key.equals("turtle_helmet")) return "turtle_scute"; - String base = null; - for (String suf : new String[] {"_helmet", "_chestplate", "_leggings", "_boots"}) { - if (key.endsWith(suf)) { - base = key.substring(0, key.length() - suf.length()); - break; - } + for (String suf : ARMOR_SUFFIXES) { + if (key.endsWith(suf)) return assetMaterial(key.substring(0, key.length() - suf.length())); } - if (base == null) return null; - return base.equals("golden") ? "gold" : base; + return null; + } + + /** 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. */ @@ -652,8 +649,7 @@ public final class EntitySnapshotBuilder { private static String horseArmorKey(Horse h) { ItemStack a = h.getInventory().getArmor(); if (a == null || a.getType().isAir()) return null; - String k = a.getType().getKey().getKey().replace("_horse_armor", ""); - return k.equals("golden") ? "gold" : k; + return assetMaterial(a.getType().getKey().getKey().replace("_horse_armor", "")); } /** 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 SHAKE_FIELD = new ClassValue<>() { - @Override - protected java.lang.reflect.Field computeValue(Class c) { - try { - return c.getField("shakeTime"); - } catch (NoSuchFieldException ex) { - return null; - } - } - }; + private static final ClassValue SHAKE_FIELD = Reflection.field("shakeTime"); // NMS AbstractArrow /** * 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) { if (!(e instanceof AbstractArrow) || e instanceof Trident) return 0; try { - Object handle = e.getClass().getMethod("getHandle").invoke(e); - java.lang.reflect.Field f = SHAKE_FIELD.get(handle.getClass()); + Object handle = handle(e); + Field f = SHAKE_FIELD.get(handle.getClass()); int shake = f == null ? 0 : f.getInt(handle); return shake <= 0 ? 0 : (float) (-Math.sin(shake * 3.0) * shake); } 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 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; - } - }; + private static final ClassValue VARIANT_GETTER = Reflection.method("getVariant", "getWeatheringState"); - /** Variant key via a {@code getVariant()} / {@code getWeatheringState()} getter, if the entity has one. */ 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; try { 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) { ItemStack st = h.getInventory().getSaddle(); return st != null && !st.getType().isAir(); diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/FrustumBounds.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/FrustumBounds.java index c230010..1eec5a4 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/FrustumBounds.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/FrustumBounds.java @@ -8,9 +8,8 @@ import org.bukkit.entity.Entity; import org.bukkit.util.Vector; /** - * Axis-aligned world-space bounds of the camera frustum: the min/max corner of the camera origin - * together with every ray endpoint at {@code maxDistance}. Shared by all snapshot builders to size - * their chunk/entity queries identically. + * Axis-aligned world-space bounds of the camera frustum (origin plus every ray end at {@code maxDistance}), + * computed once per photo and shared by all snapshot builders. */ public final class FrustumBounds { @@ -26,13 +25,14 @@ public final class FrustumBounds { this.maxZ = maxZ; } - static FrustumBounds of(Vector origin, List rayMap, double maxDistance) { - double minX = origin.getX(), minY = origin.getY(), minZ = origin.getZ(); - double maxX = origin.getX(), maxY = origin.getY(), maxZ = origin.getZ(); + public static FrustumBounds of(Location eye, List rayMap, double maxDistance) { + double ox = eye.getX(), oy = eye.getY(), oz = eye.getZ(); + double minX = ox, minY = oy, minZ = oz; + double maxX = ox, maxY = oy, maxZ = oz; for (Vector ray : rayMap) { - double fx = origin.getX() + ray.getX() * maxDistance; - double fy = origin.getY() + ray.getY() * maxDistance; - double fz = origin.getZ() + ray.getZ() * maxDistance; + double fx = ox + ray.getX() * maxDistance; + double fy = oy + ray.getY() * maxDistance; + double fz = oz + ray.getZ() * maxDistance; minX = Math.min(minX, fx); maxX = Math.max(maxX, fx); minY = Math.min(minY, fy); @@ -43,20 +43,15 @@ public final class FrustumBounds { 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 - * object snapshot builders. MUST run on the main thread. + * Entities near the frustum (2-block margin), queried once per photo and shared by the entity, decoration + * and object builders. MUST run on the main thread. */ - public static Collection nearbyEntities(Location eye, List rayMap, double maxDistance) { - return of(eye.toVector(), rayMap, maxDistance).nearbyEntities(eye.getWorld(), 2); - } - - Collection nearbyEntities(World world, double margin) { + public Collection nearbyEntities(World world) { Location center = new Location(world, (minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2); - double hx = (maxX - minX) / 2 + margin; - double hy = (maxY - minY) / 2 + margin; - double hz = (maxZ - minZ) / 2 + margin; + double hx = (maxX - minX) / 2 + 2; + double hy = (maxY - minY) / 2 + 2; + double hz = (maxZ - minZ) / 2 + 2; return world.getNearbyEntities(center, hx, hy, hz); } } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/ItemSpecs.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/ItemSpecs.java index 8cbb412..121c7ab 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/ItemSpecs.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/ItemSpecs.java @@ -9,17 +9,17 @@ import org.bukkit.block.data.BlockData; import org.bukkit.inventory.ItemStack; /** Builds {@link ItemSpec}s from Bukkit item stacks. MUST run on the main thread. */ -public final class ItemSpecs { +final class 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 static final Map BLOCKS = new ConcurrentHashMap<>(); /** 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; Material m = stack.getType(); BlockInfo b = BLOCKS.computeIfAbsent(m, ItemSpecs::blockInfo); @@ -30,11 +30,10 @@ public final class ItemSpecs { glint = false; } 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)) { be = BlockEntitySnapshotBuilder.withItemMeta(be, stack.getItemMeta()); } else if (m == Material.SHIELD) { - // Shield banner patterns + base colour (ShieldSpecialRenderer), carried as a BANNER state. be = BlockEntitySnapshotBuilder.shieldPatterns(stack.getItemMeta()); } return new ItemSpec(m.getKey().getKey(), b.data, be, glint); diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/MapPixels.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/MapPixels.java index cc7a7a1..ec974aa 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/MapPixels.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/MapPixels.java @@ -5,7 +5,9 @@ import eu.mhsl.minecraft.pixelpics.utils.ImageMapRenderer; import eu.mhsl.minecraft.pixelpics.utils.MapColorPalette; import eu.mhsl.minecraft.pixelpics.utils.MapManager; import java.lang.reflect.Field; +import java.lang.reflect.Method; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Map; import org.bukkit.inventory.ItemStack; @@ -14,22 +16,30 @@ import org.bukkit.map.MapRenderer; import org.bukkit.map.MapView; /** - * Captures a filled map's 128x128 colours for rendering it in an item frame. PixelPics photo maps come from - * their {@link ImageMapRenderer} (or the on-disk index cache), other maps from the server's map data - * ({@code MapItemSavedData.colors}, reflectively — Bukkit has no API to read it). MUST run on the main thread. + * Captures a filled map's 128x128 colours (and vanilla frame markers) for rendering it in an item frame. + * PixelPics photo maps come from their {@link ImageMapRenderer} (or the on-disk index cache), other maps from + * 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 MapPixels() {} - /** ARGB {@code [y][x]}, or null when the stack is no map or has no map data (vanilla then draws the item). */ - public static int[][] of(ItemStack stack) { + /** The stack's map view, or null when it has none. */ + 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 { - if (!(stack.getItemMeta() instanceof MapMeta meta) || !meta.hasMapView()) return null; - MapView view = meta.getMapView(); - if (view == null) return null; byte[] colors = colors(view); if (colors == null || colors.length != SIZE * SIZE) return null; int[][] out = new int[SIZE][SIZE]; @@ -53,26 +63,25 @@ public final class MapPixels { byte[] cached = MapManager.readIndices(view.getId()); 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. */ private static byte[] film() { byte[] b = new byte[SIZE * SIZE]; - java.util.Arrays.fill(b, ImageMapRenderer.filmColor()); + Arrays.fill(b, ImageMapRenderer.filmColor()); return b; } /** - * The map's decorations an item frame shows (MapRenderer with showOnlyFrame: MapDecorationType.showOnItemFrame - * — banners, frame markers, structure/target markers; never players), in the map data's order. Only for maps - * drawn by the vanilla renderer (CraftMapRenderer sends {@code MapItemSavedData.decorations} as cursors): - * PixelPics photo maps and other plugin-rendered maps get none. + * The decorations an item frame shows (MapDecorationType.showOnItemFrame: banners, frame/structure/target + * markers; never players), in the map data's order. Only for maps drawn by the vanilla CraftMapRenderer, + * which sends {@code MapItemSavedData.decorations} as cursors; plugin-rendered maps (photos) get none. */ - public static List markers(ItemStack stack) { + static List markers(MapView view) { 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(); boolean vanilla = false; for (MapRenderer r : view.getRenderers()) { @@ -81,25 +90,17 @@ public final class MapPixels { } if (!vanilla) return List.of(); Object data = worldMap(view); - if (data == null) return List.of(); - Field f = decorationsField; - 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(); + Field f = data == null ? null : DECORATIONS.get(data.getClass()); + if (f == null || !(f.get(data) instanceof Map decorations) || decorations.isEmpty()) return List.of(); List out = new ArrayList<>(); for (Object d : decorations.values()) { - Class c = d.getClass(); - if (!(Boolean) c.getMethod("renderOnFrame").invoke(d)) continue; - String sprite = String.valueOf(c.getMethod("getSpriteLocation").invoke(d)); - String asset = sprite.substring(sprite.indexOf(':') + 1); + Method[] m = DECORATION_METHODS.get(d.getClass()); + if (m == null) return List.of(); + if (!(Boolean) m[0].invoke(d)) continue; + String sprite = String.valueOf(m[1].invoke(d)); out.add(new DecorationState.MapMarker( - asset, - (Byte) c.getMethod("x").invoke(d), - (Byte) c.getMethod("y").invoke(d), - (Byte) c.getMethod("rot").invoke(d))); + sprite.substring(sprite.indexOf(':') + 1), (Byte) m[2].invoke(d), (Byte) m[3].invoke(d), (Byte) + m[4].invoke(d))); } return out; } 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 { - Field wm = worldMapField; - if (wm == null || wm.getDeclaringClass() != view.getClass()) { - wm = view.getClass().getDeclaredField("worldMap"); - wm.setAccessible(true); - worldMapField = wm; - } - return wm.get(view); + Field f = WORLD_MAP.get(view.getClass()); + return f == null ? null : f.get(view); } - /** CraftMapView#worldMap → MapItemSavedData#colors (what the vanilla CraftMapRenderer sends). */ - private static byte[] worldMapColors(MapView view) throws ReflectiveOperationException { - Object data = worldMap(view); - if (data == null) return null; - Field c = colorsField; - if (c == null || c.getDeclaringClass() != data.getClass()) { - c = data.getClass().getField("colors"); - colorsField = c; - } - return (byte[]) c.get(data); - } + private static final ClassValue WORLD_MAP = Reflection.field("worldMap"); // CraftMapView + private static final ClassValue DECORATIONS = Reflection.field("decorations"); // MapItemSavedData + private static final ClassValue COLORS = Reflection.field("colors"); // MapItemSavedData + private static final ClassValue DECORATION_METHODS = + Reflection.methods("renderOnFrame", "getSpriteLocation", "x", "y", "rot"); // MapDecoration } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/ObjectSnapshotBuilder.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/ObjectSnapshotBuilder.java index 875a7af..a0dc6d3 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/ObjectSnapshotBuilder.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/ObjectSnapshotBuilder.java @@ -1,13 +1,14 @@ package eu.mhsl.minecraft.pixelpics.render.snapshot; 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.ObjectState; +import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.List; +import java.util.Locale; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.TextColor; import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; @@ -32,15 +33,14 @@ import org.bukkit.util.Transformation; import org.joml.Quaternionf; /** - * Captures block/item/text content objects into {@link ObjectState}s on the main thread: display - * entities (with their transformation and billboard mode, resolved against the camera), dropped items, - * falling blocks, primed TNT and the block shown inside minecarts. + * Captures display entities (transformation, billboard resolved against the camera), dropped items, falling + * blocks, primed TNT, minecart blocks and leash ropes into {@link ObjectState}s. MUST run on the main thread. */ public final class ObjectSnapshotBuilder { private ObjectSnapshotBuilder() {} - /** {@code nearby}: {@link FrustumBounds#nearbyEntities} of this photo; {@code eye} orients billboards. */ + /** {@code eye} orients billboards. */ public static List build(Collection nearby, Location eye) { List out = new ArrayList<>(); for (Entity e : nearby) { @@ -48,13 +48,11 @@ public final class ObjectSnapshotBuilder { ObjectState s = toState(e, eye); if (s != null) out.add(s); } catch (Throwable ignored) { - // API differences on other server versions: skip this object } try { ObjectState leash = leash(e); if (leash != null) out.add(leash); } catch (Throwable ignored) { - // not leashable on this version } } return out; @@ -64,12 +62,12 @@ public final class ObjectSnapshotBuilder { Location l = e.getLocation(); Affine at = Affine.translation(l.getX(), l.getY(), l.getZ()); 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) { return block(base, bd.getBlock()); } 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. return item(base.mul(Affine.rotY(Math.PI)), id.getItemStack(), ctx, false); } @@ -78,22 +76,17 @@ public final class ObjectSnapshotBuilder { } return null; } - if (e instanceof FallingBlock fb) { - return block(at.mul(Affine.translation(-0.5, 0, -0.5)), fb.getBlockData()); - } - if (e instanceof TNTPrimed) { - return block(at.mul(Affine.translation(-0.5, 0, -0.5)), Material.TNT.createBlockData()); + if (e instanceof FallingBlock || e instanceof TNTPrimed) { + 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 Item it) { - // Dropped item: vanilla GROUND display transform, lifted by the baker. The spin (vanilla: age/20 + - // a random bobOffset) is frozen per entity from its yaw, which ItemEntity also randomises at creation - // (so items differ like in vanilla, and a test can fix it with setRotation). + // GROUND transform, lifted by the baker. The spin (vanilla: age/20 + random bobOffset) is frozen from + // the yaw, which ItemEntity also randomises at creation (tests fix it with setRotation). double spin = Math.toRadians(l.getYaw()); return item(at.mul(Affine.rotY(spin)), it.getItemStack(), "ground", true); } 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. Affine base = at.mul(Affine.translation(0, 0.375, 0)) .mul(Affine.rotY(Math.PI - Math.toRadians(l.getYaw()))) @@ -101,15 +94,14 @@ public final class ObjectSnapshotBuilder { .mul(Affine.scale(0.75)) .mul(Affine.translation(-0.5, (mc.getDisplayBlockOffset() - 8) / 16.0, 0.5)) .mul(Affine.rotY(Math.PI / 2)); - return block(base, data); + return block(base, mc.getDisplayBlockData()); } return null; } private static ObjectState block(Affine toWorld, BlockData data) { if (data == null || data.getMaterial().isAir()) return null; - BlockEntityState be = BlockEntitySnapshotBuilder.fromBlockData(data); // chest/banner/head/… content - return ObjectState.block(toWorld, data, be); + return ObjectState.block(toWorld, data, BlockEntitySnapshotBuilder.fromBlockData(data)); } 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. */ - private static final ClassValue LEASH_METHODS = new ClassValue<>() { - @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; - } - } - }; + private static final ClassValue LEASH_METHODS = Reflection.methods("isLeashed", "getLeashHolder"); /** * 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 - * right hand, or 0.7·eye height for other holders. Works for any entity exposing - * {@code isLeashed()/getLeashHolder()} (mobs, and boats on versions where they are leashable). + * right hand, or 0.7·eye height for other holders. */ private static ObjectState leash(Entity e) throws ReflectiveOperationException { Entity holder; @@ -142,34 +124,42 @@ public final class ObjectSnapshotBuilder { if (!le.isLeashed()) return null; holder = le.getLeashHolder(); } 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; holder = (Entity) m[1].invoke(e); } if (holder == null) return null; Location l = e.getLocation(); - double yaw = Math.toRadians(e instanceof LivingEntity liv ? liv.getBodyYaw() : l.getYaw()); - double eye = e instanceof LivingEntity liv ? liv.getEyeHeight() : e.getHeight() * 0.5; + double yaw = Math.toRadians(bodyYaw(e, l)); 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(); - double[] to; + p[3] = h.getX(); + p[5] = h.getZ(); 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 || 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; - to = new double[] { - h.getX() - Math.cos(hy) * side - Math.sin(hy) * f, - h.getY() + holder.getHeight() * 0.42, - h.getZ() - Math.sin(hy) * side + Math.cos(hy) * f - }; + p[3] = h.getX() - Math.cos(hy) * side - Math.sin(hy) * f; + p[4] = h.getY() + holder.getHeight() * 0.42; + p[5] = h.getZ() - Math.sin(hy) * side + Math.cos(hy) * f; } else { - double he = holder instanceof LivingEntity hl ? hl.getEyeHeight() : holder.getHeight() * 0.5; - to = new double[] {h.getX(), h.getY() + he * 0.7, h.getZ()}; + p[4] = h.getY() + eyeHeight(holder) * 0.7; } - 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) { @@ -178,24 +168,26 @@ public final class ObjectSnapshotBuilder { if (plain.isBlank()) return null; TextColor color = c.color(); int opacity = td.getTextOpacity() & 0xFF; - if (opacity == 0) opacity = 255; // -1 (default) = fully opaque - if (opacity < 26) opacity = 255; // vanilla treats very low values as opaque too + if (opacity < 26) opacity = 255; // vanilla: -1 (default) and very low values render opaque int rgb = color == null ? 0xFFFFFF : color.value(); int textArgb = (opacity << 24) | rgb; Color bg = td.getBackgroundColor(); int bgArgb = td.isDefaultBackground() || bg == null ? 0x40000000 : bg.asARGB(); // Vanilla text displays render font pixels at 1/40 block. - Affine toWorld = base.mul(Affine.scale(0.025)); 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 * 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) { - Location l = d.getLocation(); + private static Affine billboard(Display d, Location l, Location eye) { double yaw = Math.toRadians(l.getYaw()), pitch = Math.toRadians(l.getPitch()); double camYaw = Math.toRadians(eye.getYaw()), camPitch = Math.toRadians(eye.getPitch()); return switch (d.getBillboard()) { diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/Reflection.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/Reflection.java new file mode 100644 index 0000000..07c1240 --- /dev/null +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/Reflection.java @@ -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(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 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(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; + } + } + }; + } +} diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/SnapshotBuilder.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/SnapshotBuilder.java index c94fd30..e3c0f16 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/SnapshotBuilder.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/SnapshotBuilder.java @@ -1,36 +1,26 @@ package eu.mhsl.minecraft.pixelpics.render.snapshot; import java.util.HashMap; -import java.util.List; import java.util.Map; import java.util.logging.Logger; import org.bukkit.ChunkSnapshot; -import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.World; -import org.bukkit.util.Vector; /** - * Captures the world region covered by the camera frustum into a {@link WorldSnapshot}. - * - *

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. + * 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. */ 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 SnapshotBuilder() {} - public static WorldSnapshot build(Location eye, List rayMap, double maxDistance, Logger logger) { - World world = eye.getWorld(); - FrustumBounds bounds = FrustumBounds.of(eye.toVector(), rayMap, maxDistance); - - 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); + public static WorldSnapshot build(World world, FrustumBounds bounds, Logger logger) { + int minY = Math.max(world.getMinHeight(), (int) Math.floor(bounds.minY) - 1); + int maxY = Math.min(world.getMaxHeight(), (int) Math.ceil(bounds.maxY) + 1); int minCX = (int) Math.floor(bounds.minX) >> 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; Map chunks = new HashMap<>(); - int captured = 0; int skipped = 0; for (int cx = minCX; cx <= maxCX; cx++) { for (int cz = minCZ; cz <= maxCZ; cz++) { - if (captured >= MAX_CHUNKS) { + if (chunks.size() >= MAX_CHUNKS || !world.isChunkLoaded(cx, cz)) { skipped++; continue; } - if (!world.isChunkLoaded(cx, cz)) { - skipped++; - continue; - } - ChunkSnapshot cs = world.getChunkAt(cx, cz).getChunkSnapshot(false, true, false); - chunks.put(WorldSnapshot.chunkKey(cx, cz), cs); - captured++; + chunks.put( + WorldSnapshot.chunkKey(cx, cz), world.getChunkAt(cx, cz).getChunkSnapshot(false, true, false)); } } - if (skipped > 0) { - logger.fine( - String.format("Snapshot captured %d chunks, skipped %d (unloaded or over cap)", captured, skipped)); + logger.fine(String.format( + "Snapshot captured %d chunks, skipped %d (unloaded or over cap)", chunks.size(), skipped)); } - - return new WorldSnapshot(chunks, clampedMinY, clampedMaxY, Material.AIR.createBlockData()); + return new WorldSnapshot(chunks, minY, maxY, Material.AIR.createBlockData()); } } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/WorldSnapshot.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/WorldSnapshot.java index 8572d6d..fde3ef1 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/WorldSnapshot.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/WorldSnapshot.java @@ -6,14 +6,11 @@ import org.bukkit.block.Biome; import org.bukkit.block.data.BlockData; /** - * An immutable, thread-safe view of a bounded region of the world, backed by {@link ChunkSnapshot}s. - * Block/biome lookups outside the captured region return air/null so rays simply terminate there. + * Immutable, thread-safe view of the captured chunks; lookups outside them return air/null. * - *

The chunks live in a primitive open-addressing table (linear probing, Fibonacci hashing of the full - * {@link #chunkKey}), not a {@code HashMap}: {@code Long.hashCode} of the key is {@code cx ^ cz}, so - * every diagonal of the captured rectangle collided into treeified bins and each block lookup paid for - * boxing, {@code TreeNode.find} and its reflective {@code comparableClassFor}. Keys are compared in full, - * so lookups stay exact. + *

Performance: the chunks live in a primitive open-addressing table (linear probing, Fibonacci hash of the + * full {@link #chunkKey}), not a {@code HashMap}: {@code Long.hashCode} is {@code cx ^ cz}, so every + * diagonal collided into treeified bins and each block lookup paid for boxing and {@code TreeNode.find}. */ public final class WorldSnapshot { @@ -35,7 +32,6 @@ public final class WorldSnapshot { this.mask = cap - 1; this.shift = 64 - Integer.numberOfTrailingZeros(cap); for (Map.Entry e : chunks.entrySet()) { - if (e.getValue() == null) continue; long key = e.getKey(); int i = slot(key); while (values[i] != null) i = (i + 1) & mask;