wire up rendering + build config
This commit is contained in:
Generated
+1
-1
@@ -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
@@ -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 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user