diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/Main.java b/src/main/java/eu/mhsl/minecraft/pixelpics/Main.java index 8cd2364..6308274 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/Main.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/Main.java @@ -23,13 +23,15 @@ import eu.mhsl.minecraft.pixelpics.survival.JoinListener; import eu.mhsl.minecraft.pixelpics.survival.SurvivalRecipes; import eu.mhsl.minecraft.pixelpics.utils.MapColorPalette; import java.io.File; +import java.io.FileInputStream; import java.io.InputStream; -import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.Optional; import org.bukkit.Bukkit; import org.bukkit.NamespacedKey; +import org.bukkit.World; +import org.bukkit.event.Listener; import org.bukkit.plugin.java.JavaPlugin; public final class Main extends JavaPlugin { @@ -40,13 +42,10 @@ public final class Main extends JavaPlugin { private DefaultScreenRenderer screenRenderer; private RenderManager renderManager; + // PDC keys: photo picture id (STRING), camera/film markers (BYTE), loaded film on a camera (INTEGER) 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 @@ -56,12 +55,11 @@ public final class Main extends JavaPlugin { saveDefaultConfig(); initRenderManager(); - Bukkit.getPluginManager().registerEvents(new OnMapInitialize(), this); - Objects.requireNonNull(Bukkit.getPluginCommand("pixelPic")).setExecutor(new PixelPicsCommand()); - - Bukkit.getPluginManager().registerEvents(new CameraListener(), this); - Bukkit.getPluginManager().registerEvents(new CraftingListener(), this); - Bukkit.getPluginManager().registerEvents(new JoinListener(), this); + Objects.requireNonNull(getCommand("pixelPic")).setExecutor(new PixelPicsCommand()); + for (Listener listener : + List.of(new OnMapInitialize(), new CameraListener(), new CraftingListener(), new JoinListener())) { + Bukkit.getPluginManager().registerEvents(listener, this); + } SurvivalRecipes.register(); initRenderer(); @@ -114,7 +112,7 @@ public final class Main extends JavaPlugin { // optional newer dump in the data folder (no recompile on MC updates), else the bundled copy. File dump = new File(getDataFolder(), "vanilla_entity_models.json"); try (InputStream in = - dump.isFile() ? new java.io.FileInputStream(dump) : getResource("cem/vanilla_entity_models.json")) { + dump.isFile() ? new FileInputStream(dump) : getResource("cem/vanilla_entity_models.json")) { if (in != null) { long t0 = System.nanoTime(); int n = cemLoader.load(in, getLogger()); @@ -125,8 +123,8 @@ public final class Main extends JavaPlugin { } catch (Exception e) { getLogger().severe("Failed to load vanilla entity models: " + e.getMessage()); } - List worldFolders = new ArrayList<>(); - for (org.bukkit.World w : getServer().getWorlds()) worldFolders.add(w.getWorldFolder()); + List worldFolders = + getServer().getWorlds().stream().map(World::getWorldFolder).toList(); int datapackVariants = DatapackVariants.load(cemLoader, worldFolders, getLogger()); if (datapackVariants > 0) getLogger().info("Loaded " + datapackVariants + " datapack mob variants."); SkinCache skinCache = new SkinCache(); @@ -143,7 +141,7 @@ public final class Main extends JavaPlugin { blockEntityBaker, getLogger(), renderManager.tracePool()); - // Warm the map palette on the main thread so off-thread dithering never triggers its first init. + // Initialise the map palette on the main thread (MapPalette) before any off-thread dithering. MapColorPalette.size(); getLogger().info("PixelPics renderer initialized with resource pack assets."); } @@ -166,7 +164,6 @@ public final class Main extends JavaPlugin { return this.screenRenderer; } - /** The render queue/limiter; always available after {@code onEnable}. */ public RenderManager getRenderManager() { return this.renderManager; } 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 5eb3051..50ac646 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/commands/PixelPicsCommand.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/commands/PixelPicsCommand.java @@ -33,7 +33,7 @@ public class PixelPicsCommand implements CommandExecutor { } if (args.length > 0) return false; - // Debug shortcut: render a photo from the player's view without needing a camera or film. + // Debug render without camera or film. if (!player.hasPermission("pixelpic.admin")) { player.sendActionBar( Component.text("Dafür fehlt dir die Berechtigung — nutze eine Kamera.", NamedTextColor.RED)); @@ -79,12 +79,12 @@ public class PixelPicsCommand implements CommandExecutor { if (!confirm) { sender.sendMessage(Component.text( - candidates.size() + " Aufnahme(n) könnten gelöscht werden " + "(älter als " + days + candidates.size() + " Aufnahme(n) könnten gelöscht werden (älter als " + days + " Tage, nicht in geladenen Itemframes/Online-Inventaren).", NamedTextColor.YELLOW)); sender.sendMessage(Component.text( - "Achtung: Maps in lange ungeladenen Bereichen werden hierbei nicht " - + "erkannt. Zum Löschen: /pixelPic cleanup confirm " + days, + "Achtung: Maps in lange ungeladenen Bereichen werden hierbei nicht erkannt. Zum Löschen: /pixelPic cleanup confirm " + + days, NamedTextColor.GRAY)); return true; } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/listeners/OnMapInitialize.java b/src/main/java/eu/mhsl/minecraft/pixelpics/listeners/OnMapInitialize.java index dfcbf3d..28abee4 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/listeners/OnMapInitialize.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/listeners/OnMapInitialize.java @@ -8,24 +8,20 @@ import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.event.server.MapInitializeEvent; +/** Reattaches stored photos to their maps when the server loads them. */ public class OnMapInitialize implements Listener { @EventHandler public void onMapInitialize(MapInitializeEvent event) { int mapId = event.getMap().getId(); - - // Fast path: use the cached dithered indices (no re-quantization). byte[] indices = MapManager.readIndices(mapId); - if (indices != null) { - MapManager.attachView(event.getMap(), new ImageMapRenderer(indices)); - return; + if (indices == null) { + // No index cache (yet): dither the stored PNG once and cache the result. + BufferedImage image = MapManager.readImage(mapId); + if (image == null) return; + indices = MapImageDither.dither(image); + MapManager.saveIndices(indices, mapId); } - - // Fallback/migration: dither from the stored PNG once, then cache for next time. - BufferedImage image = MapManager.readImage(mapId); - if (image == null) return; - indices = MapImageDither.dither(image); - MapManager.saveIndices(indices, mapId); MapManager.attachView(event.getMap(), new ImageMapRenderer(indices)); } } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/survival/CameraItems.java b/src/main/java/eu/mhsl/minecraft/pixelpics/survival/CameraItems.java index d6ee415..40b5d8d 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/survival/CameraItems.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/survival/CameraItems.java @@ -17,42 +17,34 @@ 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; /** - * 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. + * Camera and film roll are custom-skinned {@code PLAYER_HEAD}s (the skin needs no client resource pack), the photo is + * a {@code FILLED_MAP}. Identity is carried by PDC markers, so renaming does not matter. */ 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 = + private static final String CAMERA_TEXTURE_B64 = "eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvZDlkMmNiZjAyZDMwOGI2MDY1YTZmZThjNjU3MWI2MzU2NjMzZjQxOTJlOGVjNzEyMTNjNzcwNzgwZTNkZTRlMiJ9fX0="; - static final String FILM_TEXTURE_B64 = + private 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.WHITE).decoration(TextDecoration.ITALIC, false)); + meta.displayName(plain("Kamera", NamedTextColor.WHITE)); meta.setCustomModelData(CAMERA_MODEL_DATA); - // A camera carries per-item state (loaded film count), so it must not stack — otherwise loading - // or shooting a stacked camera would duplicate or desync the whole stack. + // The camera carries per-item state (film count): a stack would duplicate or desync on loading/shooting. meta.setMaxStackSize(1); meta.getPersistentDataContainer().set(Main.getInstance().cameraMarker, PersistentDataType.BYTE, (byte) 1); meta.getPersistentDataContainer().set(Main.getInstance().filmCountKey, PersistentDataType.INTEGER, count); @@ -62,40 +54,35 @@ public final class CameraItems { 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.WHITE).decoration(TextDecoration.ITALIC, false)); + meta.displayName(plain("Filmrolle", NamedTextColor.WHITE)); meta.setCustomModelData(FILM_MODEL_DATA); - // Lore leads with "Filmrolle" so the recipe book search finds it by name (see applyCameraLore). - meta.lore(List.of(Component.text("Filmrolle – lädt eine Kamera auf.", NamedTextColor.GRAY) - .decoration(TextDecoration.ITALIC, false))); + // Leads with "Filmrolle" for the recipe book search (see applyCameraLore). + meta.lore(List.of(plain("Filmrolle – lädt eine Kamera auf.", NamedTextColor.GRAY))); meta.getPersistentDataContainer().set(Main.getInstance().filmMarker, PersistentDataType.BYTE, (byte) 1); makeUnwearable(meta); item.setItemMeta(meta); return item; } - /** Display name applied to a developed photo map (white, like a normally renamed item). */ public static Component photoDisplayName() { - return Component.text("Foto", NamedTextColor.WHITE).decoration(TextDecoration.ITALIC, false); + return plain("Foto", NamedTextColor.WHITE); } - /** Returns a copy of {@code camera} with its film count set to {@code newCount} and lore refreshed. */ + /** A copy of {@code camera} with the given film count; stays component-equal to {@code createCamera(count)}. */ public static ItemStack withFilmCount(ItemStack camera, int newCount) { int count = Math.clamp(newCount, 0, MAX_FILM); ItemStack copy = camera.clone(); - SkullMeta meta = (SkullMeta) copy.getItemMeta(); + ItemMeta meta = 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); } @@ -104,50 +91,46 @@ public final class CameraItems { 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); + return item != null + && item.getType() == Material.FILLED_MAP + && item.hasItemMeta() + && item.getItemMeta() + .getPersistentDataContainer() + .has(Main.getInstance().pictureIdFlag, PersistentDataType.STRING); } - /** Loaded film on a camera, or 0 if the item is not a camera. */ + /** Loaded film on a camera, 0 for any other item. */ public static int getFilmCount(ItemStack item) { if (!isCamera(item)) return 0; - Integer v = item.getItemMeta() + return item.getItemMeta() .getPersistentDataContainer() - .get(Main.getInstance().filmCountKey, PersistentDataType.INTEGER); - return v == null ? 0 : v; + .getOrDefault(Main.getInstance().filmCountKey, PersistentDataType.INTEGER, 0); } - // --- internals --- - private static boolean hasMarker(ItemStack item, 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); + return item != null + && item.getType() == Material.PLAYER_HEAD + && item.hasItemMeta() + && item.getItemMeta().getPersistentDataContainer().has(key, PersistentDataType.BYTE); + } + + private static Component plain(String text, NamedTextColor color) { + return Component.text(text, color).decoration(TextDecoration.ITALIC, false); } private static void applyCameraLore(ItemMeta meta, int count) { - // The first line carries the word "Kamera" so the recipe book search (which indexes the result - // item's lore, not its custom display name — every item here is a PLAYER_HEAD named "Spielerkopf" - // by default) can find it when the player types "kamera". + // The recipe book search indexes the result's lore, not its custom name (every item here is a + // PLAYER_HEAD), so the first line carries the word "Kamera". meta.lore(List.of( - Component.text("Kamera für Sofortbildfotos", NamedTextColor.GRAY) - .decoration(TextDecoration.ITALIC, false), - 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))); + plain("Kamera für Sofortbildfotos", NamedTextColor.GRAY), + plain("Film: " + count + " / " + MAX_FILM, count > 0 ? NamedTextColor.YELLOW : NamedTextColor.RED), + plain("Rechtsklick: Foto aufnehmen", NamedTextColor.DARK_GRAY))); } /** - * 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. + * Overrides the head's equippable component: no player slot accepts {@code SADDLE}, which blocks right-click, + * shift-click and dispenser equipping; swapping is disabled as well. */ private static void makeUnwearable(ItemMeta meta) { EquippableComponent eq = meta.getEquippable(); @@ -157,7 +140,6 @@ public final class CameraItems { } 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)); diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/survival/CameraListener.java b/src/main/java/eu/mhsl/minecraft/pixelpics/survival/CameraListener.java index 90a34fd..6043603 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/survival/CameraListener.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/survival/CameraListener.java @@ -15,9 +15,8 @@ import org.bukkit.inventory.EquipmentSlot; import org.bukkit.inventory.ItemStack; /** - * 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. + * Right-click with a camera takes a photo and uses one film. {@link PlayerInteractEvent} can fire twice per click + * (off hand, air + block), hence the hand filter and the per-player cooldown. */ public class CameraListener implements Listener { @@ -33,25 +32,19 @@ public class CameraListener implements Listener { ItemStack inHand = event.getItem(); if (!CameraItems.isCamera(inHand)) return; + event.setCancelled(true); Player player = event.getPlayer(); long now = System.currentTimeMillis(); - if (now - lastUse.getOrDefault(player.getUniqueId(), 0L) < COOLDOWN_MILLIS) { - event.setCancelled(true); - return; - } + if (now - lastUse.getOrDefault(player.getUniqueId(), 0L) < COOLDOWN_MILLIS) 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; } - // Only charge film if the shot was actually accepted (renderer ready, not queue-rejected). if (PhotoService.takePhoto(player)) { player.getInventory().setItemInMainHand(CameraItems.withFilmCount(inHand, film - 1)); } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/survival/CraftingListener.java b/src/main/java/eu/mhsl/minecraft/pixelpics/survival/CraftingListener.java index e73e5de..5e83d76 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/survival/CraftingListener.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/survival/CraftingListener.java @@ -21,12 +21,10 @@ import org.bukkit.inventory.meta.MapMeta; import org.bukkit.persistence.PersistentDataType; /** - * 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. + * Load film (camera + film -> camera with one more film) and copy photo (photo + film -> a second photo on the same + * map) have variable-NBT inputs, so the result is computed here: {@link PrepareItemCraftEvent} shows it and rejects + * loose {@code MaterialChoice} matches, {@link CraftItemEvent} performs the craft by hand. Shift-click is refused, and + * our items never feed any other recipe. */ public class CraftingListener implements Listener { @@ -36,30 +34,16 @@ public class CraftingListener implements Listener { 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) {} + private record Scan(Kind kind, ItemStack camera, 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 - : single(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); - } - } + if (scan.kind() != Kind.NONE) { + inv.setResult(result(scan)); + } else if (isDynamicRecipe(event.getRecipe()) || scan.hasOurItems()) { + inv.setResult(null); } } @@ -67,50 +51,36 @@ public class CraftingListener implements Listener { 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 result = result(scan); + if (result == null) return; ItemStack[] matrix = inv.getMatrix(); - if (scan.kind() == Kind.LOAD) { - int count = CameraItems.getFilmCount(scan.camera()); - if (count >= CameraItems.MAX_FILM) return; - result = single(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 - } + if (scan.kind() == Kind.LOAD) consumeFirst(matrix, CameraItems::isCamera); + consumeFirst(matrix, CameraItems::isFilm); // a copied photo keeps its original - // Apply on the next tick so the click settles before we mutate the grid and hand over the item. - ItemStack finalResult = result; + // Next tick, so the click settles before the grid changes and the item is handed over. Bukkit.getScheduler().runTask(Main.getInstance(), () -> { inv.setMatrix(matrix); - give(player, finalResult); + give(player, result); 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; + ItemStack camera = null, photo = null; for (ItemStack it : matrix) { if (it == null || it.getType().isAir()) continue; if (CameraItems.isCamera(it)) { @@ -118,67 +88,64 @@ public class CraftingListener implements Listener { 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; + if (others == 0 && films == 1) { + if (cameras == 1 && photos == 0) kind = Kind.LOAD; + else if (photos == 1 && cameras == 0) kind = Kind.COPY; } - return new Scan(kind, camera, film, photo, ours); + return new Scan(kind, camera, photo, cameras + films + photos > 0); + } + + /** The crafted item, or null when the camera is full or the photo has no map. */ + private static ItemStack result(Scan scan) { + if (scan.kind() == Kind.COPY) return photoCopy(scan.photo()); + int count = CameraItems.getFilmCount(scan.camera()); + if (count >= CameraItems.MAX_FILM) return null; + ItemStack camera = CameraItems.withFilmCount(scan.camera(), count + 1); + camera.setAmount(1); // the grid may hold an old camera stack + return camera; } 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); + return recipe instanceof Keyed keyed + && (SurvivalRecipes.LOAD.equals(keyed.getKey()) || SurvivalRecipes.COPY.equals(keyed.getKey())); } - /** A second photo referencing the same {@link org.bukkit.map.MapView} and picture id as {@code photo}. */ - private static ItemStack buildPhotoCopy(ItemStack photo) { + private static ItemStack photoCopy(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); - } + NamespacedKey key = Main.getInstance().pictureIdFlag; + dst.getPersistentDataContainer() + .set( + key, + PersistentDataType.STRING, + src.getPersistentDataContainer().get(key, PersistentDataType.STRING)); dst.displayName(CameraItems.photoDisplayName()); copy.setItemMeta(dst); return copy; } - /** Forces a crafting result to a single item — one craft yields exactly one camera, never a stack. */ - private static ItemStack single(ItemStack item) { - item.setAmount(1); - return item; - } - 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; - } + if (it != null && pred.test(it)) { + if (it.getAmount() <= 1) matrix[i] = null; + else it.setAmount(it.getAmount() - 1); return; } } } private static void give(Player player, ItemStack item) { - ItemStack cursor = player.getItemOnCursor(); - if (cursor.getType().isAir()) { + if (player.getItemOnCursor().getType().isAir()) { player.setItemOnCursor(item); } else { player.getInventory().addItem(item).values().forEach(left -> player.getWorld() diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/survival/PhotoService.java b/src/main/java/eu/mhsl/minecraft/pixelpics/survival/PhotoService.java index 1b2a55c..4f04e97 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/survival/PhotoService.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/survival/PhotoService.java @@ -9,7 +9,6 @@ import eu.mhsl.minecraft.pixelpics.utils.ImageMapRenderer; import eu.mhsl.minecraft.pixelpics.utils.MapImageDither; import eu.mhsl.minecraft.pixelpics.utils.MapManager; import java.awt.image.BufferedImage; -import java.util.Objects; import java.util.UUID; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; @@ -23,26 +22,18 @@ 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; /** - * Renders a photo of a player's current view and delivers it as a developing {@code FILLED_MAP}, - * with a shutter sound + flash particles on capture and a chime when the picture finishes developing. - * Heavy work is funneled through {@link RenderManager} (per-player single slot, global concurrency - * limit + queue, bounded low-priority CPU). Shared by the camera item (survival) and the - * {@code /pixelPic} debug command. Film accounting is the caller's responsibility. + * Photographs a player's view into a {@code FILLED_MAP} that develops once the render (throttled by + * {@link RenderManager}) is done. Used by the camera and {@code /pixelPic}; film is the caller's business. */ public final class PhotoService { - /** Render result carried back to the main thread: PNG source + dithered map indices. */ private record RenderOutput(BufferedImage image, byte[] indices) {} private PhotoService() {} - /** - * Captures and delivers a photo. Returns {@code false} (with action-bar feedback) if the renderer - * is unavailable or the request is rejected by the queue — callers should not charge film then. - */ + /** False (with action-bar feedback) when no photo was taken: no renderer, or rejected by the queue. */ public static boolean takePhoto(Player player) { Main plugin = Main.getInstance(); DefaultScreenRenderer renderer = plugin.getScreenRenderer(); @@ -66,18 +57,17 @@ public final class PhotoService { boolean dispatched = false; try { - Resolution resolution = new Resolution(128, 128); + // The world snapshot is taken now, on the main thread; tracing happens later off-thread. + RenderJob job = renderer.prepare( + player.getEyeLocation(), new Resolution(MapImageDither.SIZE, MapImageDither.SIZE), user); - // Capture the world snapshot on the main thread (the moment of the shot). - RenderJob job = renderer.prepare(player.getEyeLocation(), resolution, user); - - // Hand the map over immediately, showing blank "film"; it develops once the render is ready. + // The map is handed over right away and shows blank film until the picture develops. 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); + ItemStack map = new ItemStack(Material.FILLED_MAP); MapMeta meta = (MapMeta) map.getItemMeta(); meta.getPersistentDataContainer() .set( @@ -88,12 +78,8 @@ public final class PhotoService { meta.displayName(CameraItems.photoDisplayName()); 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 (queued), then develop on the main thread. The cancel token is - // tripped by the watchdog on timeout; we bail out before the (now pointless) dithering. manager.dispatch( user, cancelled -> { @@ -102,7 +88,7 @@ public final class PhotoService { return new RenderOutput(image, MapImageDither.dither(image)); }, out -> { - MapManager.saveImage(Objects.requireNonNull(out).image(), id); + MapManager.saveImage(out.image(), id); MapManager.saveIndices(out.indices(), id); mapRenderer.develop(out.indices()); player.playSound(player.getLocation(), Sound.BLOCK_AMETHYST_BLOCK_CHIME, 0.8f, 1.2f); @@ -110,7 +96,6 @@ public final class PhotoService { () -> player.sendActionBar(Component.text("Rendern fehlgeschlagen.", NamedTextColor.RED))); dispatched = true; } finally { - // prepare()/map setup threw before handing off — free the reserved slot. if (!dispatched) manager.release(user); } @@ -120,8 +105,7 @@ public final class PhotoService { 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); + Location flash = eye.clone().add(eye.getDirection()); 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 index d199582..8180776 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/survival/SurvivalRecipes.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/survival/SurvivalRecipes.java @@ -1,8 +1,8 @@ package eu.mhsl.minecraft.pixelpics.survival; import eu.mhsl.minecraft.pixelpics.Main; -import java.util.ArrayList; import java.util.List; +import java.util.stream.IntStream; import org.bukkit.Bukkit; import org.bukkit.Material; import org.bukkit.NamespacedKey; @@ -12,10 +12,8 @@ import org.bukkit.inventory.ShapedRecipe; import org.bukkit.inventory.ShapelessRecipe; /** - * 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. + * The four survival recipes. Load film and copy photo only register a recipe book icon as result; the real result + * is computed in {@link CraftingListener}. */ public final class SurvivalRecipes { @@ -37,7 +35,6 @@ public final class SurvivalRecipes { 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); @@ -47,35 +44,29 @@ public final class SurvivalRecipes { 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. Use ExactChoice so the recipe book renders the real camera/film - // textures instead of generic player heads. The camera choice enumerates every loadable fill - // level (0..MAX_FILM-1) so a partly loaded camera still matches; the actual result is still - // validated and computed dynamically in CraftingListener. - List loadableCameras = new ArrayList<>(); - for (int i = 0; i < CameraItems.MAX_FILM; i++) { - loadableCameras.add(CameraItems.createCamera(i)); - } + // ExactChoice so the recipe book shows the real head textures; every loadable fill level is listed + // because a used camera must still match. + List loadableCameras = IntStream.range(0, CameraItems.MAX_FILM) + .mapToObj(CameraItems::createCamera) + .toList(); ShapelessRecipe load = new ShapelessRecipe(LOAD, CameraItems.createCamera(1)); load.addIngredient(RecipeChoice.exactChoice(loadableCameras)); load.addIngredient(RecipeChoice.exactChoice(CameraItems.createFilm())); Bukkit.addRecipe(load, false); - // Copy photo: photo + film. The photo carries a variable map id (cannot be enumerated), so it - // stays a MaterialChoice (a filled-map icon, not a player head); the film uses ExactChoice so - // its real texture shows in the book. + // The photo's map id cannot be enumerated, so it stays a MaterialChoice. ShapelessRecipe copy = new ShapelessRecipe(COPY, new ItemStack(Material.FILLED_MAP)); copy.addIngredient(new RecipeChoice.MaterialChoice(Material.FILLED_MAP)); copy.addIngredient(RecipeChoice.exactChoice(CameraItems.createFilm())); Bukkit.addRecipe(copy, false); - // Cover /reload while players are online; fresh joins are handled by JoinListener. + // Players already online after a reload; later joins are handled by JoinListener. Bukkit.getOnlinePlayers().forEach(p -> p.discoverRecipes(allKeys())); } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/utils/ImageMapRenderer.java b/src/main/java/eu/mhsl/minecraft/pixelpics/utils/ImageMapRenderer.java index f705a7a..abebb23 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/utils/ImageMapRenderer.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/utils/ImageMapRenderer.java @@ -1,6 +1,5 @@ package eu.mhsl.minecraft.pixelpics.utils; -import java.awt.image.BufferedImage; import org.bukkit.entity.Player; import org.bukkit.map.MapCanvas; import org.bukkit.map.MapRenderer; @@ -8,41 +7,31 @@ import org.bukkit.map.MapView; import org.jetbrains.annotations.NotNull; /** - * Draws precomputed map palette indices onto the canvas. Supports a Polaroid-style "developing" - * animation: the picture dissolves in over {@link #DEVELOP_MILLIS} once the render is ready, like an - * old instant camera. Persisted maps are drawn instantly without animation. - * - *

{@code render()} is invoked by Bukkit each tick for viewing players, which drives the animation; - * progress is based on wall-clock time so it is correct even if the player looks away and back. + * Draws precomputed map palette indices. A fresh photo shows blank film until {@link #develop} and then dissolves in + * over {@link #DEVELOP_MILLIS} like an instant camera picture; persisted maps are drawn at once. Bukkit calls + * {@code render} every tick for viewing players; progress follows the wall clock, so looking away does not stall it. */ +@SuppressWarnings("deprecation") // MapCanvas.setPixel(int, int, byte) public class ImageMapRenderer extends MapRenderer { public static final int MAP_SIZE = MapImageDither.SIZE; private static final long DEVELOP_MILLIS = 3000; + private static final byte FILM = MapColorPalette.mapIndex(MapColorPalette.nearestPos(206, 202, 190)); - private static byte filmIndex = 0; - private static boolean filmResolved = false; - - private volatile byte[] indices; // MAP_SIZE*MAP_SIZE, null while still rendering + private volatile byte[] indices; // null while still rendering private final boolean animate; - private volatile long developStart = 0; // 0 until indices are available + private volatile long developStart; private boolean finished = false; private boolean blankDrawn = false; - /** Persisted/instant map: draw the full picture immediately, no animation. */ + /** A persisted picture, drawn without animation. */ public ImageMapRenderer(byte[] indices) { this.indices = indices; this.animate = false; } - /** Fresh capture from a rendered image: dithers immediately, then develops. */ - public ImageMapRenderer(BufferedImage image) { - this(MapImageDither.dither(image)); - } - - /** Pending capture: shows blank film until {@link #develop} supplies the picture. */ + /** A pending capture. */ public ImageMapRenderer() { - this.indices = null; this.animate = true; } @@ -50,58 +39,39 @@ public class ImageMapRenderer extends MapRenderer { return indices; } - /** Supplies the finished picture and starts the developing animation. */ public void develop(byte[] indices) { - this.indices = indices; this.developStart = System.currentTimeMillis(); - this.finished = false; + this.indices = indices; } @Override public void render(@NotNull MapView map, @NotNull MapCanvas canvas, @NotNull Player player) { if (finished) return; byte[] data = this.indices; - - // Still rendering: show a blank "film" once. if (data == null) { if (!blankDrawn) { - fill(canvas, film()); + blit(canvas, null, 0); blankDrawn = true; } return; } - - if (!animate) { - blit(canvas, data, 1.0); - finished = true; - return; - } - - double progress = (System.currentTimeMillis() - developStart) / (double) DEVELOP_MILLIS; + double progress = animate ? (System.currentTimeMillis() - developStart) / (double) DEVELOP_MILLIS : 1.0; blit(canvas, data, progress); if (progress >= 1.0) finished = true; } - /** Draws revealed pixels at the given progress; unrevealed pixels stay film-colored. */ - private void blit(MapCanvas canvas, byte[] data, double progress) { - byte film = film(); + /** Draws the pixels revealed at {@code progress}; the rest (all of them without data) stay film-coloured. */ + private static void blit(MapCanvas canvas, byte[] data, double progress) { for (int y = 0; y < MAP_SIZE; y++) { for (int x = 0; x < MAP_SIZE; x++) { - byte value = (progress >= 1.0 || revealThreshold(x, y) <= progress) ? data[y * MAP_SIZE + x] : film; - canvas.setPixel(x, y, value); + boolean revealed = data != null && (progress >= 1.0 || revealThreshold(x, y) <= progress); + canvas.setPixel(x, y, revealed ? data[y * MAP_SIZE + x] : FILM); } } } - @SuppressWarnings("deprecation") - private void fill(MapCanvas canvas, byte value) { - for (int y = 0; y < MAP_SIZE; y++) { - for (int x = 0; x < MAP_SIZE; x++) canvas.setPixel(x, y, value); - } - } - - /** Per-pixel reveal time in [0,1]: mostly noise plus a gentle top-to-bottom sweep for an organic develop. */ - private double revealThreshold(int x, int y) { + /** Per-pixel reveal time in [0,1): mostly noise plus a gentle top-to-bottom sweep. */ + private static double revealThreshold(int x, int y) { int h = (x * 374761393 + y * 668265263); h = (h ^ (h >>> 13)) * 1274126177; double noise = ((h >>> 8) & 0xFFFF) / 65535.0; @@ -109,16 +79,8 @@ public class ImageMapRenderer extends MapRenderer { return Math.min(0.999, noise * 0.7 + sweep * 0.3); } - /** The blank-film map colour a photo shows while it is still being rendered. */ + /** The blank-film colour of a photo that is still rendering. */ public static byte filmColor() { - return film(); - } - - private static byte film() { - if (!filmResolved) { - filmIndex = MapColorPalette.mapIndex(MapColorPalette.nearestPos(206, 202, 190)); - filmResolved = true; - } - return filmIndex; + return FILM; } } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapColorPalette.java b/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapColorPalette.java index 55b7333..1761384 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapColorPalette.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapColorPalette.java @@ -2,84 +2,63 @@ package eu.mhsl.minecraft.pixelpics.utils; import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil; import java.awt.Color; -import java.util.ArrayList; -import java.util.List; +import java.util.Arrays; import org.bukkit.map.MapPalette; /** - * The set of usable Minecraft map colors, with nearest-color matching in CIELAB (perceptual) space. - * Built once from {@link MapPalette}. Matching in Lab rather than RGB keeps hues correct (greens stay - * green instead of collapsing to gray). + * The opaque Minecraft map colours with nearest-colour matching in CIELAB (in RGB, greens collapse to grey). Built + * from {@link MapPalette} on class initialisation, which {@code Main} triggers on the main thread. */ public final class MapColorPalette { - private static volatile boolean initialized = false; - private static byte[] indices; - private static int[] rgb; - private static double[][] lab; - private static int[] argbByIndex; // all 256 map colour bytes → ARGB (transparent slots alpha 0) + private static final byte[] INDICES; + private static final double[][] LAB; + private static final int[] ARGB_BY_INDEX = new int[256]; // raw map colour byte -> ARGB, transparent = 0 + + static { + byte[] indices = new byte[256]; + double[][] lab = new double[256][]; + int n = 0; + for (int i = 0; i < 256; i++) { + Color c = color((byte) i); + if (c == null || c.getAlpha() < 255) continue; + ARGB_BY_INDEX[i] = 0xFF000000 | (c.getRGB() & 0xFFFFFF); + indices[n] = (byte) i; + lab[n++] = rgbToLab(c.getRed(), c.getGreen(), c.getBlue()); + } + INDICES = Arrays.copyOf(indices, n); + LAB = Arrays.copyOf(lab, n); + } private MapColorPalette() {} - // MapPalette.getColor(byte) is deprecated for removal with no replacement for enumerating the - // index->colour palette; suppress until the API offers an alternative. + // MapPalette.getColor is deprecated for removal without a replacement for enumerating the palette; it throws + // past the last colour. @SuppressWarnings("removal") - private static synchronized void ensure() { - if (initialized) return; - List idx = new ArrayList<>(); - List rgbs = new ArrayList<>(); - List labs = new ArrayList<>(); - for (int i = 0; i < 256; i++) { - Color c; - try { - c = MapPalette.getColor((byte) i); - } catch (Throwable t) { - continue; - } - if (c.getAlpha() < 255) continue; // skip transparent slots - idx.add((byte) i); - rgbs.add((c.getRed() << 16) | (c.getGreen() << 8) | c.getBlue()); - labs.add(rgbToLab(c.getRed(), c.getGreen(), c.getBlue())); + private static Color color(byte index) { + try { + return MapPalette.getColor(index); + } catch (Throwable t) { + return null; } - indices = new byte[idx.size()]; - rgb = new int[idx.size()]; - lab = new double[idx.size()][]; - for (int i = 0; i < idx.size(); i++) { - indices[i] = idx.get(i); - rgb[i] = rgbs.get(i); - lab[i] = labs.get(i); - } - argbByIndex = new int[256]; - for (int i = 0; i < 256; i++) { - try { - Color c = MapPalette.getColor((byte) i); - argbByIndex[i] = c.getAlpha() < 255 ? 0 : (0xFF << 24) | (c.getRGB() & 0xFFFFFF); - } catch (Throwable t) { - argbByIndex[i] = 0; - } - } - initialized = true; } /** ARGB of a raw map colour byte as the client draws it; transparent slots are 0. */ public static int argb(byte mapColor) { - ensure(); - return argbByIndex[mapColor & 0xFF]; + return ARGB_BY_INDEX[mapColor & 0xFF]; } public static int size() { - ensure(); - return indices.length; + return INDICES.length; } - /** Index into the internal arrays of the perceptually nearest palette color. */ + /** Position of the perceptually nearest palette colour. */ public static int nearestPos(int r, int g, int b) { - ensure(); double[] target = rgbToLab(r, g, b); double best = Double.MAX_VALUE; int bestPos = 0; - for (int i = 0; i < lab.length; i++) { - double[] l = lab[i]; + for (int i = 0; i < LAB.length; i++) { + double[] l = LAB[i]; double dl = target[0] - l[0], da = target[1] - l[1], db = target[2] - l[2]; double d = dl * dl + da * da + db * db; if (d < best) { @@ -90,18 +69,13 @@ public final class MapColorPalette { return bestPos; } - /** CIELAB of the palette color at {@code pos} (do not modify). */ + /** CIELAB of the palette colour at {@code pos} (do not modify). */ public static double[] lab(int pos) { - ensure(); - return lab[pos]; + return LAB[pos]; } public static byte mapIndex(int pos) { - return indices[pos]; - } - - public static int mapRgb(int pos) { - return rgb[pos]; + return INDICES[pos]; } public static double[] rgbToLab(int r, int g, int b) { diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapImageDither.java b/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapImageDither.java index d0b76d4..f43af24 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapImageDither.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapImageDither.java @@ -3,38 +3,26 @@ package eu.mhsl.minecraft.pixelpics.utils; import java.awt.image.BufferedImage; /** - * Quantizes a rendered image to Minecraft map color indices. The result is a {@code 128*128} byte - * array of palette indices, ready to blit onto a {@link org.bukkit.map.MapCanvas}. - * - *

Uses dampened Floyd–Steinberg error diffusion in CIELAB space with constrained - * candidates: a pixel may only take a palette color that is nearly as close to its original color - * as the best match. Colors the map palette cannot represent well (e.g. light sky blue, sunset - * tones) would otherwise be approximated by mixing far-apart colors, producing a harsh, - * high-contrast checkerboard; the constraint keeps the dither between neighbouring colors only, - * so flat areas stay calm while gradients still blend. - * - *

This is the expensive step; computing it once and caching the byte array lets map re-rendering - * after a restart be a cheap copy instead of a full re-quantization. + * Quantizes an image to {@code SIZE*SIZE} map colour indices: dampened Floyd–Steinberg in CIELAB with + * constrained candidates, i.e. a pixel may only take a colour nearly as close to its original as the best + * match. Colours the palette represents badly (light sky blue, sunset tones) would otherwise be mixed from far-apart + * colours into a harsh checkerboard; this way flat areas stay calm while gradients still blend. Expensive, hence the + * on-disk index cache in {@link MapManager}. */ public final class MapImageDither { public static final int SIZE = 128; - /** Fraction of quantization error diffused. */ + /** Fraction of the quantization error that is diffused. */ private static final double DITHER_STRENGTH = 0.6; - /** - * A candidate is allowed when its Lab distance to the original pixel is at most - * {@code best * CANDIDATE_RATIO + CANDIDATE_SLACK}, where {@code best} is the distance of the - * nearest palette color. - */ + // A candidate may be at most best * CANDIDATE_RATIO + CANDIDATE_SLACK (Lab distance) from the original pixel. private static final double CANDIDATE_RATIO = 1.1; - private static final double CANDIDATE_SLACK = 1.5; private MapImageDither() {} - /** Returns a {@code SIZE*SIZE} array of map palette indices (row-major, y*SIZE + x). */ + /** Row-major ({@code y*SIZE + x}) map colour indices. */ public static byte[] dither(BufferedImage image) { int w = Math.min(SIZE, image.getWidth()); int h = Math.min(SIZE, image.getHeight()); diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapManager.java b/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapManager.java index 200110b..61a276f 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapManager.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapManager.java @@ -20,9 +20,8 @@ import org.bukkit.inventory.meta.MapMeta; import org.bukkit.map.MapView; /** - * Persists rendered images (PNG, source of truth) and their dithered map-color indices (cache) to - * disk, (re)attaches the renderer to a {@link MapView}, and supports orphan cleanup. State is - * filesystem-backed so screenshots survive a restart. + * Photo storage in {@code images/}: the PNG is the source of truth, {@code .mcmap} caches its dithered map indices. + * Also (re)attaches renderers to map views and finds orphans for cleanup. */ public class MapManager { @@ -42,12 +41,12 @@ public class MapManager { mapView.setTrackingPosition(false); } - public static File getImagePath(int mapId) { - return new File(imageFolder(), String.format("mapId_%d.png", mapId)); + private static File getImagePath(int mapId) { + return new File(imageFolder(), "mapId_" + mapId + ".png"); } - public static File getIndexPath(int mapId) { - return new File(imageFolder(), String.format("mapId_%d.mcmap", mapId)); + private static File getIndexPath(int mapId) { + return new File(imageFolder(), "mapId_" + mapId + ".mcmap"); } private static boolean ensureFolder() { @@ -77,7 +76,7 @@ public class MapManager { } } - /** Reads the cached dithered indices, or {@code null} if absent/invalid. */ + /** The cached indices, or {@code null} if absent or invalid. */ public static byte[] readIndices(int id) { File path = getIndexPath(id); if (!path.isFile()) return null; @@ -89,7 +88,7 @@ public class MapManager { } } - /** Reads the stored image, or {@code null} if it does not exist or cannot be read. */ + /** The stored image, or {@code null} if absent or unreadable. */ public static BufferedImage readImage(int id) { File path = getImagePath(id); if (!path.isFile()) return null; @@ -105,16 +104,12 @@ public class MapManager { return getIndexPath(id).isFile() || getImagePath(id).isFile(); } - // --- Cleanup support --- - - /** A map id stored on disk together with the newest of its files' last-modified time. */ + /** A stored map id with the newest last-modified time of its files. */ public record StoredMap(int id, long lastModified) {} - /** All map ids that have files on disk. */ public static Set listStored() { Set result = new HashSet<>(); - File folder = imageFolder(); - File[] files = folder.listFiles(); + File[] files = imageFolder().listFiles(); if (files == null) return result; Set seen = new HashSet<>(); for (File f : files) { @@ -132,7 +127,7 @@ public class MapManager { return f.isFile() ? f.lastModified() : 0L; } - /** Map ids referenced by currently-loaded item frames and online players (inventory + ender chest). */ + /** Map ids held by online players (inventory, ender chest, cursor) and loaded item frames. */ public static Set collectInUseMapIds() { Set inUse = new HashSet<>(); for (Player p : Bukkit.getOnlinePlayers()) { @@ -149,18 +144,20 @@ public class MapManager { } private static void addFromContents(Set set, ItemStack[] contents) { - if (contents == null) return; for (ItemStack item : contents) addFromItem(set, item); } private static void addFromItem(Set set, ItemStack item) { - if (item == null || item.getType() != Material.FILLED_MAP || !item.hasItemMeta()) return; - if (item.getItemMeta() instanceof MapMeta meta && meta.hasMapView() && meta.getMapView() != null) { + if (item != null + && item.getType() == Material.FILLED_MAP + && item.getItemMeta() instanceof MapMeta meta + && meta.hasMapView() + && meta.getMapView() != null) { set.add(meta.getMapView().getId()); } } - /** Deletes both files for a map id. Returns true if anything was deleted. */ + /** Deletes both files of a map id; true if anything was deleted. */ public static boolean delete(int id) { boolean deleted = false; File png = getImagePath(id); diff --git a/tools/edgecase-plugin/pptest/PPTest.java b/tools/edgecase-plugin/pptest/PPTest.java index 452fa86..1b701e0 100644 --- a/tools/edgecase-plugin/pptest/PPTest.java +++ b/tools/edgecase-plugin/pptest/PPTest.java @@ -2071,7 +2071,7 @@ public class PPTest extends JavaPlugin { g.fillRect(48, 56, 32, 12); g.dispose(); org.bukkit.map.MapView v = Bukkit.createMap(w); - eu.mhsl.minecraft.pixelpics.utils.MapManager.attachView(v, new eu.mhsl.minecraft.pixelpics.utils.ImageMapRenderer(img)); + eu.mhsl.minecraft.pixelpics.utils.MapManager.attachView(v, new eu.mhsl.minecraft.pixelpics.utils.ImageMapRenderer(eu.mhsl.minecraft.pixelpics.utils.MapImageDither.dither(img))); return synthMap = mapItem(v); }