wire up rendering + build config
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user