{@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 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