diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/Main.java b/src/main/java/eu/mhsl/minecraft/pixelpics/Main.java index ee2d15e..9505610 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/Main.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/Main.java @@ -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; diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/commands/PixelPicsCommand.java b/src/main/java/eu/mhsl/minecraft/pixelpics/commands/PixelPicsCommand.java index f32919b..2adb3b1 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/commands/PixelPicsCommand.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/commands/PixelPicsCommand.java @@ -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; } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityState.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityState.java index 2bb9a62..fe9251a 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityState.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/EntityState.java @@ -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 + ) {} +} diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/TextureOps.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/TextureOps.java index cc70593..2d6a99d 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/TextureOps.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/TextureOps.java @@ -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)); + } } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/CemBaker.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/CemBaker.java index 830380a..e89f3f4 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/CemBaker.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/entity/cem/CemBaker.java @@ -53,9 +53,14 @@ public final class CemBaker implements EntityBaker { 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 { 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 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 body = (model != null && tex != null) + ? CemGeometry.bakeModel(model, tex, pre, hidden) : java.util.List.of(); + List 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 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 { 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 baked) { String saddleModel = cem.equals("donkey") ? "donkey_saddle" : (cem.equals("horse") ? "horse_saddle" : null); @@ -178,6 +205,103 @@ public final class CemBaker implements EntityBaker { 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/): head=helmet, body+arms=chestplate, + // shoes=boots; + // armor_layer_2 (texture entity/equipment/humanoid_leggings/): 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 ARMOR1_HEAD = java.util.Set.of("head"); + private static final java.util.Set ARMOR1_CHEST = java.util.Set.of("body", "left_arm", "right_arm"); + private static final java.util.Set ARMOR1_FEET = java.util.Set.of("left_shoe", "right_shoe"); + private static final java.util.Set 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 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 show, + String layerFolder, Affine pre, List 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 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 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}; diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/EntitySnapshotBuilder.java b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/EntitySnapshotBuilder.java index 6d23861..04f54d7 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/EntitySnapshotBuilder.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/render/snapshot/EntitySnapshotBuilder.java @@ -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 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 build(Location eye, List rayMap, double maxDistance, UUID shooter) { FrustumBounds bounds = FrustumBounds.of(eye.toVector(), rayMap, maxDistance); Collection 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/ 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(); diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/survival/CameraItems.java b/src/main/java/eu/mhsl/minecraft/pixelpics/survival/CameraItems.java new file mode 100644 index 0000000..b14fd45 --- /dev/null +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/survival/CameraItems.java @@ -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); + } +} diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/survival/CameraListener.java b/src/main/java/eu/mhsl/minecraft/pixelpics/survival/CameraListener.java new file mode 100644 index 0000000..aa28316 --- /dev/null +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/survival/CameraListener.java @@ -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 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); + } +} diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/survival/CraftingListener.java b/src/main/java/eu/mhsl/minecraft/pixelpics/survival/CraftingListener.java new file mode 100644 index 0000000..53ece04 --- /dev/null +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/survival/CraftingListener.java @@ -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: load film + * (camera + film β†’ camera+1) and copy photo (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 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)); + } + } +} diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/survival/JoinListener.java b/src/main/java/eu/mhsl/minecraft/pixelpics/survival/JoinListener.java new file mode 100644 index 0000000..87a7636 --- /dev/null +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/survival/JoinListener.java @@ -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()); + } +} diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/survival/PhotoService.java b/src/main/java/eu/mhsl/minecraft/pixelpics/survival/PhotoService.java new file mode 100644 index 0000000..59e9742 --- /dev/null +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/survival/PhotoService.java @@ -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); + } +} diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/survival/SurvivalRecipes.java b/src/main/java/eu/mhsl/minecraft/pixelpics/survival/SurvivalRecipes.java new file mode 100644 index 0000000..54d0a0c --- /dev/null +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/survival/SurvivalRecipes.java @@ -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 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); + } + } +} diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 4d9e414..7156566 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -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 diff --git a/tools/EntityTestRender.java b/tools/EntityTestRender.java index 6931f0d..083a76d 100644 --- a/tools/EntityTestRender.java +++ b/tools/EntityTestRender.java @@ -62,12 +62,41 @@ public class EntityTestRender { // Horse/llama/donkey equipment for the standalone render. static final Map MARK = Map.of("horse", "blackdots"); // coat markings - static final java.util.Set SADDLE = java.util.Set.of("horse", "donkey", "mule"); + static final java.util.Set SADDLE = java.util.Set.of("horse", "donkey", "mule", "nautilus"); static final java.util.Set CHEST = java.util.Set.of("llama", "donkey"); static final Map 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 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 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;