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

This commit is contained in:
2026-06-21 17:30:24 +02:00
parent fed94f97d1
commit 220cda1deb
14 changed files with 948 additions and 95 deletions
@@ -26,6 +26,13 @@ public final class Main extends JavaPlugin {
public final NamespacedKey pictureIdFlag = new NamespacedKey(this, "imageid");
/** Marks a {@code PLAYER_HEAD} as a camera (BYTE 1). */
public final NamespacedKey cameraMarker = new NamespacedKey(this, "camera");
/** Loaded film count stored on a camera (INTEGER, 0..{@code CameraItems.MAX_FILM}). */
public final NamespacedKey filmCountKey = new NamespacedKey(this, "filmcount");
/** Marks a {@code PLAYER_HEAD} as a film roll (BYTE 1). */
public final NamespacedKey filmMarker = new NamespacedKey(this, "film");
@Override
public void onEnable() {
instance = this;
@@ -33,6 +40,11 @@ public final class Main extends JavaPlugin {
Bukkit.getPluginManager().registerEvents(new OnMapInitialize(), this);
Objects.requireNonNull(Bukkit.getPluginCommand("pixelPic")).setExecutor(new PixelPicsCommand());
Bukkit.getPluginManager().registerEvents(new eu.mhsl.minecraft.pixelpics.survival.CameraListener(), this);
Bukkit.getPluginManager().registerEvents(new eu.mhsl.minecraft.pixelpics.survival.CraftingListener(), this);
Bukkit.getPluginManager().registerEvents(new eu.mhsl.minecraft.pixelpics.survival.JoinListener(), this);
eu.mhsl.minecraft.pixelpics.survival.SurvivalRecipes.register();
initRenderer();
}
@@ -81,6 +93,7 @@ public final class Main extends JavaPlugin {
@Override
public void onDisable() {
eu.mhsl.minecraft.pixelpics.survival.SurvivalRecipes.unregister();
if (resourcePack != null) {
resourcePack.close();
resourcePack = null;
@@ -1,30 +1,17 @@
package eu.mhsl.minecraft.pixelpics.commands;
import eu.mhsl.minecraft.pixelpics.Main;
import eu.mhsl.minecraft.pixelpics.render.render.DefaultScreenRenderer;
import eu.mhsl.minecraft.pixelpics.render.render.RenderJob;
import eu.mhsl.minecraft.pixelpics.render.render.Resolution;
import eu.mhsl.minecraft.pixelpics.utils.ImageMapRenderer;
import eu.mhsl.minecraft.pixelpics.utils.MapImageDither;
import eu.mhsl.minecraft.pixelpics.survival.PhotoService;
import eu.mhsl.minecraft.pixelpics.utils.MapManager;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.MapMeta;
import org.bukkit.map.MapView;
import org.bukkit.persistence.PersistentDataType;
import org.jetbrains.annotations.NotNull;
import java.awt.image.BufferedImage;
import java.util.List;
import java.util.Set;
import java.util.UUID;
public class PixelPicsCommand implements CommandExecutor {
@@ -44,57 +31,14 @@ public class PixelPicsCommand implements CommandExecutor {
}
if (args.length > 0) return false;
DefaultScreenRenderer renderer = Main.getInstance().getScreenRenderer();
if (renderer == null) {
player.sendMessage(Component.text("PixelPics ist nicht einsatzbereit: es wurde kein Resource-Pack geladen.",
// Debug shortcut: render a photo from the player's view without needing a camera or film.
if (!player.hasPermission("pixelpic.admin")) {
player.sendActionBar(Component.text("Dafür fehlt dir die Berechtigung — nutze eine Kamera.",
NamedTextColor.RED));
return true;
}
Resolution resolution = new Resolution(Resolution.Pixels._128P, Resolution.AspectRatio._1_1);
// Capture the world snapshot on the main thread.
RenderJob job = renderer.prepare(player.getEyeLocation(), resolution, player.getUniqueId());
// Hand the map over immediately, showing blank "film"; it develops once the render is ready.
ImageMapRenderer mapRenderer = new ImageMapRenderer();
MapView mapView = Bukkit.createMap(player.getWorld());
int id = mapView.getId();
MapManager.attachView(mapView, mapRenderer);
ItemStack map = new ItemStack(Material.FILLED_MAP, 1);
MapMeta meta = (MapMeta) map.getItemMeta();
meta.getPersistentDataContainer().set(Main.getInstance().pictureIdFlag,
PersistentDataType.STRING, UUID.randomUUID().toString());
meta.setMapView(mapView);
map.setItemMeta(meta);
player.getInventory().addItem(map);
player.sendMessage(Component.text("📸 Aufnahme wird entwickelt …", NamedTextColor.GRAY));
// Trace + dither off-thread, then start the developing animation on the main thread.
Main plugin = Main.getInstance();
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
BufferedImage image;
byte[] indices;
try {
image = renderer.execute(job);
indices = MapImageDither.dither(image);
} catch (Exception e) {
plugin.getLogger().warning("Render failed: " + e.getMessage());
Bukkit.getScheduler().runTask(plugin, () ->
player.sendMessage(Component.text("Rendern fehlgeschlagen.", NamedTextColor.RED)));
return;
}
BufferedImage finalImage = image;
byte[] finalIndices = indices;
Bukkit.getScheduler().runTask(plugin, () -> {
MapManager.saveImage(finalImage, id);
MapManager.saveIndices(finalIndices, id);
mapRenderer.develop(finalIndices);
player.sendMessage(Component.text("✅ Aufnahme erstellt!", NamedTextColor.GREEN));
});
});
PhotoService.takePhoto(player);
return true;
}
@@ -19,5 +19,24 @@ public record EntityState(
String markings, // horse coat markings style (e.g. "white", "whitefield", "whitedots", "blackdots"), or null
boolean saddle, // horse/donkey/mule is saddled
boolean chest, // donkey/mule/llama is carrying a chest
String bodyEquip // horse armor material (iron/gold/diamond/leather) OR llama carpet colour OR "trader_llama"; null = none
) {}
String bodyEquip, // horse armor material (iron/gold/diamond/leather) OR llama carpet colour OR "trader_llama"; null = none
Equipment equipment, // worn humanoid armor (players, armor stands, zombies, …); null = none/not a wearer
boolean invisible // entity is invisible -> render only its equipment (like vanilla), not the body
) {
/** Worn armor (4 slots) of a humanoid wearer; any field may be null. */
public record Equipment(EquipPiece head, EquipPiece chest, EquipPiece legs, EquipPiece feet) {
public boolean isEmpty() {
return head == null && chest == null && legs == null && feet == null;
}
}
/** One worn armor piece. */
public record EquipPiece(
String asset, // equipment asset id, e.g. "diamond", "leather", "elytra", "turtle_scute"
int dyeColor, // ARGB tint for dyeable (leather) armor; 0 = undyed/not dyeable
String trimMaterial, // armor-trim material key (e.g. "diamond"); null = no trim
String trimPattern, // armor-trim pattern key (e.g. "coast"); null = no trim
boolean glint // item is enchanted -> render the enchantment glint
) {}
}
@@ -52,4 +52,63 @@ public final class TextureOps {
}
}
}
/**
* Palette-swap recolouring used for armor trims: each (non-transparent) texel of {@code tex} is matched
* to its nearest entry in the grayscale source palette {@code from} (8-colour key, RGB distance) and
* replaced by the same-index colour from the material palette {@code to}, preserving alpha. The vanilla
* trim patterns are authored in those 8 key shades, so this reproduces the runtime palette swap.
*/
public static void paletteSwap(int[][] tex, int[] from, int[] to) {
int n = Math.min(from.length, to.length);
if (n == 0) return;
for (int[] row : tex) {
for (int x = 0; x < row.length; x++) {
int p = row[x];
int a = (p >>> 24) & 0xFF;
if (a == 0) continue;
int pr = (p >> 16) & 0xFF, pg = (p >> 8) & 0xFF, pb = p & 0xFF;
int best = 0, bestD = Integer.MAX_VALUE;
for (int i = 0; i < n; i++) {
int c = from[i];
int dr = pr - ((c >> 16) & 0xFF), dg = pg - ((c >> 8) & 0xFF), db = pb - (c & 0xFF);
int d = dr * dr + dg * dg + db * db;
if (d < bestD) { bestD = d; best = i; }
}
row[x] = (a << 24) | (to[best] & 0xFFFFFF);
}
}
}
/**
* Static approximation of the (animated) enchantment glint: a single glint frame is tiled over the
* texture and additively blended — tinted by {@code glintColor} and scaled by {@code strength} and the
* frame's luminance — onto the texture's opaque texels only (so it sheens the armor, not the
* transparent background). Not animated; just a frozen purple shimmer.
*/
public static void addGlint(int[][] tex, int[][] glint, int glintColor, double strength) {
if (glint == null || glint.length == 0 || glint[0].length == 0) return;
int gh = glint.length, gw = glint[0].length;
int cr = (glintColor >> 16) & 0xFF, cg = (glintColor >> 8) & 0xFF, cb = glintColor & 0xFF;
for (int y = 0; y < tex.length; y++) {
for (int x = 0; x < tex[y].length; x++) {
int p = tex[y][x];
int a = (p >>> 24) & 0xFF;
if (a == 0) continue; // only sheen actual armor pixels
int gp = glint[y % gh][x % gw];
int ga = (gp >>> 24) & 0xFF;
int gl = Math.max((gp >> 16) & 0xFF, Math.max((gp >> 8) & 0xFF, gp & 0xFF));
double inten = strength * (ga / 255.0) * (gl / 255.0);
if (inten <= 0) continue;
int r = clamp(((p >> 16) & 0xFF) + (int) (cr * inten));
int g = clamp(((p >> 8) & 0xFF) + (int) (cg * inten));
int b = clamp((p & 0xFF) + (int) (cb * inten));
tex[y][x] = (a << 24) | (r << 16) | (g << 8) | b;
}
}
}
private static int clamp(int v) {
return v < 0 ? 0 : (Math.min(v, 255));
}
}
@@ -53,9 +53,14 @@ public final class CemBaker implements EntityBaker<EntityState> {
tex = compositeHorse(s, tex); // coat markings + horse armor
} else if (cem.equals("llama")) {
tex = compositeLlama(s, tex); // dyed/trader carpet decor
} else if (cem.equals("nautilus")) {
tex = compositeNautilus(s, tex); // body armor + saddle (same-UV overlays)
}
CemModelLoader.CemModel model = models.get(cem);
if (model == null || tex == null) return fallbackBox(s, tex);
// A visible entity needs its body model+texture; an invisible one renders only its equipment
// (vanilla hides the body but still draws worn armor).
boolean invisible = s.invisible();
if (!invisible && (model == null || tex == null)) return fallbackBox(s, tex);
double sc = (s.baby() ? 0.5 : 1.0) * s.sizeScale();
// CEM model px -> entity-local blocks. Identity orientation (no axis flip) preserves ALL part
@@ -68,31 +73,44 @@ public final class CemBaker implements EntityBaker<EntityState> {
if (cem.equals("donkey")) { hidden.add("left_chest"); hidden.add("right_chest"); }
else if (cem.equals("llama")) { hidden.add("chest_left"); hidden.add("chest_right"); }
}
List<CemGeometry.Baked> baked = new ArrayList<>(CemGeometry.bakeModel(model, tex, pre, hidden));
// Sheep: render the inflated, dye-tinted wool fur layer over the body (transparent where the face shows).
if (s.typeKey().equals("sheep")) {
CemModelLoader.CemModel wool = models.get("sheep_wool");
int[][] woolTex = textures.get(ResourceLocation.parse("entity/sheep/sheep_wool")).orElse(null);
if (wool != null && woolTex != null) {
int[][] t = TextureOps.deepCopy(woolTex);
if (s.tint() != 0) TextureOps.tint(t, s.tint());
baked.addAll(CemGeometry.bakeModel(wool, t, pre, hidden));
}
}
// Guardian: the CEM model ships a RIGHT body side-panel but no left one, and the main body box's
// left face is transparent in the texture → a see-through hole on the left. Add the mirrored left panel.
if (cem.equals("guardian")) {
double[] org = {-8, 2, -6}, size = {2, 12, 12};
ModelCube mc = new ModelCube(org, size, 0, new double[]{0, 28}, true);
Face[] faces = BoxUv.build(mc, tex, model.texW(), model.texH());
baked.add(new CemGeometry.Baked(org, new double[]{org[0]+size[0], org[1]+size[1], org[2]+size[2]}, faces, pre));
}
// Saddle: an extra inflated layer from the *_saddle CEM model, showing only its saddle-specific parts.
if (s.saddle()) addSaddleLayer(s, cem, model, pre, baked);
if (baked.isEmpty()) return fallbackBox(s, tex);
// The body model is baked even when invisible — not drawn, but used as the ground-snap reference
// so equipment stays at body height (e.g. a lone helmet sits at the head, not on the floor).
List<CemGeometry.Baked> body = (model != null && tex != null)
? CemGeometry.bakeModel(model, tex, pre, hidden) : java.util.List.of();
List<CemGeometry.Baked> baked = new ArrayList<>();
if (!invisible) {
baked.addAll(body);
// Sheep: render the inflated, dye-tinted wool fur layer over the body (transparent where the face shows).
if (s.typeKey().equals("sheep")) {
CemModelLoader.CemModel wool = models.get("sheep_wool");
int[][] woolTex = textures.get(ResourceLocation.parse("entity/sheep/sheep_wool")).orElse(null);
if (wool != null && woolTex != null) {
int[][] t = TextureOps.deepCopy(woolTex);
if (s.tint() != 0) TextureOps.tint(t, s.tint());
baked.addAll(CemGeometry.bakeModel(wool, t, pre, hidden));
}
}
// Guardian: the CEM model ships a RIGHT body side-panel but no left one, and the main body box's
// left face is transparent in the texture → a see-through hole on the left. Add the mirrored left panel.
if (cem.equals("guardian")) {
double[] org = {-8, 2, -6}, size = {2, 12, 12};
ModelCube mc = new ModelCube(org, size, 0, new double[]{0, 28}, true);
Face[] faces = BoxUv.build(mc, tex, model.texW(), model.texH());
baked.add(new CemGeometry.Baked(org, new double[]{org[0]+size[0], org[1]+size[1], org[2]+size[2]}, faces, pre));
}
// Saddle: an extra inflated layer from the *_saddle CEM model, showing only its saddle-specific parts.
if (s.saddle()) addSaddleLayer(s, cem, model, pre, baked);
}
// Humanoid armor (players, armor stands, zombies, …): extra inflated armor_layer models. Rendered
// even when invisible, so an invisible armored entity shows floating armor like in vanilla.
if (s.equipment() != null) addArmorLayers(s, pre, baked);
if (baked.isEmpty()) return invisible ? null : fallbackBox(s, tex);
// Ground-snap from the body extent (preserves visible behaviour); fall back to the drawn geometry.
List<CemGeometry.Baked> ref = (invisible && !body.isEmpty()) ? body : baked;
double minY = Double.MAX_VALUE;
for (CemGeometry.Baked b : baked) minY = Math.min(minY, b.minWorldY());
for (CemGeometry.Baked b : ref) minY = Math.min(minY, b.minWorldY());
Affine place = Affine.translation(s.x(), s.y(), s.z())
.mul(Affine.rotY(Math.PI - Math.toRadians(s.bodyYaw())))
@@ -164,6 +182,15 @@ public final class CemBaker implements EntityBaker<EntityState> {
return out;
}
/** Nautilus body armor + saddle: same 128² UV as the nautilus texture, so composited as overlays. */
private int[][] compositeNautilus(EntityState s, int[][] base) {
if (base == null || (s.bodyEquip() == null && !s.saddle())) return base;
int[][] out = TextureOps.deepCopy(base);
if (s.bodyEquip() != null) overlayIfPresent(out, "entity/equipment/nautilus_body/" + s.bodyEquip());
if (s.saddle()) overlayIfPresent(out, "entity/equipment/nautilus_saddle/saddle");
return out;
}
/** Bake the saddle as a separate inflated layer; only the saddle-specific parts (those not in the base model). */
private void addSaddleLayer(EntityState s, String cem, CemModelLoader.CemModel base, Affine pre, List<CemGeometry.Baked> baked) {
String saddleModel = cem.equals("donkey") ? "donkey_saddle" : (cem.equals("horse") ? "horse_saddle" : null);
@@ -178,6 +205,103 @@ public final class CemBaker implements EntityBaker<EntityState> {
baked.addAll(CemGeometry.bakeModel(sm, saddleTex, pre, hideBase));
}
// --- humanoid armor ---------------------------------------------------------------------------
// Vanilla splits worn armor across two inflated layer models that overlay the standard humanoid body:
// armor_layer_1 (texture entity/equipment/humanoid/<mat>): head=helmet, body+arms=chestplate,
// shoes=boots;
// armor_layer_2 (texture entity/equipment/humanoid_leggings/<mat>): waist+legs=leggings.
// Each slot may use a different material, so each is baked separately, showing only its parts.
private static final java.util.Set<String> ARMOR1_HEAD = java.util.Set.of("head");
private static final java.util.Set<String> ARMOR1_CHEST = java.util.Set.of("body", "left_arm", "right_arm");
private static final java.util.Set<String> ARMOR1_FEET = java.util.Set.of("left_shoe", "right_shoe");
private static final java.util.Set<String> ARMOR2_LEGS = java.util.Set.of("waist", "left_leg", "right_leg");
private static final int GLINT_COLOR = 0xFF8040CC; // approximated enchantment-glint purple
private void addArmorLayers(EntityState s, Affine pre, List<CemGeometry.Baked> baked) {
EntityState.Equipment eq = s.equipment();
bakeArmorPiece(eq.head(), "armor_layer_1", ARMOR1_HEAD, "humanoid", pre, baked);
// Chest slot: an elytra renders its own wing model instead of the chestplate layer.
if (eq.chest() != null && eq.chest().asset().equals("elytra")) {
bakeElytra(eq.chest(), pre, baked);
} else {
bakeArmorPiece(eq.chest(), "armor_layer_1", ARMOR1_CHEST, "humanoid", pre, baked);
}
bakeArmorPiece(eq.legs(), "armor_layer_2", ARMOR2_LEGS, "humanoid_leggings", pre, baked);
bakeArmorPiece(eq.feet(), "armor_layer_1", ARMOR1_FEET, "humanoid", pre, baked);
}
/** Bake one armor slot: its layer model with only {@code show} parts, textured for the material. */
private void bakeArmorPiece(EntityState.EquipPiece piece, String modelName, java.util.Set<String> show,
String layerFolder, Affine pre, List<CemGeometry.Baked> baked) {
if (piece == null) return;
CemModelLoader.CemModel model = models.get(modelName);
if (model == null) return;
int[][] tex = buildArmorTexture(piece, layerFolder);
if (tex == null) return;
java.util.Set<String> hidden = new java.util.HashSet<>();
for (CemModelLoader.CemPart p : model.parts()) if (!show.contains(p.name())) hidden.add(p.name());
baked.addAll(CemGeometry.bakeModel(model, tex, pre, hidden));
}
/** Resolve + composite a single armor slot's texture: material base (+leather dye/overlay), trim, glint. */
private int[][] buildArmorTexture(EntityState.EquipPiece piece, String layerFolder) {
String asset = piece.asset();
int[][] base = textures.get(ResourceLocation.parse("entity/equipment/" + layerFolder + "/" + asset)).orElse(null);
if (base == null) return null;
int[][] out = TextureOps.deepCopy(base);
// Leather is dyeable: tint the base layer, then composite the (undyed) overlay layer on top.
if (asset.equals("leather")) {
if (piece.dyeColor() != 0) TextureOps.tint(out, piece.dyeColor());
overlayIfPresent(out, "entity/equipment/" + layerFolder + "/leather_overlay");
}
if (piece.trimMaterial() != null && piece.trimPattern() != null) {
applyTrim(out, layerFolder, piece.trimPattern(), piece.trimMaterial(), asset);
}
if (piece.glint()) applyGlint(out);
return out;
}
/** Armor trim: recolour the grayscale pattern via the material palette (same UV) and overlay it. */
private void applyTrim(int[][] armorTex, String layerFolder, String pattern, String material, String armorAsset) {
int[][] mask = textures.get(ResourceLocation.parse("trims/entity/" + layerFolder + "/" + pattern)).orElse(null);
if (mask == null || mask.length != armorTex.length || mask[0].length != armorTex[0].length) return;
int[] from = palette8("trims/color_palettes/trim_palette");
// Vanilla uses the darker palette variant when the trim material matches the armor material.
String palette = material;
if (material.equals(armorAsset)
&& textures.get(ResourceLocation.parse("trims/color_palettes/" + material + "_darker")).isPresent()) {
palette = material + "_darker";
}
int[] to = palette8("trims/color_palettes/" + palette);
if (from == null || to == null) return;
int[][] colored = TextureOps.deepCopy(mask);
TextureOps.paletteSwap(colored, from, to);
TextureOps.overlay(armorTex, colored);
}
/** First row of an 8×1 trim colour-palette texture; null if missing. */
private int[] palette8(String path) {
int[][] p = textures.get(ResourceLocation.parse(path)).orElse(null);
return (p == null || p.length == 0) ? null : p[0];
}
/** Static enchantment-glint approximation over an armor/elytra/item texture (in place). */
private void applyGlint(int[][] tex) {
textures.get(ResourceLocation.parse("misc/enchanted_glint_armor"))
.ifPresent(glint -> TextureOps.addGlint(tex, glint, GLINT_COLOR, 0.6));
}
/** Elytra in the chest slot: bake the wing model with the default elytra texture (no chestplate). */
private void bakeElytra(EntityState.EquipPiece piece, Affine pre, List<CemGeometry.Baked> baked) {
CemModelLoader.CemModel model = models.get("elytra");
if (model == null) return;
int[][] tex = textures.get(ResourceLocation.parse("entity/equipment/wings/elytra")).orElse(null);
if (tex == null) return;
int[][] out = TextureOps.deepCopy(tex);
if (piece.glint()) applyGlint(out);
baked.addAll(CemGeometry.bakeModel(model, out, pre, java.util.Set.of()));
}
private RenderedEntity fallbackBox(EntityState s, int[][] tex) {
double w = Math.max(0.3, s.width()) * 16 * s.sizeScale(), h = Math.max(0.3, s.height()) * 16 * s.sizeScale();
double[] from = {-w / 2, 0, -w / 2};
@@ -33,6 +33,15 @@ public final class EntitySnapshotBuilder {
"potion", "ender_pearl", "tnt", "falling_block", "item"
);
// Entities whose vanilla renderer draws the humanoid armor layers (HumanoidArmorLayer) and held
// items. Their CEM bodies share standard humanoid proportions, so the armor_layer_1/2 models align.
private static final java.util.Set<String> HUMANOID_ARMOR_WEARERS = java.util.Set.of(
"player", "mannequin", "armor_stand", "giant",
"zombie", "husk", "drowned", "zombie_villager", "zombified_piglin",
"skeleton", "stray", "wither_skeleton", "bogged",
"piglin", "piglin_brute"
);
public static List<EntityState> build(Location eye, List<Vector> rayMap, double maxDistance, UUID shooter) {
FrustumBounds bounds = FrustumBounds.of(eye.toVector(), rayMap, maxDistance);
Collection<Entity> nearby = bounds.nearbyEntities(eye.getWorld(), 2);
@@ -58,7 +67,13 @@ public final class EntitySnapshotBuilder {
}
boolean baby = (e instanceof Ageable a && !a.isAdult())
|| (e instanceof org.bukkit.entity.Zombie z && z.isBaby());
|| (e instanceof org.bukkit.entity.Zombie z && z.isAdult());
// Invisible entities render only their equipment (like vanilla): the generic invisible flag, an
// invisibility potion effect, or an explicitly-hidden armor stand.
boolean invisible = e.isInvisible()
|| (e instanceof org.bukkit.entity.ArmorStand as && !as.isVisible())
|| (e instanceof LivingEntity inv && inv.hasPotionEffect(org.bukkit.potion.PotionEffectType.INVISIBILITY));
double width = safeDim(e::getWidth, () -> e.getBoundingBox().getWidthX());
double height = safeDim(e::getHeight, () -> e.getBoundingBox().getHeight());
@@ -114,6 +129,14 @@ public final class EntitySnapshotBuilder {
} else if (e instanceof org.bukkit.entity.AbstractHorse ah) {
// Skeleton/zombie horse: only saddle (no colour/markings/armor variants).
saddle = isSaddled(ah);
} else if (e instanceof org.bukkit.entity.AbstractNautilus && e instanceof LivingEntity nl) {
// Nautilus body armor + saddle are same-UV overlays (like horse armor).
org.bukkit.inventory.EntityEquipment eq = nl.getEquipment();
if (eq != null) {
bodyEquip = equipAsset(eq.getItem(org.bukkit.inventory.EquipmentSlot.BODY));
org.bukkit.inventory.ItemStack sd = eq.getItem(org.bukkit.inventory.EquipmentSlot.SADDLE);
saddle = sd != null && !sd.getType().isAir();
}
} else if (e instanceof org.bukkit.entity.Fox f) {
variant = keyOf(f.getFoxType());
} else if (e instanceof org.bukkit.entity.MushroomCow mc) {
@@ -143,10 +166,62 @@ public final class EntitySnapshotBuilder {
// Unsupported on this server version — fall back to the base texture.
}
EntityState.Equipment equipment = null;
if (HUMANOID_ARMOR_WEARERS.contains(type) && e instanceof LivingEntity wearer) {
equipment = captureEquipment(wearer);
}
return new EntityState(type, loc.getX(), loc.getY(), loc.getZ(),
bodyYaw, baby, width, height,
player, skinUrl, slim, variant, tint, sizeScale, profession, villagerLevel,
markings, saddle, chest, bodyEquip);
markings, saddle, chest, bodyEquip, equipment, invisible);
}
/** Worn armor (4 slots) from a humanoid wearer; null when nothing is equipped. */
private static EntityState.Equipment captureEquipment(LivingEntity le) {
try {
org.bukkit.inventory.EntityEquipment eq = le.getEquipment();
if (eq == null) return null;
EntityState.Equipment equip = new EntityState.Equipment(
armorPiece(eq.getHelmet()), armorPiece(eq.getChestplate()),
armorPiece(eq.getLeggings()), armorPiece(eq.getBoots()));
return equip.isEmpty() ? null : equip;
} catch (Throwable t) {
return null;
}
}
/** One armor slot -> EquipPiece (asset, leather dye, trim, glint); null for empty / non-armor items. */
private static EntityState.EquipPiece armorPiece(org.bukkit.inventory.ItemStack it) {
if (it == null || it.getType().isAir()) return null;
String asset = armorAsset(it.getType());
if (asset == null) return null; // not a humanoid-armor item (e.g. a mob head / pumpkin)
int dye = 0;
String trimMat = null, trimPat = null;
boolean glint = false;
if (it.hasItemMeta()) {
org.bukkit.inventory.meta.ItemMeta meta = it.getItemMeta();
glint = meta.hasEnchants();
if (meta instanceof org.bukkit.inventory.meta.LeatherArmorMeta lam) dye = lam.getColor().asARGB();
if (meta instanceof org.bukkit.inventory.meta.ArmorMeta am && am.getTrim() != null) {
trimMat = keyOf(am.getTrim().getMaterial());
trimPat = keyOf(am.getTrim().getPattern());
}
}
return new EntityState.EquipPiece(asset, dye, trimMat, trimPat, glint);
}
/** Item Material -> equipment asset id (= texture name): strips the slot suffix; null if not armor. */
private static String armorAsset(org.bukkit.Material m) {
String key = m.getKey().getKey();
if (key.equals("elytra")) return "elytra";
if (key.equals("turtle_helmet")) return "turtle_scute";
String base = null;
for (String suf : new String[]{"_helmet", "_chestplate", "_leggings", "_boots"}) {
if (key.endsWith(suf)) { base = key.substring(0, key.length() - suf.length()); break; }
}
if (base == null) return null;
return base.equals("golden") ? "gold" : base;
}
/** Horse coat markings overlay key (vanilla texture suffix); null for the plain NONE style. */
@@ -171,6 +246,17 @@ public final class EntitySnapshotBuilder {
return k.endsWith("_carpet") ? k.substring(0, k.length() - "_carpet".length()) : null;
}
/** Equipment asset id (= equipment/<asset> texture name) from an item's EQUIPPABLE component; null if none. */
private static String equipAsset(org.bukkit.inventory.ItemStack it) {
if (it == null || it.getType().isAir()) return null;
try {
var comp = it.getData(io.papermc.paper.datacomponent.DataComponentTypes.EQUIPPABLE);
if (comp != null && comp.assetId() != null) return comp.assetId().value();
} catch (Throwable ignored) {
}
return null;
}
/** Whether a horse-like mount carries a saddle in its dedicated saddle slot. */
private static boolean isSaddled(org.bukkit.entity.AbstractHorse h) {
org.bukkit.inventory.ItemStack st = h.getInventory().getSaddle();
@@ -0,0 +1,149 @@
package eu.mhsl.minecraft.pixelpics.survival;
import com.destroystokyo.paper.profile.PlayerProfile;
import com.destroystokyo.paper.profile.ProfileProperty;
import eu.mhsl.minecraft.pixelpics.Main;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextDecoration;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.inventory.meta.SkullMeta;
import org.bukkit.inventory.meta.components.EquippableComponent;
import org.bukkit.persistence.PersistentDataContainer;
import org.bukkit.persistence.PersistentDataType;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.UUID;
/**
* Factory and identity helpers for the survival items: the camera and the film roll are custom-skinned
* {@code PLAYER_HEAD}s (the texture renders client-side without a resource pack), the photo is the
* existing {@code FILLED_MAP}. Identity is carried by PDC markers (robust against renames), and the
* camera's loaded film count lives in PDC as well.
*/
public final class CameraItems {
/** Maximum film rolls a camera can hold; one roll yields one photo. */
public static final int MAX_FILM = 8;
private static final int CAMERA_MODEL_DATA = 1001;
private static final int FILM_MODEL_DATA = 1002;
static final String CAMERA_TEXTURE_B64 = "eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvZDlkMmNiZjAyZDMwOGI2MDY1YTZmZThjNjU3MWI2MzU2NjMzZjQxOTJlOGVjNzEyMTNjNzcwNzgwZTNkZTRlMiJ9fX0=";
static final String FILM_TEXTURE_B64 = "eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvMTVkMGY0OGJlNzNkYmIwZDJjYjE1NTRjMmUzODZiNWNjM2FiMjFhNGRjYWU4ZmYzOGI3NzRhZDNkMDFkMGE1OSJ9fX0=";
private CameraItems() {}
// --- factories ---
/** A camera holding {@code filmCount} (clamped to 0..{@link #MAX_FILM}) loaded film rolls. */
public static ItemStack createCamera(int filmCount) {
int count = Math.clamp(filmCount, 0, MAX_FILM);
ItemStack item = new ItemStack(Material.PLAYER_HEAD);
SkullMeta meta = (SkullMeta) item.getItemMeta();
applyHead(meta, "pixelpics:camera", CAMERA_TEXTURE_B64);
meta.displayName(Component.text("Kamera", NamedTextColor.AQUA)
.decoration(TextDecoration.ITALIC, false));
meta.setCustomModelData(CAMERA_MODEL_DATA);
meta.getPersistentDataContainer().set(Main.getInstance().cameraMarker, PersistentDataType.BYTE, (byte) 1);
meta.getPersistentDataContainer().set(Main.getInstance().filmCountKey, PersistentDataType.INTEGER, count);
applyCameraLore(meta, count);
makeUnwearable(meta);
item.setItemMeta(meta);
return item;
}
/** A single film roll. */
public static ItemStack createFilm() {
ItemStack item = new ItemStack(Material.PLAYER_HEAD);
SkullMeta meta = (SkullMeta) item.getItemMeta();
applyHead(meta, "pixelpics:film", FILM_TEXTURE_B64);
meta.displayName(Component.text("Filmrolle", NamedTextColor.GREEN)
.decoration(TextDecoration.ITALIC, false));
meta.setCustomModelData(FILM_MODEL_DATA);
meta.lore(List.of(Component.text("Lädt eine Kamera auf.", NamedTextColor.GRAY)
.decoration(TextDecoration.ITALIC, false)));
meta.getPersistentDataContainer().set(Main.getInstance().filmMarker, PersistentDataType.BYTE, (byte) 1);
makeUnwearable(meta);
item.setItemMeta(meta);
return item;
}
/** Returns a copy of {@code camera} with its film count set to {@code newCount} and lore refreshed. */
public static ItemStack withFilmCount(ItemStack camera, int newCount) {
int count = Math.max(0, Math.min(MAX_FILM, newCount));
ItemStack copy = camera.clone();
SkullMeta meta = (SkullMeta) copy.getItemMeta();
meta.getPersistentDataContainer().set(Main.getInstance().filmCountKey, PersistentDataType.INTEGER, count);
applyCameraLore(meta, count);
copy.setItemMeta(meta);
return copy;
}
// --- identity ---
public static boolean isCamera(ItemStack item) {
return hasMarker(item, Main.getInstance().cameraMarker);
}
public static boolean isFilm(ItemStack item) {
return hasMarker(item, Main.getInstance().filmMarker);
}
/** A photo is a filled map carrying our picture id. */
public static boolean isPhoto(ItemStack item) {
if (item == null || item.getType() != Material.FILLED_MAP || !item.hasItemMeta()) return false;
return item.getItemMeta().getPersistentDataContainer()
.has(Main.getInstance().pictureIdFlag, PersistentDataType.STRING);
}
/** Loaded film on a camera, or 0 if the item is not a camera. */
public static int getFilmCount(ItemStack item) {
if (!isCamera(item)) return 0;
Integer v = item.getItemMeta().getPersistentDataContainer()
.get(Main.getInstance().filmCountKey, PersistentDataType.INTEGER);
return v == null ? 0 : v;
}
// --- internals ---
private static boolean hasMarker(ItemStack item, org.bukkit.NamespacedKey key) {
if (item == null || item.getType() != Material.PLAYER_HEAD || !item.hasItemMeta()) return false;
PersistentDataContainer pdc = item.getItemMeta().getPersistentDataContainer();
return pdc.has(key, PersistentDataType.BYTE);
}
private static void applyCameraLore(ItemMeta meta, int count) {
meta.lore(List.of(
Component.text("Film: " + count + " / " + MAX_FILM,
count > 0 ? NamedTextColor.YELLOW : NamedTextColor.RED).decoration(TextDecoration.ITALIC, false),
Component.text("Rechtsklick: Foto aufnehmen", NamedTextColor.DARK_GRAY)
.decoration(TextDecoration.ITALIC, false)
));
}
/**
* Overrides the {@code PLAYER_HEAD}'s default head-equippable component so the item cannot be worn:
* the slot is moved to {@code SADDLE} (no player slot accepts it, so the helmet slot rejects it —
* blocking right-click, shift-click and dispenser equipping) and right-click swap is disabled.
*/
private static void makeUnwearable(ItemMeta meta) {
EquippableComponent eq = meta.getEquippable();
eq.setSlot(EquipmentSlot.SADDLE);
eq.setSwappable(false);
meta.setEquippable(eq);
}
private static void applyHead(SkullMeta meta, String seed, String textureB64) {
if (textureB64 == null || textureB64.isBlank()) return;
UUID synthetic = UUID.nameUUIDFromBytes(seed.getBytes(StandardCharsets.UTF_8));
PlayerProfile profile = Bukkit.createProfileExact(synthetic, null);
profile.setProperty(new ProfileProperty("textures", textureB64));
meta.setPlayerProfile(profile);
}
}
@@ -0,0 +1,59 @@
package eu.mhsl.minecraft.pixelpics.survival;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Sound;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.block.Action;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.inventory.ItemStack;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
* Takes a photo when a player right-clicks while holding a camera. Consumes one loaded film per shot;
* with no film loaded it gives a short fail feedback. Guards against the well-known double-fire of
* {@link PlayerInteractEvent} (off-hand fire + air/block) via a hand filter and a per-player cooldown.
*/
public class CameraListener implements Listener {
private static final long COOLDOWN_MILLIS = 500;
private final Map<UUID, Long> lastUse = new HashMap<>();
@EventHandler
public void onInteract(PlayerInteractEvent event) {
if (event.getHand() != EquipmentSlot.HAND) return;
Action action = event.getAction();
if (action != Action.RIGHT_CLICK_AIR && action != Action.RIGHT_CLICK_BLOCK) return;
ItemStack inHand = event.getItem();
if (!CameraItems.isCamera(inHand)) return;
Player player = event.getPlayer();
long now = System.currentTimeMillis();
if (now - lastUse.getOrDefault(player.getUniqueId(), 0L) < COOLDOWN_MILLIS) {
event.setCancelled(true);
return;
}
lastUse.put(player.getUniqueId(), now);
event.setCancelled(true);
int film = CameraItems.getFilmCount(inHand);
if (film <= 0) {
// Dry "empty shutter" click — no film loaded.
player.playSound(player.getLocation(), Sound.BLOCK_DISPENSER_FAIL, 1f, 1.2f);
player.sendActionBar(Component.text("Kein Film geladen!", NamedTextColor.RED));
return;
}
// Consume one film and update the in-hand camera (count + lore) before the shot.
player.getInventory().setItemInMainHand(CameraItems.withFilmCount(inHand, film - 1));
PhotoService.takePhoto(player);
}
}
@@ -0,0 +1,170 @@
package eu.mhsl.minecraft.pixelpics.survival;
import eu.mhsl.minecraft.pixelpics.Main;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.Keyed;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.Sound;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.inventory.CraftItemEvent;
import org.bukkit.event.inventory.PrepareItemCraftEvent;
import org.bukkit.inventory.CraftingInventory;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.Recipe;
import org.bukkit.inventory.meta.MapMeta;
import org.bukkit.persistence.PersistentDataType;
import java.util.function.Predicate;
/**
* Drives the two dynamic recipes whose inputs carry variable NBT: <em>load film</em>
* (camera + film → camera+1) and <em>copy photo</em> (photo + film → 2 photos sharing the same map).
* {@link PrepareItemCraftEvent} computes the result preview and validates the loose
* {@code MaterialChoice} matches; {@link CraftItemEvent} performs the consumption/doubling manually
* (the vanilla machinery cannot, given the variable-NBT ingredients) and blocks shift-click and any
* attempt to leak our custom items into other recipes.
*/
public class CraftingListener implements Listener {
private enum Kind { LOAD, COPY, NONE }
/** Scanned crafting grid: the relevant items and whether any of our marked items are present. */
private record Scan(Kind kind, ItemStack camera, ItemStack film, ItemStack photo, boolean hasOurItems) {}
@EventHandler
public void onPrepare(PrepareItemCraftEvent event) {
CraftingInventory inv = event.getInventory();
Scan scan = scan(inv.getMatrix());
switch (scan.kind()) {
case LOAD -> {
int count = CameraItems.getFilmCount(scan.camera());
inv.setResult(count >= CameraItems.MAX_FILM
? null
: CameraItems.withFilmCount(scan.camera(), count + 1));
}
case COPY -> inv.setResult(buildPhotoCopy(scan.photo()));
case NONE -> {
// Block our loose MaterialChoice recipes from matching garbage, and stop our items
// from being consumed by any unrelated (vanilla) recipe.
if (isDynamicRecipe(event.getRecipe()) || scan.hasOurItems()) {
inv.setResult(null);
}
}
}
}
@EventHandler
public void onCraft(CraftItemEvent event) {
CraftingInventory inv = event.getInventory();
Scan scan = scan(inv.getMatrix());
if (scan.kind() == Kind.NONE) {
// Defensive: never let our items be consumed by another recipe.
if (scan.hasOurItems()) event.setCancelled(true);
return;
}
event.setCancelled(true);
Player player = (Player) event.getWhoClicked();
if (event.isShiftClick()) {
player.sendActionBar(Component.text("Bitte einzeln herstellen.", NamedTextColor.YELLOW));
return;
}
ItemStack result;
ItemStack[] matrix = inv.getMatrix();
if (scan.kind() == Kind.LOAD) {
int count = CameraItems.getFilmCount(scan.camera());
if (count >= CameraItems.MAX_FILM) return;
result = CameraItems.withFilmCount(scan.camera(), count + 1);
consumeFirst(matrix, CameraItems::isCamera);
consumeFirst(matrix, CameraItems::isFilm);
} else { // COPY
result = buildPhotoCopy(scan.photo());
if (result == null) return;
consumeFirst(matrix, CameraItems::isFilm); // original photo stays
}
// Apply on the next tick so the click settles before we mutate the grid and hand over the item.
ItemStack finalResult = result;
Bukkit.getScheduler().runTask(Main.getInstance(), () -> {
inv.setMatrix(matrix);
give(player, finalResult);
player.playSound(player.getLocation(), Sound.UI_TOAST_IN, 0.7f, 1.4f);
player.updateInventory();
});
}
// --- helpers ---
private static Scan scan(ItemStack[] matrix) {
int cameras = 0, films = 0, photos = 0, others = 0;
ItemStack camera = null, film = null, photo = null;
for (ItemStack it : matrix) {
if (it == null || it.getType().isAir()) continue;
if (CameraItems.isCamera(it)) { cameras++; camera = it; }
else if (CameraItems.isFilm(it)) { films++; film = it; }
else if (CameraItems.isPhoto(it)) { photos++; photo = it; }
else others++;
}
boolean ours = cameras + films + photos > 0;
Kind kind = Kind.NONE;
if (others == 0) {
if (cameras == 1 && films == 1 && photos == 0) kind = Kind.LOAD;
else if (photos == 1 && films == 1 && cameras == 0) kind = Kind.COPY;
}
return new Scan(kind, camera, film, photo, ours);
}
private static boolean isDynamicRecipe(Recipe recipe) {
if (!(recipe instanceof Keyed keyed)) return false;
NamespacedKey key = keyed.getKey();
return SurvivalRecipes.LOAD.equals(key) || SurvivalRecipes.COPY.equals(key);
}
/** A second photo referencing the same {@link MapView} and picture id as {@code photo}. */
private static ItemStack buildPhotoCopy(ItemStack photo) {
MapMeta src = (MapMeta) photo.getItemMeta();
if (!src.hasMapView() || src.getMapView() == null) return null;
ItemStack copy = new ItemStack(Material.FILLED_MAP);
MapMeta dst = (MapMeta) copy.getItemMeta();
dst.setMapView(src.getMapView());
String pid = src.getPersistentDataContainer()
.get(Main.getInstance().pictureIdFlag, PersistentDataType.STRING);
if (pid != null) {
dst.getPersistentDataContainer()
.set(Main.getInstance().pictureIdFlag, PersistentDataType.STRING, pid);
}
copy.setItemMeta(dst);
return copy;
}
private static void consumeFirst(ItemStack[] matrix, Predicate<ItemStack> pred) {
for (int i = 0; i < matrix.length; i++) {
ItemStack it = matrix[i];
if (it != null && !it.getType().isAir() && pred.test(it)) {
int amt = it.getAmount();
if (amt <= 1) matrix[i] = null;
else { it.setAmount(amt - 1); matrix[i] = it; }
return;
}
}
}
private static void give(Player player, ItemStack item) {
ItemStack cursor = player.getItemOnCursor();
if (cursor.getType().isAir()) {
player.setItemOnCursor(item);
} else {
player.getInventory().addItem(item).values()
.forEach(left -> player.getWorld().dropItemNaturally(player.getLocation(), left));
}
}
}
@@ -0,0 +1,14 @@
package eu.mhsl.minecraft.pixelpics.survival;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
/** Unlocks the survival recipes in each player's recipe book on join (idempotent). */
public class JoinListener implements Listener {
@EventHandler
public void onJoin(PlayerJoinEvent event) {
event.getPlayer().discoverRecipes(SurvivalRecipes.allKeys());
}
}
@@ -0,0 +1,103 @@
package eu.mhsl.minecraft.pixelpics.survival;
import eu.mhsl.minecraft.pixelpics.Main;
import eu.mhsl.minecraft.pixelpics.render.render.DefaultScreenRenderer;
import eu.mhsl.minecraft.pixelpics.render.render.RenderJob;
import eu.mhsl.minecraft.pixelpics.render.render.Resolution;
import eu.mhsl.minecraft.pixelpics.utils.ImageMapRenderer;
import eu.mhsl.minecraft.pixelpics.utils.MapImageDither;
import eu.mhsl.minecraft.pixelpics.utils.MapManager;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Particle;
import org.bukkit.Sound;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.MapMeta;
import org.bukkit.map.MapView;
import org.bukkit.persistence.PersistentDataType;
import org.bukkit.util.Vector;
import java.awt.image.BufferedImage;
import java.util.UUID;
/**
* Renders a photo of a player's current view and delivers it as a developing {@code FILLED_MAP},
* with action-bar feedback, a shutter sound + flash particles on capture, and a chime when the
* picture finishes developing. Shared by the camera item (survival) and the {@code /pixelPic}
* debug command. Film accounting is the caller's responsibility — this method never touches film.
*/
public final class PhotoService {
private PhotoService() {}
/** Captures and delivers a photo. Returns {@code false} if the renderer is unavailable. */
public static boolean takePhoto(Player player) {
Main plugin = Main.getInstance();
DefaultScreenRenderer renderer = plugin.getScreenRenderer();
if (renderer == null) {
player.sendActionBar(Component.text("PixelPics ist nicht einsatzbereit (kein Resource-Pack).",
NamedTextColor.RED));
return false;
}
Resolution resolution = new Resolution(Resolution.Pixels._128P, Resolution.AspectRatio._1_1);
// Capture the world snapshot on the main thread.
RenderJob job = renderer.prepare(player.getEyeLocation(), resolution, player.getUniqueId());
// Hand the map over immediately, showing blank "film"; it develops once the render is ready.
ImageMapRenderer mapRenderer = new ImageMapRenderer();
MapView mapView = Bukkit.createMap(player.getWorld());
int id = mapView.getId();
MapManager.attachView(mapView, mapRenderer);
ItemStack map = new ItemStack(Material.FILLED_MAP, 1);
MapMeta meta = (MapMeta) map.getItemMeta();
meta.getPersistentDataContainer().set(plugin.pictureIdFlag,
PersistentDataType.STRING, UUID.randomUUID().toString());
meta.setMapView(mapView);
map.setItemMeta(meta);
player.getInventory().addItem(map);
// Feedback is sound + particles only; no action-bar text on a normal shot.
playShutter(player);
// Trace + dither off-thread, then start the developing animation on the main thread.
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
BufferedImage image;
byte[] indices;
try {
image = renderer.execute(job);
indices = MapImageDither.dither(image);
} catch (Exception e) {
plugin.getLogger().warning("Render failed: " + e.getMessage());
Bukkit.getScheduler().runTask(plugin, () ->
player.sendActionBar(Component.text("Rendern fehlgeschlagen.", NamedTextColor.RED)));
return;
}
BufferedImage finalImage = image;
byte[] finalIndices = indices;
Bukkit.getScheduler().runTask(plugin, () -> {
MapManager.saveImage(finalImage, id);
MapManager.saveIndices(finalIndices, id);
mapRenderer.develop(finalIndices);
player.playSound(player.getLocation(), Sound.BLOCK_AMETHYST_BLOCK_CHIME, 0.8f, 1.2f);
});
});
return true;
}
private static void playShutter(Player player) {
player.playSound(player.getLocation(), Sound.BLOCK_VAULT_OPEN_SHUTTER, 1f, 1.4f);
Location eye = player.getEyeLocation();
Vector forward = eye.getDirection().normalize().multiply(1.0);
Location flash = eye.clone().add(forward);
player.getWorld().spawnParticle(Particle.END_ROD, flash, 12, 0.2, 0.2, 0.2, 0.01);
player.getWorld().spawnParticle(Particle.FIREWORK, flash, 6, 0.1, 0.1, 0.1, 0.02);
}
}
@@ -0,0 +1,78 @@
package eu.mhsl.minecraft.pixelpics.survival;
import eu.mhsl.minecraft.pixelpics.Main;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.RecipeChoice;
import org.bukkit.inventory.ShapedRecipe;
import org.bukkit.inventory.ShapelessRecipe;
import java.util.List;
/**
* Registers the four survival recipes and makes them discoverable in the recipe book. Camera and
* film have fixed results; load-film and copy-photo use {@code MaterialChoice} ingredients (their
* inputs carry variable NBT) and their real results are computed dynamically in {@link CraftingListener}
* — the registered result here is only the book/preview icon.
*/
public final class SurvivalRecipes {
public static final NamespacedKey CAMERA = key("camera_recipe");
public static final NamespacedKey FILM = key("film_recipe");
public static final NamespacedKey LOAD = key("load_recipe");
public static final NamespacedKey COPY = key("copy_recipe");
private SurvivalRecipes() {}
private static NamespacedKey key(String name) {
return new NamespacedKey(Main.getInstance(), name);
}
public static List<NamespacedKey> allKeys() {
return List.of(CAMERA, FILM, LOAD, COPY);
}
public static void register() {
unregister();
// Camera
ShapedRecipe camera = new ShapedRecipe(CAMERA, CameraItems.createCamera(0));
camera.shape("IDI", "GLG", "IRI");
camera.setIngredient('I', Material.IRON_INGOT);
camera.setIngredient('D', Material.DIAMOND);
camera.setIngredient('G', Material.GLASS_PANE);
camera.setIngredient('L', Material.ENDER_EYE);
camera.setIngredient('R', Material.REDSTONE);
Bukkit.addRecipe(camera, false);
// Film roll
ShapedRecipe film = new ShapedRecipe(FILM, CameraItems.createFilm());
film.shape(" P ", "PIP", " P ");
film.setIngredient('P', Material.PAPER);
film.setIngredient('I', Material.INK_SAC);
Bukkit.addRecipe(film, false);
// Load film: camera + film
ShapelessRecipe load = new ShapelessRecipe(LOAD, CameraItems.createCamera(1));
load.addIngredient(new RecipeChoice.MaterialChoice(Material.PLAYER_HEAD));
load.addIngredient(new RecipeChoice.MaterialChoice(Material.PLAYER_HEAD));
Bukkit.addRecipe(load, false);
// Copy photo: photo + film
ShapelessRecipe copy = new ShapelessRecipe(COPY, new ItemStack(Material.FILLED_MAP));
copy.addIngredient(new RecipeChoice.MaterialChoice(Material.FILLED_MAP));
copy.addIngredient(new RecipeChoice.MaterialChoice(Material.PLAYER_HEAD));
Bukkit.addRecipe(copy, false);
// Cover /reload while players are online; fresh joins are handled by JoinListener.
Bukkit.getOnlinePlayers().forEach(p -> p.discoverRecipes(allKeys()));
}
public static void unregister() {
for (NamespacedKey k : allKeys()) {
Bukkit.removeRecipe(k, false);
}
}
}
+2 -2
View File
@@ -8,8 +8,8 @@ commands:
usage: "/pixelPic [cleanup [confirm] [days]]"
permissions:
pixelpic.use:
description: "Allows taking PixelPics camera screenshots"
description: "Allows running /pixelPic (the render branch itself requires pixelpic.admin)"
default: true
pixelpic.admin:
description: "Allows managing PixelPics (e.g. cleanup)"
description: "Allows the /pixelPic debug render (no camera/film) and cleanup management"
default: op
+39 -4
View File
@@ -62,12 +62,41 @@ public class EntityTestRender {
// Horse/llama/donkey equipment for the standalone render.
static final Map<String, String> MARK = Map.of("horse", "blackdots"); // coat markings
static final java.util.Set<String> SADDLE = java.util.Set.of("horse", "donkey", "mule");
static final java.util.Set<String> SADDLE = java.util.Set.of("horse", "donkey", "mule", "nautilus");
static final java.util.Set<String> CHEST = java.util.Set.of("llama", "donkey");
static final Map<String, String> EQUIP = Map.ofEntries( // armor / carpet
Map.entry("horse", "diamond"), Map.entry("llama", "red"), Map.entry("trader_llama", "trader_llama")
Map.entry("horse", "diamond"), Map.entry("llama", "red"), Map.entry("trader_llama", "trader_llama"),
Map.entry("nautilus", "diamond")
);
// Humanoid armor test cases (worn equipment, trims, glint, elytra).
static EntityState.EquipPiece P(String asset) { return new EntityState.EquipPiece(asset, 0, null, null, false); }
static EntityState.EquipPiece P(String asset, int dye, String trimMat, String trimPat, boolean glint) {
return new EntityState.EquipPiece(asset, dye, trimMat, trimPat, glint);
}
static final Map<String, EntityState.Equipment> ARMOR = new HashMap<>();
static {
// Full diamond armor.
ARMOR.put("skeleton", new EntityState.Equipment(P("diamond"), P("diamond"), P("diamond"), P("diamond")));
// Dyed (orange) leather armor.
EntityState.EquipPiece le = P("leather", 0xFFFF8000, null, null, false);
ARMOR.put("zombie", new EntityState.Equipment(le, le, le, le));
// Iron armor with a coast/diamond trim.
EntityState.EquipPiece ir = P("iron", 0, "diamond", "coast", false);
ARMOR.put("armor_stand", new EntityState.Equipment(ir, ir, ir, ir));
// Enchanted netherite armor (glint).
EntityState.EquipPiece ne = P("netherite", 0, null, null, true);
ARMOR.put("wither_skeleton", new EntityState.Equipment(ne, ne, ne, ne));
// Enchanted diamond helmet + elytra in the chest slot.
ARMOR.put("player", new EntityState.Equipment(P("diamond", 0, null, null, true), P("elytra"), null, null));
// Gold helmet.
ARMOR.put("piglin", new EntityState.Equipment(P("gold"), null, null, null));
}
// Invisible test cases: armor_stand -> only its (trimmed iron) armor floats; creeper -> nothing renders.
static final java.util.Set<String> INVISIBLE = java.util.Set.of("armor_stand", "creeper");
public static void main(String[] args) throws Exception {
Logger log = Logger.getLogger("test");
ResourcePack pack = ResourcePackLoader.load(new File(ROOT, "resourcepack"), log).orElseThrow();
@@ -80,7 +109,9 @@ public class EntityTestRender {
log.info("Loaded " + n + " geometries");
SkinCache skins = new SkinCache();
eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker baker = new eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker(geo, textures, skins);
eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker beBaker = new eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker(geo, textures, skins);
eu.mhsl.minecraft.pixelpics.assets.font.BitmapFont font =
eu.mhsl.minecraft.pixelpics.assets.font.FontLoader.load(pack, textures, log);
eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker beBaker = new eu.mhsl.minecraft.pixelpics.render.entity.cem.BlockEntityBaker(geo, textures, skins, font);
DefaultScreenRenderer renderer = new DefaultScreenRenderer(registry, tint, textures, baker, beBaker, log);
BlockData air = (BlockData) Proxy.newProxyInstance(EntityTestRender.class.getClassLoader(),
@@ -144,8 +175,12 @@ public class EntityTestRender {
boolean isPlayer = key.equals("player");
EntityState s = new EntityState(key, 0, 0, 0, yaw, false, 0.8, 1.0,
isPlayer, null, false, VAR.get(key), 0, 1.0, PROF.get(key), LVL.getOrDefault(key, 0),
MARK.get(key), SADDLE.contains(key), CHEST.contains(key), EQUIP.get(key));
MARK.get(key), SADDLE.contains(key), CHEST.contains(key), EQUIP.get(key), ARMOR.get(key),
INVISIBLE.contains(key));
RenderedEntity re = baker.bake(s);
if (re == null || re.cubes.isEmpty()) { // invisible with no equipment -> nothing renders
return new BufferedImage(TW, TH, BufferedImage.TYPE_INT_RGB);
}
double cx = (re.aabbMin[0] + re.aabbMax[0]) / 2;
double cy = (re.aabbMin[1] + re.aabbMax[1]) / 2;
double cz = (re.aabbMin[2] + re.aabbMax[2]) / 2;