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

This commit is contained in:
MineTec committed 2026-06-21 17:30:24 +02:00
1 parent fed94f97d1
commit 220cda1deb
14 files changed
+948 -95

No files matched your search

@@ -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);
}
}
}