wire up rendering + build config

This commit is contained in:
MineTec committed 2026-06-07 18:57:53 +02:00
1 parent 211c7e8479
commit f1844a9dd9
10 files changed
+665 -155

No files matched your search

@@ -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.
*
* <p>{@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;
}
}
@@ -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<Byte> idx = new ArrayList<>();
List<Integer> rgbs = new ArrayList<>();
List<double[]> 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);
}
}
@@ -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}.
*
* <p>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));
}
}
@@ -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<StoredMap> listStored() {
Set<StoredMap> result = new HashSet<>();
File folder = imageFolder();
File[] files = folder.listFiles();
if (files == null) return result;
Set<Integer> 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<Integer> collectInUseMapIds() {
Set<Integer> 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<Integer> set, ItemStack[] contents) {
if (contents == null) return;
for (ItemStack item : contents) addFromItem(set, item);
}
private static void addFromItem(Set<Integer> 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;
}
}