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"); 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 @Override
public void onEnable() { public void onEnable() {
instance = this; instance = this;
@@ -33,6 +40,11 @@ public final class Main extends JavaPlugin {
Bukkit.getPluginManager().registerEvents(new OnMapInitialize(), this); Bukkit.getPluginManager().registerEvents(new OnMapInitialize(), this);
Objects.requireNonNull(Bukkit.getPluginCommand("pixelPic")).setExecutor(new PixelPicsCommand()); 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(); initRenderer();
} }
@@ -81,6 +93,7 @@ public final class Main extends JavaPlugin {
@Override @Override
public void onDisable() { public void onDisable() {
eu.mhsl.minecraft.pixelpics.survival.SurvivalRecipes.unregister();
if (resourcePack != null) { if (resourcePack != null) {
resourcePack.close(); resourcePack.close();
resourcePack = null; resourcePack = null;
@@ -1,30 +1,17 @@
package eu.mhsl.minecraft.pixelpics.commands; package eu.mhsl.minecraft.pixelpics.commands;
import eu.mhsl.minecraft.pixelpics.Main; import eu.mhsl.minecraft.pixelpics.survival.PhotoService;
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 eu.mhsl.minecraft.pixelpics.utils.MapManager;
import net.kyori.adventure.text.Component; import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.command.Command; import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender; import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player; 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 org.jetbrains.annotations.NotNull;
import java.awt.image.BufferedImage;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.UUID;
public class PixelPicsCommand implements CommandExecutor { public class PixelPicsCommand implements CommandExecutor {
@@ -44,57 +31,14 @@ public class PixelPicsCommand implements CommandExecutor {
} }
if (args.length > 0) return false; if (args.length > 0) return false;
DefaultScreenRenderer renderer = Main.getInstance().getScreenRenderer(); // Debug shortcut: render a photo from the player's view without needing a camera or film.
if (renderer == null) { if (!player.hasPermission("pixelpic.admin")) {
player.sendMessage(Component.text("PixelPics ist nicht einsatzbereit: es wurde kein Resource-Pack geladen.", player.sendActionBar(Component.text("Dafür fehlt dir die Berechtigung — nutze eine Kamera.",
NamedTextColor.RED)); NamedTextColor.RED));
return true; return true;
} }
Resolution resolution = new Resolution(Resolution.Pixels._128P, Resolution.AspectRatio._1_1); PhotoService.takePhoto(player);
// 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));
});
});
return true; return true;
} }
@@ -19,5 +19,24 @@ public record EntityState(
String markings, // horse coat markings style (e.g. "white", "whitefield", "whitedots", "blackdots"), or null String markings, // horse coat markings style (e.g. "white", "whitefield", "whitedots", "blackdots"), or null
boolean saddle, // horse/donkey/mule is saddled boolean saddle, // horse/donkey/mule is saddled
boolean chest, // donkey/mule/llama is carrying a chest 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 tex = compositeHorse(s, tex); // coat markings + horse armor
} else if (cem.equals("llama")) { } else if (cem.equals("llama")) {
tex = compositeLlama(s, tex); // dyed/trader carpet decor 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); 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(); double sc = (s.baby() ? 0.5 : 1.0) * s.sizeScale();
// CEM model px -> entity-local blocks. Identity orientation (no axis flip) preserves ALL part // 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"); } 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"); } 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)); // The body model is baked even when invisible — not drawn, but used as the ground-snap reference
// Sheep: render the inflated, dye-tinted wool fur layer over the body (transparent where the face shows). // so equipment stays at body height (e.g. a lone helmet sits at the head, not on the floor).
if (s.typeKey().equals("sheep")) { List<CemGeometry.Baked> body = (model != null && tex != null)
CemModelLoader.CemModel wool = models.get("sheep_wool"); ? CemGeometry.bakeModel(model, tex, pre, hidden) : java.util.List.of();
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);
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; 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()) Affine place = Affine.translation(s.x(), s.y(), s.z())
.mul(Affine.rotY(Math.PI - Math.toRadians(s.bodyYaw()))) .mul(Affine.rotY(Math.PI - Math.toRadians(s.bodyYaw())))
@@ -164,6 +182,15 @@ public final class CemBaker implements EntityBaker<EntityState> {
return out; 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). */ /** 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) { 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); 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)); 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) { 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 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}; double[] from = {-w / 2, 0, -w / 2};
@@ -33,6 +33,15 @@ public final class EntitySnapshotBuilder {
"potion", "ender_pearl", "tnt", "falling_block", "item" "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) { public static List<EntityState> build(Location eye, List<Vector> rayMap, double maxDistance, UUID shooter) {
FrustumBounds bounds = FrustumBounds.of(eye.toVector(), rayMap, maxDistance); FrustumBounds bounds = FrustumBounds.of(eye.toVector(), rayMap, maxDistance);
Collection<Entity> nearby = bounds.nearbyEntities(eye.getWorld(), 2); Collection<Entity> nearby = bounds.nearbyEntities(eye.getWorld(), 2);
@@ -58,7 +67,13 @@ public final class EntitySnapshotBuilder {
} }
boolean baby = (e instanceof Ageable a && !a.isAdult()) 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 width = safeDim(e::getWidth, () -> e.getBoundingBox().getWidthX());
double height = safeDim(e::getHeight, () -> e.getBoundingBox().getHeight()); 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) { } else if (e instanceof org.bukkit.entity.AbstractHorse ah) {
// Skeleton/zombie horse: only saddle (no colour/markings/armor variants). // Skeleton/zombie horse: only saddle (no colour/markings/armor variants).
saddle = isSaddled(ah); 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) { } else if (e instanceof org.bukkit.entity.Fox f) {
variant = keyOf(f.getFoxType()); variant = keyOf(f.getFoxType());
} else if (e instanceof org.bukkit.entity.MushroomCow mc) { } 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. // 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(), return new EntityState(type, loc.getX(), loc.getY(), loc.getZ(),
bodyYaw, baby, width, height, bodyYaw, baby, width, height,
player, skinUrl, slim, variant, tint, sizeScale, profession, villagerLevel, 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. */ /** 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; 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. */ /** Whether a horse-like mount carries a saddle in its dedicated saddle slot. */
private static boolean isSaddled(org.bukkit.entity.AbstractHorse h) { private static boolean isSaddled(org.bukkit.entity.AbstractHorse h) {
org.bukkit.inventory.ItemStack st = h.getInventory().getSaddle(); 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]]" usage: "/pixelPic [cleanup [confirm] [days]]"
permissions: permissions:
pixelpic.use: pixelpic.use:
description: "Allows taking PixelPics camera screenshots" description: "Allows running /pixelPic (the render branch itself requires pixelpic.admin)"
default: true default: true
pixelpic.admin: pixelpic.admin:
description: "Allows managing PixelPics (e.g. cleanup)" description: "Allows the /pixelPic debug render (no camera/film) and cleanup management"
default: op default: op
+39 -4
View File
@@ -62,12 +62,41 @@ public class EntityTestRender {
// Horse/llama/donkey equipment for the standalone render. // Horse/llama/donkey equipment for the standalone render.
static final Map<String, String> MARK = Map.of("horse", "blackdots"); // coat markings 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 java.util.Set<String> CHEST = java.util.Set.of("llama", "donkey");
static final Map<String, String> EQUIP = Map.ofEntries( // armor / carpet 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 { public static void main(String[] args) throws Exception {
Logger log = Logger.getLogger("test"); Logger log = Logger.getLogger("test");
ResourcePack pack = ResourcePackLoader.load(new File(ROOT, "resourcepack"), log).orElseThrow(); ResourcePack pack = ResourcePackLoader.load(new File(ROOT, "resourcepack"), log).orElseThrow();
@@ -80,7 +109,9 @@ public class EntityTestRender {
log.info("Loaded " + n + " geometries"); log.info("Loaded " + n + " geometries");
SkinCache skins = new SkinCache(); 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.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); DefaultScreenRenderer renderer = new DefaultScreenRenderer(registry, tint, textures, baker, beBaker, log);
BlockData air = (BlockData) Proxy.newProxyInstance(EntityTestRender.class.getClassLoader(), BlockData air = (BlockData) Proxy.newProxyInstance(EntityTestRender.class.getClassLoader(),
@@ -144,8 +175,12 @@ public class EntityTestRender {
boolean isPlayer = key.equals("player"); boolean isPlayer = key.equals("player");
EntityState s = new EntityState(key, 0, 0, 0, yaw, false, 0.8, 1.0, 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), 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); 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 cx = (re.aabbMin[0] + re.aabbMax[0]) / 2;
double cy = (re.aabbMin[1] + re.aabbMax[1]) / 2; double cy = (re.aabbMin[1] + re.aabbMax[1]) / 2;
double cz = (re.aabbMin[2] + re.aabbMax[2]) / 2; double cz = (re.aabbMin[2] + re.aabbMax[2]) / 2;