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:
1 parent
bc07631fec
commit
2a3db3d7aa
11 files changed
+504
-587
No files matched your search
@@ -116,10 +116,11 @@ public class DefaultScreenRenderer {
|
||||
!= FluidRenderer.NONE;
|
||||
List<Vector> 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<Entity> nearby = FrustumBounds.nearbyEntities(eyeLocation, rayMap, MAX_DISTANCE);
|
||||
FrustumBounds bounds = FrustumBounds.of(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<BlockEntityState> blockEntities = BlockEntitySnapshotBuilder.build(eyeLocation, rayMap, MAX_DISTANCE);
|
||||
List<BlockEntityState> blockEntities = BlockEntitySnapshotBuilder.build(eyeLocation, bounds);
|
||||
List<DecorationState> decorations = DecorationSnapshotBuilder.build(nearby);
|
||||
List<ObjectState> objects = ObjectSnapshotBuilder.build(nearby, eyeLocation);
|
||||
|
||||
|
||||
+137
-206
@@ -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<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();
|
||||
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<BannerPattern> 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<BannerPattern> pats = new ArrayList<>();
|
||||
for (Pattern p : shield.getPatterns()) {
|
||||
pats.add(new BannerPattern(patternKey(p), ColorUtil.dyeArgb(p.getColor(), 0xFFFFFFFF)));
|
||||
}
|
||||
List<BannerPattern> 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<BannerPattern> 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<int[]> 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_]<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) {
|
||||
String[] raw = side.getLines();
|
||||
List<String> 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<String> sherds(DecoratedPot pot) {
|
||||
// Order: front, left, right, back — the DecoratedPotRenderer side parts (BlockEntityModels.potParts).
|
||||
List<String> 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);
|
||||
}
|
||||
|
||||
+42
-45
@@ -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<DecorationState> build(Collection<Entity> nearby) {
|
||||
List<DecorationState> 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<DecorationState.MapMarker> 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) {
|
||||
|
||||
+132
-161
@@ -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<String> 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<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) {
|
||||
List<EntityState> 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<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;
|
||||
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<String> f = new java.util.TreeSet<>();
|
||||
TreeSet<String> 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_<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) {
|
||||
/** Render-state flags beyond poses: arm poses ({@code arms_<pose>}), textures and layers. */
|
||||
private static void renderFlags(Entity e, Set<String> 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<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. */
|
||||
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<String, float[]> armorStandPoses(ArmorStand as) {
|
||||
java.util.Map<String, float[]> out = new java.util.HashMap<>();
|
||||
private static Map<String, float[]> armorStandPoses(ArmorStand as) {
|
||||
Map<String, float[]> 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<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;
|
||||
}
|
||||
}
|
||||
};
|
||||
private static final ClassValue<Field> 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<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;
|
||||
}
|
||||
};
|
||||
private static final ClassValue<Method> 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();
|
||||
|
||||
@@ -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<Vector> 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<Vector> 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<Entity> nearbyEntities(Location eye, List<Vector> rayMap, double maxDistance) {
|
||||
return of(eye.toVector(), rayMap, maxDistance).nearbyEntities(eye.getWorld(), 2);
|
||||
}
|
||||
|
||||
Collection<Entity> nearbyEntities(World world, double margin) {
|
||||
public Collection<Entity> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<Material, BlockInfo> 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);
|
||||
|
||||
@@ -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<DecorationState.MapMarker> markers(ItemStack stack) {
|
||||
static List<DecorationState.MapMarker> 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<DecorationState.MapMarker> 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<Field> WORLD_MAP = Reflection.field("worldMap"); // CraftMapView
|
||||
private static final ClassValue<Field> DECORATIONS = Reflection.field("decorations"); // MapItemSavedData
|
||||
private static final ClassValue<Field> COLORS = Reflection.field("colors"); // MapItemSavedData
|
||||
private static final ClassValue<Method[]> DECORATION_METHODS =
|
||||
Reflection.methods("renderOnFrame", "getSpriteLocation", "x", "y", "rot"); // MapDecoration
|
||||
}
|
||||
+47
-55
@@ -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<ObjectState> build(Collection<Entity> nearby, Location eye) {
|
||||
List<ObjectState> 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<java.lang.reflect.Method[]> 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<Method[]> 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()) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
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}.
|
||||
*
|
||||
* <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.
|
||||
* 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<Vector> 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<Long, ChunkSnapshot> 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());
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>The chunks live in a primitive open-addressing table (linear probing, Fibonacci hashing of the full
|
||||
* {@link #chunkKey}), not a {@code HashMap<Long, …>}: {@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.
|
||||
* <p>Performance: the chunks live in a primitive open-addressing table (linear probing, Fibonacci hash of the
|
||||
* full {@link #chunkKey}), not a {@code HashMap<Long, …>}: {@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<Long, ChunkSnapshot> e : chunks.entrySet()) {
|
||||
if (e.getValue() == null) continue;
|
||||
long key = e.getKey();
|
||||
int i = slot(key);
|
||||
while (values[i] != null) i = (i + 1) & mask;
|
||||
|
||||
Reference in new issue
Block a user