wire up rendering + build config

This commit is contained in:
2026-06-07 18:57:53 +02:00
parent 211c7e8479
commit f1844a9dd9
10 changed files with 665 additions and 155 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/modules/PixelPic.main.iml" filepath="$PROJECT_DIR$/.idea/modules/PixelPic.main.iml" />
<module fileurl="file://$PROJECT_DIR$/.idea/modules/PixelPics.PixelPic.main.iml" filepath="$PROJECT_DIR$/.idea/modules/PixelPics.PixelPic.main.iml" />
<module fileurl="file://$PROJECT_DIR$/.idea/modules/eu.mhsl.minecraft.pixelpic.PixelPic.main.iml" filepath="$PROJECT_DIR$/.idea/modules/eu.mhsl.minecraft.pixelpic.PixelPic.main.iml" />
<module fileurl="file://$PROJECT_DIR$/.idea/modules/PixelPics.main.iml" filepath="$PROJECT_DIR$/.idea/modules/PixelPics.main.iml" />
</modules>
</component>
</project>
+1 -1
View File
@@ -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
@@ -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<ResourcePack> 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<JarEntry> 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;
}
@@ -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<Integer> inUse = MapManager.collectInUseMapIds();
long cutoff = System.currentTimeMillis() - days * 86_400_000L;
List<MapManager.StoredMap> 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;
}
}
@@ -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));
}
}
@@ -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 FloydSteinberg 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;
}
}
+11 -5
View File
@@ -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