diff --git a/.idea/modules.xml b/.idea/modules.xml index 987850f..7ae2d68 100644 --- a/.idea/modules.xml +++ b/.idea/modules.xml @@ -4,7 +4,7 @@ - + \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 0d8ab51..3369296 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1 +1 @@ -distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-bin.zip diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/Main.java b/src/main/java/eu/mhsl/minecraft/pixelpics/Main.java index 68df380..9c93d44 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/Main.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/Main.java @@ -1,124 +1,89 @@ package eu.mhsl.minecraft.pixelpics; +import eu.mhsl.minecraft.pixelpics.assets.AssetReader; +import eu.mhsl.minecraft.pixelpics.assets.BlockModelRegistry; +import eu.mhsl.minecraft.pixelpics.assets.ResourcePack; +import eu.mhsl.minecraft.pixelpics.assets.ResourcePackLoader; +import eu.mhsl.minecraft.pixelpics.assets.TextureCache; import eu.mhsl.minecraft.pixelpics.commands.PixelPicsCommand; +import eu.mhsl.minecraft.pixelpics.listeners.OnMapInitialize; import eu.mhsl.minecraft.pixelpics.render.render.DefaultScreenRenderer; -import eu.mhsl.minecraft.pixelpics.render.render.Renderer; -import net.kyori.adventure.text.Component; +import eu.mhsl.minecraft.pixelpics.render.tint.BiomeTintProvider; import org.bukkit.Bukkit; -import org.bukkit.Material; import org.bukkit.NamespacedKey; -import org.bukkit.entity.Player; import org.bukkit.plugin.java.JavaPlugin; -import java.io.*; -import java.net.URISyntaxException; -import java.net.URL; -import java.util.Enumeration; +import java.io.File; import java.util.Objects; -import java.util.jar.JarEntry; -import java.util.jar.JarFile; +import java.util.Optional; public final class Main extends JavaPlugin { - private static Main instance; - private Renderer screenRenderer; - public final NamespacedKey pictureIdFlag = Objects.requireNonNull( - NamespacedKey.fromString("imageId".toLowerCase(), this), - "Failed to create item-Flag Namespace" - ); + private static Main instance; + + private ResourcePack resourcePack; + private DefaultScreenRenderer screenRenderer; + + public final NamespacedKey pictureIdFlag = new NamespacedKey(this, "imageid"); @Override public void onEnable() { instance = this; - extractJsonResources(); - Objects.requireNonNull(Bukkit.getPluginCommand("pixelPic")) - .setExecutor(new PixelPicsCommand()); + Bukkit.getPluginManager().registerEvents(new OnMapInitialize(), this); + Objects.requireNonNull(Bukkit.getPluginCommand("pixelPic")).setExecutor(new PixelPicsCommand()); - Bukkit.getPluginCommand("test").setExecutor((sender, command, label, args) -> { -// Dialog dialog = Dialog.create( -// builder -> builder.empty() -// .base( -// DialogBase.builder(Component.text("Hello World")).build() -// ) -// .type(DialogType.multiAction( -// List.of( -// ActionButton.builder(Component.text("Option 1")).action(DialogAction.staticAction(ClickEvent.callback(audience -> System.out.println("HIIIII")))).build(), -// ActionButton.builder(Component.text("Option 2")).action(DialogAction.customClick(Key.key("test"), null)).build(), -// ActionButton.builder(Component.text("Option 3")).action(DialogAction.commandTemplate("say hi")).build() -// ), -// ActionButton.builder(Component.text("Beenden")).build(), -// 3 -// )) -// -// ); -// sender.showDialog(dialog); - - Material.getMaterial("acacia_button"); - Bukkit.broadcast(Component.text(Material.STONE.getBlockTranslationKey().replace("block.minecraft.", ""))); - - if(!(sender instanceof Player)) - throw new IllegalStateException("Dieser Command kann nur von einem Spieler ausgeführt werden!"); - - File blockDir = new File(getDataFolder(), "models/block"); - for (File file : blockDir.listFiles()) { - String blockName = file.getName().substring(0, file.getName().lastIndexOf('.')); - Material material = Material.getMaterial(blockName.toUpperCase()); - System.out.println(material); - if(material == null) { - System.out.println(blockName); - } - } - - return true; - }); + initRenderer(); } - public void extractJsonResources() { - String resourcePath = "models/block/"; - File outputDir = new File(getDataFolder(), resourcePath); - if (outputDir.exists()) return; - outputDir.mkdirs(); + private void initRenderer() { + File resourcePackDir = new File(getDataFolder(), "resourcepack"); + if (!resourcePackDir.exists() && !resourcePackDir.mkdirs()) { + getLogger().warning("Could not create resource pack directory: " + resourcePackDir); + } - try { - URL jarUrl = getClass().getProtectionDomain().getCodeSource().getLocation(); - File jarFile = new File(jarUrl.toURI()); + Optional pack = ResourcePackLoader.load(resourcePackDir, getLogger()); + if (pack.isEmpty()) { + getLogger().severe("No resource pack found in " + resourcePackDir.getPath() + + " — place a vanilla resource pack (directory with assets/minecraft/... or a .zip) there. " + + "/pixelPic is disabled until a pack is available."); + return; + } - try (JarFile jar = new JarFile(jarFile)) { - Enumeration entries = jar.entries(); + this.resourcePack = pack.get(); + AssetReader reader = new AssetReader(resourcePack); + TextureCache textures = new TextureCache(resourcePack); + BlockModelRegistry registry = new BlockModelRegistry(reader, textures); + BiomeTintProvider tintProvider = new BiomeTintProvider(textures); - while (entries.hasMoreElements()) { - JarEntry entry = entries.nextElement(); - String entryName = entry.getName(); + eu.mhsl.minecraft.pixelpics.render.entity.cem.CemModelLoader cemLoader = + new eu.mhsl.minecraft.pixelpics.render.entity.cem.CemModelLoader(); + try (java.io.InputStream in = getResource("cem/cem_template_models.json")) { + int n = in == null ? 0 : cemLoader.load(in, getLogger()); + getLogger().info("Loaded " + n + " CEM entity models."); + } catch (Exception e) { + getLogger().severe("Failed to load CEM entity models: " + e.getMessage()); + } + eu.mhsl.minecraft.pixelpics.assets.SkinCache skinCache = new eu.mhsl.minecraft.pixelpics.assets.SkinCache(); + eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker entityBaker = + new eu.mhsl.minecraft.pixelpics.render.entity.cem.CemBaker(cemLoader, textures, skinCache); - // Nur JSON-Dateien im gewünschten Ordner - if (entryName.startsWith(resourcePath) && entryName.endsWith(".json")) { - InputStream in = getResource(entryName); - if (in == null) continue; + this.screenRenderer = new DefaultScreenRenderer(registry, tintProvider, textures, entityBaker, getLogger()); + // Warm the map palette on the main thread so off-thread dithering never triggers its first init. + eu.mhsl.minecraft.pixelpics.utils.MapColorPalette.size(); + getLogger().info("PixelPics renderer initialized with resource pack assets."); + } - File outFile = new File(getDataFolder(), entryName); - outFile.getParentFile().mkdirs(); // Ordnerstruktur sicherstellen - - try (OutputStream out = new FileOutputStream(outFile)) { - byte[] buffer = new byte[1024]; - int len; - while ((len = in.read(buffer)) != -1) { - out.write(buffer, 0, len); - } - System.out.println("Extrahiert: " + entryName); - } - - in.close(); - } - } - } - } catch (IOException | URISyntaxException e) { - e.printStackTrace(); + @Override + public void onDisable() { + if (resourcePack != null) { + resourcePack.close(); + resourcePack = null; } } - public Renderer getScreenRenderer() { - if(this.screenRenderer == null) this.screenRenderer = new DefaultScreenRenderer(); + /** The renderer, or {@code null} when no resource pack is available (degraded mode). */ + public DefaultScreenRenderer getScreenRenderer() { return this.screenRenderer; } 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 7f2fbcd..f32919b 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/commands/PixelPicsCommand.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/commands/PixelPicsCommand.java @@ -1,8 +1,14 @@ package eu.mhsl.minecraft.pixelpics.commands; -import eu.mhsl.minecraft.pixelpics.utils.ImageMapRenderer; 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.Material; import org.bukkit.command.Command; @@ -15,50 +21,128 @@ import org.bukkit.map.MapView; import org.bukkit.persistence.PersistentDataType; import org.jetbrains.annotations.NotNull; -import javax.imageio.ImageIO; import java.awt.image.BufferedImage; -import java.io.File; -import java.io.IOException; +import java.util.List; +import java.util.Set; import java.util.UUID; public class PixelPicsCommand implements CommandExecutor { + + private static final int DEFAULT_CLEANUP_DAYS = 30; + @Override - public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String @NotNull [] args) { - if(!(sender instanceof Player player)) - throw new IllegalStateException("Dieser Command kann nur von einem Spieler ausgeführt werden!"); - - if(args.length > 0) - return false; - - // render image - Resolution resolution = new Resolution(Resolution.Pixels._128P, Resolution.AspectRatio._1_1); - BufferedImage image = Main.getInstance().getScreenRenderer().render(player.getEyeLocation(), resolution); - - // save image - UUID imageId = UUID.randomUUID(); - File imageFolder = new File(Main.getInstance().getDataFolder(), "images"); - try { - if(!imageFolder.exists() && !imageFolder.mkdirs()) - throw new IOException("Failed to create folders for image output!"); - - ImageIO.write(image, "png", new File(imageFolder, String.format("%s.png", imageId))); - } catch (IOException e) { - throw new RuntimeException("Failed to save image to disk!"); + public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, + @NotNull String @NotNull [] args) { + if (args.length >= 1 && args[0].equalsIgnoreCase("cleanup")) { + return cleanup(sender, args); } - // image item + if (!(sender instanceof Player player)) { + sender.sendMessage(Component.text("Dieser Command kann nur von einem Spieler ausgeführt werden!", + NamedTextColor.RED)); + return true; + } + 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.", + 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, imageId.toString()); - - // display image - MapView mapView = Bukkit.createMap(Bukkit.getWorlds().getFirst()); - mapView.getRenderers().forEach(mapView::removeRenderer); - mapView.addRenderer(new ImageMapRenderer(image)); + meta.getPersistentDataContainer().set(Main.getInstance().pictureIdFlag, + PersistentDataType.STRING, UUID.randomUUID().toString()); meta.setMapView(mapView); - map.setItemMeta(meta); player.getInventory().addItem(map); + player.sendMessage(Component.text("📸 Aufnahme wird entwickelt …", NamedTextColor.GRAY)); + + // Trace + dither off-thread, then start the developing animation on the main thread. + Main plugin = Main.getInstance(); + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { + BufferedImage image; + byte[] indices; + try { + image = renderer.execute(job); + indices = MapImageDither.dither(image); + } catch (Exception e) { + plugin.getLogger().warning("Render failed: " + e.getMessage()); + Bukkit.getScheduler().runTask(plugin, () -> + player.sendMessage(Component.text("Rendern fehlgeschlagen.", NamedTextColor.RED))); + return; + } + BufferedImage finalImage = image; + byte[] finalIndices = indices; + Bukkit.getScheduler().runTask(plugin, () -> { + MapManager.saveImage(finalImage, id); + MapManager.saveIndices(finalIndices, id); + mapRenderer.develop(finalIndices); + player.sendMessage(Component.text("✅ Aufnahme erstellt!", NamedTextColor.GREEN)); + }); + }); + + return true; + } + + /** {@code /pixelPic cleanup [days]} dry-run; {@code /pixelPic cleanup confirm [days]} deletes orphans. */ + private boolean cleanup(CommandSender sender, String[] args) { + if (!sender.hasPermission("pixelpic.admin") && !sender.isOp()) { + sender.sendMessage(Component.text("Dafür fehlt dir die Berechtigung (pixelpic.admin).", NamedTextColor.RED)); + return true; + } + + boolean confirm = false; + int days = DEFAULT_CLEANUP_DAYS; + for (int i = 1; i < args.length; i++) { + if (args[i].equalsIgnoreCase("confirm")) confirm = true; + else { + try { + days = Math.max(0, Integer.parseInt(args[i])); + } catch (NumberFormatException ignored) { + } + } + } + + Set inUse = MapManager.collectInUseMapIds(); + long cutoff = System.currentTimeMillis() - days * 86_400_000L; + List candidates = MapManager.listStored().stream() + .filter(s -> !inUse.contains(s.id()) && s.lastModified() < cutoff) + .toList(); + + if (candidates.isEmpty()) { + sender.sendMessage(Component.text("Keine aufräumbaren Aufnahmen gefunden (älter als " + days + + " Tage und nicht in Benutzung).", NamedTextColor.YELLOW)); + return true; + } + + if (!confirm) { + sender.sendMessage(Component.text(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, NamedTextColor.GRAY)); + return true; + } + + int deleted = 0; + for (MapManager.StoredMap s : candidates) { + if (MapManager.delete(s.id())) deleted++; + } + sender.sendMessage(Component.text(deleted + " Aufnahme(n) gelöscht.", NamedTextColor.GREEN)); 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 new file mode 100644 index 0000000..e4ca940 --- /dev/null +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/listeners/OnMapInitialize.java @@ -0,0 +1,32 @@ +package eu.mhsl.minecraft.pixelpics.listeners; + +import eu.mhsl.minecraft.pixelpics.utils.ImageMapRenderer; +import eu.mhsl.minecraft.pixelpics.utils.MapImageDither; +import eu.mhsl.minecraft.pixelpics.utils.MapManager; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.server.MapInitializeEvent; + +import java.awt.image.BufferedImage; + +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; + } + + // 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/utils/ImageMapRenderer.java b/src/main/java/eu/mhsl/minecraft/pixelpics/utils/ImageMapRenderer.java index 6194dab..5b377d6 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/utils/ImageMapRenderer.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/utils/ImageMapRenderer.java @@ -8,39 +8,114 @@ import org.jetbrains.annotations.NotNull; import java.awt.image.BufferedImage; +/** + * 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. + */ public class ImageMapRenderer extends MapRenderer { - public static final int MAP_SIZE = 128; - private final BufferedImage image; - private boolean alreadyRendered = false; + public static final int MAP_SIZE = MapImageDither.SIZE; + private static final long DEVELOP_MILLIS = 3000; + + private static byte filmIndex = 0; + private static boolean filmResolved = false; + + private volatile byte[] indices; // MAP_SIZE*MAP_SIZE, null while still rendering + private final boolean animate; + private volatile long developStart = 0; // 0 until indices are available + private boolean finished = false; + private boolean blankDrawn = false; + + /** Persisted/instant map: draw the full picture immediately, no 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(image, 0, 0); + this(MapImageDither.dither(image)); } - public ImageMapRenderer(BufferedImage image, int x, int y) { - this.image = this.recalculateInput(image, x, y); + /** Pending capture: shows blank film until {@link #develop} supplies the picture. */ + public ImageMapRenderer() { + this.indices = null; + this.animate = true; } - private BufferedImage recalculateInput(BufferedImage input, int x, int y) { - if (x * MAP_SIZE > input.getWidth() || y * MAP_SIZE > input.getHeight()) - throw new RuntimeException(String.format("Input image mus match a multiple of x and y with %d", MAP_SIZE)); + public byte[] getIndices() { + return indices; + } - int x1 = (int) (double) (x * MAP_SIZE); - int y1 = (int) (double) (y * MAP_SIZE); - - int x2 = (int) (double) Math.min(input.getWidth(), ((x + 1) * MAP_SIZE)); - int y2 = (int) (double) Math.min(input.getHeight(), ((y + 1) * MAP_SIZE)); - - if (x2 - x1 <= 0 || y2 - y1 <= 0) - throw new RuntimeException("Invalid Image dimensions!"); - - return input.getSubimage(x1, y1, x2 - x1, y2 - y1); + /** Supplies the finished picture and starts the developing animation. */ + public void develop(byte[] indices) { + this.indices = indices; + this.developStart = System.currentTimeMillis(); + this.finished = false; } @Override public void render(@NotNull MapView map, @NotNull MapCanvas canvas, @NotNull Player player) { - if(this.alreadyRendered) return; - canvas.drawImage(0, 0, this.image); - this.alreadyRendered = true; + if (finished) return; + byte[] data = this.indices; + + // Still rendering: show a blank "film" once. + if (data == null) { + if (!blankDrawn) { + fill(canvas, film()); + blankDrawn = true; + } + return; + } + + if (!animate) { + blit(canvas, data, 1.0); + finished = true; + return; + } + + double progress = (System.currentTimeMillis() - developStart) / (double) DEVELOP_MILLIS; + 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(); + 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); + } + } + } + + @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) { + int h = (x * 374761393 + y * 668265263); + h = (h ^ (h >>> 13)) * 1274126177; + double noise = ((h >>> 8) & 0xFFFF) / 65535.0; + double sweep = y / (double) (MAP_SIZE - 1); + return Math.min(0.999, noise * 0.7 + sweep * 0.3); + } + + private static byte film() { + if (!filmResolved) { + filmIndex = MapColorPalette.mapIndex(MapColorPalette.nearestPos(206, 202, 190)); + filmResolved = true; + } + return filmIndex; } } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapColorPalette.java b/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapColorPalette.java new file mode 100644 index 0000000..8b63913 --- /dev/null +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapColorPalette.java @@ -0,0 +1,102 @@ +package eu.mhsl.minecraft.pixelpics.utils; + +import org.bukkit.map.MapPalette; + +import java.awt.Color; +import java.util.ArrayList; +import java.util.List; + +/** + * 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). + */ +public final class MapColorPalette { + + private static volatile boolean initialized = false; + private static byte[] indices; + private static int[] rgb; + private static double[][] lab; + + private MapColorPalette() {} + + 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 == null || 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())); + } + 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); + } + initialized = true; + } + + public static int size() { + ensure(); + return indices.length; + } + + /** Index into the internal arrays of the perceptually nearest palette color. */ + 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]; + 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) { + best = d; + bestPos = i; + } + } + return bestPos; + } + + public static byte mapIndex(int pos) { + return indices[pos]; + } + + public static int mapRgb(int pos) { + return rgb[pos]; + } + + private static double[] rgbToLab(int r, int g, int b) { + double rl = pivotSrgb(r), gl = pivotSrgb(g), bl = pivotSrgb(b); + // sRGB -> XYZ (D65) + double x = rl * 0.4124 + gl * 0.3576 + bl * 0.1805; + double y = rl * 0.2126 + gl * 0.7152 + bl * 0.0722; + double z = rl * 0.0193 + gl * 0.1192 + bl * 0.9505; + double fx = pivotXyz(x / 0.95047); + double fy = pivotXyz(y); + double fz = pivotXyz(z / 1.08883); + return new double[]{116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)}; + } + + private static double pivotSrgb(int c) { + double v = c / 255.0; + return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); + } + + private static double pivotXyz(double t) { + return t > 0.008856 ? Math.cbrt(t) : (7.787 * t + 16.0 / 116.0); + } +} diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapImageDither.java b/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapImageDither.java new file mode 100644 index 0000000..ff592c4 --- /dev/null +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapImageDither.java @@ -0,0 +1,73 @@ +package eu.mhsl.minecraft.pixelpics.utils; + +import java.awt.image.BufferedImage; + +/** + * Quantizes a rendered image to Minecraft map color indices using dampened Floyd–Steinberg error + * diffusion with perceptual (CIELAB) nearest-color matching. The result is a {@code 128*128} byte + * array of palette indices, ready to blit onto a {@link org.bukkit.map.MapCanvas}. + * + *

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. + */ +public final class MapImageDither { + + public static final int SIZE = 128; + + /** Fraction of quantization error diffused. Low values keep gradients smooth without visible noise. */ + private static final float DITHER_STRENGTH = 0.35f; + + private MapImageDither() {} + + /** Returns a {@code SIZE*SIZE} array of map palette indices (row-major, y*SIZE + x). */ + public static byte[] dither(BufferedImage image) { + int w = Math.min(SIZE, image.getWidth()); + int h = Math.min(SIZE, image.getHeight()); + byte[] out = new byte[SIZE * SIZE]; + + float[][] buf = new float[h][w * 3]; + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + int argb = image.getRGB(x, y); + buf[y][x * 3] = (argb >> 16) & 0xFF; + buf[y][x * 3 + 1] = (argb >> 8) & 0xFF; + buf[y][x * 3 + 2] = argb & 0xFF; + } + } + + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + int i = x * 3; + int r = clamp(buf[y][i]); + int g = clamp(buf[y][i + 1]); + int b = clamp(buf[y][i + 2]); + + int pos = MapColorPalette.nearestPos(r, g, b); + out[y * SIZE + x] = MapColorPalette.mapIndex(pos); + + int chosen = MapColorPalette.mapRgb(pos); + float er = (r - ((chosen >> 16) & 0xFF)) * DITHER_STRENGTH; + float eg = (g - ((chosen >> 8) & 0xFF)) * DITHER_STRENGTH; + float eb = (b - (chosen & 0xFF)) * DITHER_STRENGTH; + + spread(buf, y, x + 1, w, h, er, eg, eb, 7f / 16f); + spread(buf, y + 1, x - 1, w, h, er, eg, eb, 3f / 16f); + spread(buf, y + 1, x, w, h, er, eg, eb, 5f / 16f); + spread(buf, y + 1, x + 1, w, h, er, eg, eb, 1f / 16f); + } + } + return out; + } + + private static void spread(float[][] buf, int y, int x, int w, int h, float er, float eg, float eb, float f) { + if (x < 0 || x >= w || y < 0 || y >= h) return; + int i = x * 3; + buf[y][i] += er * f; + buf[y][i + 1] += eg * f; + buf[y][i + 2] += eb * f; + } + + private static int clamp(float v) { + return v < 0 ? 0 : (v > 255 ? 255 : Math.round(v)); + } +} diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapManager.java b/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapManager.java new file mode 100644 index 0000000..aea0de0 --- /dev/null +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapManager.java @@ -0,0 +1,173 @@ +package eu.mhsl.minecraft.pixelpics.utils; + +import eu.mhsl.minecraft.pixelpics.Main; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.entity.ItemFrame; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.MapMeta; +import org.bukkit.map.MapView; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.HashSet; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 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. + */ +public class MapManager { + + private static final Pattern FILE_ID = Pattern.compile("mapId_(\\d+)\\.(png|mcmap)"); + private static final int INDEX_BYTES = ImageMapRenderer.MAP_SIZE * ImageMapRenderer.MAP_SIZE; + + private MapManager() {} + + private static File imageFolder() { + return new File(Main.getInstance().getDataFolder(), "images"); + } + + public static void attachView(MapView mapView, ImageMapRenderer renderer) { + mapView.getRenderers().clear(); + mapView.setScale(MapView.Scale.FARTHEST); + mapView.addRenderer(renderer); + mapView.setTrackingPosition(false); + } + + public static File getImagePath(int mapId) { + return new File(imageFolder(), String.format("mapId_%d.png", mapId)); + } + + public static File getIndexPath(int mapId) { + return new File(imageFolder(), String.format("mapId_%d.mcmap", mapId)); + } + + private static boolean ensureFolder() { + File folder = imageFolder(); + if (!folder.exists() && !folder.mkdirs()) { + Main.getInstance().getLogger().warning("Failed to create image output folder: " + folder); + return false; + } + return true; + } + + public static void saveImage(BufferedImage image, int id) { + if (!ensureFolder()) return; + try { + ImageIO.write(image, "png", getImagePath(id)); + } catch (IOException e) { + Main.getInstance().getLogger().warning("Failed to save map image " + id + ": " + e.getMessage()); + } + } + + public static void saveIndices(byte[] indices, int id) { + if (indices == null || indices.length != INDEX_BYTES || !ensureFolder()) return; + try { + Files.write(getIndexPath(id).toPath(), indices); + } catch (IOException e) { + Main.getInstance().getLogger().warning("Failed to save map index cache " + id + ": " + e.getMessage()); + } + } + + /** Reads the cached dithered indices, or {@code null} if absent/invalid. */ + public static byte[] readIndices(int id) { + File path = getIndexPath(id); + if (!path.isFile()) return null; + try { + byte[] data = Files.readAllBytes(path.toPath()); + return data.length == INDEX_BYTES ? data : null; + } catch (IOException e) { + return null; + } + } + + /** Reads the stored image, or {@code null} if it does not exist or cannot be read. */ + public static BufferedImage readImage(int id) { + File path = getImagePath(id); + if (!path.isFile()) return null; + try { + return ImageIO.read(path); + } catch (IOException e) { + Main.getInstance().getLogger().warning("Failed to read map image " + id + ": " + e.getMessage()); + return null; + } + } + + public static boolean isManaged(int id) { + 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. */ + 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(); + if (files == null) return result; + Set seen = new HashSet<>(); + for (File f : files) { + Matcher m = FILE_ID.matcher(f.getName()); + if (!m.matches()) continue; + int id = Integer.parseInt(m.group(1)); + if (!seen.add(id)) continue; + long lm = Math.max(lastModified(getImagePath(id)), lastModified(getIndexPath(id))); + result.add(new StoredMap(id, lm)); + } + return result; + } + + private static long lastModified(File f) { + return f.isFile() ? f.lastModified() : 0L; + } + + /** Map ids referenced by currently-loaded item frames and online players (inventory + ender chest). */ + public static Set collectInUseMapIds() { + Set inUse = new HashSet<>(); + for (Player p : Bukkit.getOnlinePlayers()) { + addFromContents(inUse, p.getInventory().getContents()); + addFromContents(inUse, p.getEnderChest().getContents()); + addFromItem(inUse, p.getItemOnCursor()); + } + for (World w : Bukkit.getWorlds()) { + for (ItemFrame frame : w.getEntitiesByClass(ItemFrame.class)) { + addFromItem(inUse, frame.getItem()); + } + } + return inUse; + } + + 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) { + set.add(meta.getMapView().getId()); + } + } + + /** Deletes both files for a map id. Returns true if anything was deleted. */ + public static boolean delete(int id) { + boolean deleted = false; + File png = getImagePath(id); + File idx = getIndexPath(id); + if (png.isFile()) deleted |= png.delete(); + if (idx.isFile()) deleted |= idx.delete(); + return deleted; + } +} diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 40e875b..4d9e414 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -1,9 +1,15 @@ -name: PixelPic -version: '1.0-SNAPSHOT' +name: PixelPics +version: '${version}' main: eu.mhsl.minecraft.pixelpics.Main -api-version: '1.21.7' +api-version: '1.21' commands: pixelPic: permission: "pixelpic.use" - usage: "pixelpic take" - test: + usage: "/pixelPic [cleanup [confirm] [days]]" +permissions: + pixelpic.use: + description: "Allows taking PixelPics camera screenshots" + default: true + pixelpic.admin: + description: "Allows managing PixelPics (e.g. cleanup)" + default: op