simplify survival, map and plugin code: map palette built once in a static initializer instead of a synchronized check per dither lookup, film colour as a constant, one blit for blank film and develop, shared crafting result for prepare and craft, unused photo scan state and dead null checks removed, non-italic text helper for item names, pdc getOrDefault, private map file paths, single map-init attach path, listener registration in one loop, unused image constructor removed (pptest dithers itself), comments trimmed

This commit is contained in:
MineTec committed 2026-11-05 02:28:46 +01:00
1 parent 44f2b3c59c
commit b242adb6d2
13 files changed
+209 -378

No files matched your search

@@ -23,13 +23,15 @@ import eu.mhsl.minecraft.pixelpics.survival.JoinListener;
import eu.mhsl.minecraft.pixelpics.survival.SurvivalRecipes;
import eu.mhsl.minecraft.pixelpics.utils.MapColorPalette;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey;
import org.bukkit.World;
import org.bukkit.event.Listener;
import org.bukkit.plugin.java.JavaPlugin;
public final class Main extends JavaPlugin {
@@ -40,13 +42,10 @@ public final class Main extends JavaPlugin {
private DefaultScreenRenderer screenRenderer;
private RenderManager renderManager;
// PDC keys: photo picture id (STRING), camera/film markers (BYTE), loaded film on a camera (INTEGER)
public final NamespacedKey pictureIdFlag = new NamespacedKey(this, "imageid");
/** Marks a {@code PLAYER_HEAD} as a camera (BYTE 1). */
public final NamespacedKey cameraMarker = new NamespacedKey(this, "camera");
/** Loaded film count stored on a camera (INTEGER, 0..{@code CameraItems.MAX_FILM}). */
public final NamespacedKey filmCountKey = new NamespacedKey(this, "filmcount");
/** Marks a {@code PLAYER_HEAD} as a film roll (BYTE 1). */
public final NamespacedKey filmMarker = new NamespacedKey(this, "film");
@Override
@@ -56,12 +55,11 @@ public final class Main extends JavaPlugin {
saveDefaultConfig();
initRenderManager();
Bukkit.getPluginManager().registerEvents(new OnMapInitialize(), this);
Objects.requireNonNull(Bukkit.getPluginCommand("pixelPic")).setExecutor(new PixelPicsCommand());
Bukkit.getPluginManager().registerEvents(new CameraListener(), this);
Bukkit.getPluginManager().registerEvents(new CraftingListener(), this);
Bukkit.getPluginManager().registerEvents(new JoinListener(), this);
Objects.requireNonNull(getCommand("pixelPic")).setExecutor(new PixelPicsCommand());
for (Listener listener :
List.of(new OnMapInitialize(), new CameraListener(), new CraftingListener(), new JoinListener())) {
Bukkit.getPluginManager().registerEvents(listener, this);
}
SurvivalRecipes.register();
initRenderer();
@@ -114,7 +112,7 @@ public final class Main extends JavaPlugin {
// optional newer dump in the data folder (no recompile on MC updates), else the bundled copy.
File dump = new File(getDataFolder(), "vanilla_entity_models.json");
try (InputStream in =
dump.isFile() ? new java.io.FileInputStream(dump) : getResource("cem/vanilla_entity_models.json")) {
dump.isFile() ? new FileInputStream(dump) : getResource("cem/vanilla_entity_models.json")) {
if (in != null) {
long t0 = System.nanoTime();
int n = cemLoader.load(in, getLogger());
@@ -125,8 +123,8 @@ public final class Main extends JavaPlugin {
} catch (Exception e) {
getLogger().severe("Failed to load vanilla entity models: " + e.getMessage());
}
List<File> worldFolders = new ArrayList<>();
for (org.bukkit.World w : getServer().getWorlds()) worldFolders.add(w.getWorldFolder());
List<File> worldFolders =
getServer().getWorlds().stream().map(World::getWorldFolder).toList();
int datapackVariants = DatapackVariants.load(cemLoader, worldFolders, getLogger());
if (datapackVariants > 0) getLogger().info("Loaded " + datapackVariants + " datapack mob variants.");
SkinCache skinCache = new SkinCache();
@@ -143,7 +141,7 @@ public final class Main extends JavaPlugin {
blockEntityBaker,
getLogger(),
renderManager.tracePool());
// Warm the map palette on the main thread so off-thread dithering never triggers its first init.
// Initialise the map palette on the main thread (MapPalette) before any off-thread dithering.
MapColorPalette.size();
getLogger().info("PixelPics renderer initialized with resource pack assets.");
}
@@ -166,7 +164,6 @@ public final class Main extends JavaPlugin {
return this.screenRenderer;
}
/** The render queue/limiter; always available after {@code onEnable}. */
public RenderManager getRenderManager() {
return this.renderManager;
}
@@ -33,7 +33,7 @@ public class PixelPicsCommand implements CommandExecutor {
}
if (args.length > 0) return false;
// Debug shortcut: render a photo from the player's view without needing a camera or film.
// Debug render without camera or film.
if (!player.hasPermission("pixelpic.admin")) {
player.sendActionBar(
Component.text("Dafür fehlt dir die Berechtigung — nutze eine Kamera.", NamedTextColor.RED));
@@ -79,12 +79,12 @@ public class PixelPicsCommand implements CommandExecutor {
if (!confirm) {
sender.sendMessage(Component.text(
candidates.size() + " Aufnahme(n) könnten gelöscht werden " + "(älter als " + days
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,
"Achtung: Maps in lange ungeladenen Bereichen werden hierbei nicht erkannt. Zum Löschen: /pixelPic cleanup confirm "
+ days,
NamedTextColor.GRAY));
return true;
}
@@ -8,24 +8,20 @@ import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.server.MapInitializeEvent;
/** Reattaches stored photos to their maps when the server loads them. */
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;
if (indices == null) {
// No index cache (yet): dither the stored PNG once and cache the result.
BufferedImage image = MapManager.readImage(mapId);
if (image == null) return;
indices = MapImageDither.dither(image);
MapManager.saveIndices(indices, mapId);
}
// 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));
}
}
@@ -17,42 +17,34 @@ import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.inventory.meta.SkullMeta;
import org.bukkit.inventory.meta.components.EquippableComponent;
import org.bukkit.persistence.PersistentDataContainer;
import org.bukkit.persistence.PersistentDataType;
/**
* Factory and identity helpers for the survival items: the camera and the film roll are custom-skinned
* {@code PLAYER_HEAD}s (the texture renders client-side without a resource pack), the photo is the
* existing {@code FILLED_MAP}. Identity is carried by PDC markers (robust against renames), and the
* camera's loaded film count lives in PDC as well.
* Camera and film roll are custom-skinned {@code PLAYER_HEAD}s (the skin needs no client resource pack), the photo is
* a {@code FILLED_MAP}. Identity is carried by PDC markers, so renaming does not matter.
*/
public final class CameraItems {
/** Maximum film rolls a camera can hold; one roll yields one photo. */
public static final int MAX_FILM = 8;
private static final int CAMERA_MODEL_DATA = 1001;
private static final int FILM_MODEL_DATA = 1002;
static final String CAMERA_TEXTURE_B64 =
private static final String CAMERA_TEXTURE_B64 =
"eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvZDlkMmNiZjAyZDMwOGI2MDY1YTZmZThjNjU3MWI2MzU2NjMzZjQxOTJlOGVjNzEyMTNjNzcwNzgwZTNkZTRlMiJ9fX0=";
static final String FILM_TEXTURE_B64 =
private static final String FILM_TEXTURE_B64 =
"eyJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvMTVkMGY0OGJlNzNkYmIwZDJjYjE1NTRjMmUzODZiNWNjM2FiMjFhNGRjYWU4ZmYzOGI3NzRhZDNkMDFkMGE1OSJ9fX0=";
private CameraItems() {}
// --- factories ---
/** A camera holding {@code filmCount} (clamped to 0..{@link #MAX_FILM}) loaded film rolls. */
public static ItemStack createCamera(int filmCount) {
int count = Math.clamp(filmCount, 0, MAX_FILM);
ItemStack item = new ItemStack(Material.PLAYER_HEAD);
SkullMeta meta = (SkullMeta) item.getItemMeta();
applyHead(meta, "pixelpics:camera", CAMERA_TEXTURE_B64);
meta.displayName(Component.text("Kamera", NamedTextColor.WHITE).decoration(TextDecoration.ITALIC, false));
meta.displayName(plain("Kamera", NamedTextColor.WHITE));
meta.setCustomModelData(CAMERA_MODEL_DATA);
// A camera carries per-item state (loaded film count), so it must not stack — otherwise loading
// or shooting a stacked camera would duplicate or desync the whole stack.
// The camera carries per-item state (film count): a stack would duplicate or desync on loading/shooting.
meta.setMaxStackSize(1);
meta.getPersistentDataContainer().set(Main.getInstance().cameraMarker, PersistentDataType.BYTE, (byte) 1);
meta.getPersistentDataContainer().set(Main.getInstance().filmCountKey, PersistentDataType.INTEGER, count);
@@ -62,40 +54,35 @@ public final class CameraItems {
return item;
}
/** A single film roll. */
public static ItemStack createFilm() {
ItemStack item = new ItemStack(Material.PLAYER_HEAD);
SkullMeta meta = (SkullMeta) item.getItemMeta();
applyHead(meta, "pixelpics:film", FILM_TEXTURE_B64);
meta.displayName(Component.text("Filmrolle", NamedTextColor.WHITE).decoration(TextDecoration.ITALIC, false));
meta.displayName(plain("Filmrolle", NamedTextColor.WHITE));
meta.setCustomModelData(FILM_MODEL_DATA);
// Lore leads with "Filmrolle" so the recipe book search finds it by name (see applyCameraLore).
meta.lore(List.of(Component.text("Filmrolle – lädt eine Kamera auf.", NamedTextColor.GRAY)
.decoration(TextDecoration.ITALIC, false)));
// Leads with "Filmrolle" for the recipe book search (see applyCameraLore).
meta.lore(List.of(plain("Filmrolle – lädt eine Kamera auf.", NamedTextColor.GRAY)));
meta.getPersistentDataContainer().set(Main.getInstance().filmMarker, PersistentDataType.BYTE, (byte) 1);
makeUnwearable(meta);
item.setItemMeta(meta);
return item;
}
/** Display name applied to a developed photo map (white, like a normally renamed item). */
public static Component photoDisplayName() {
return Component.text("Foto", NamedTextColor.WHITE).decoration(TextDecoration.ITALIC, false);
return plain("Foto", NamedTextColor.WHITE);
}
/** Returns a copy of {@code camera} with its film count set to {@code newCount} and lore refreshed. */
/** A copy of {@code camera} with the given film count; stays component-equal to {@code createCamera(count)}. */
public static ItemStack withFilmCount(ItemStack camera, int newCount) {
int count = Math.clamp(newCount, 0, MAX_FILM);
ItemStack copy = camera.clone();
SkullMeta meta = (SkullMeta) copy.getItemMeta();
ItemMeta meta = copy.getItemMeta();
meta.getPersistentDataContainer().set(Main.getInstance().filmCountKey, PersistentDataType.INTEGER, count);
applyCameraLore(meta, count);
copy.setItemMeta(meta);
return copy;
}
// --- identity ---
public static boolean isCamera(ItemStack item) {
return hasMarker(item, Main.getInstance().cameraMarker);
}
@@ -104,50 +91,46 @@ public final class CameraItems {
return hasMarker(item, Main.getInstance().filmMarker);
}
/** A photo is a filled map carrying our picture id. */
public static boolean isPhoto(ItemStack item) {
if (item == null || item.getType() != Material.FILLED_MAP || !item.hasItemMeta()) return false;
return item.getItemMeta()
.getPersistentDataContainer()
.has(Main.getInstance().pictureIdFlag, PersistentDataType.STRING);
return item != null
&& item.getType() == Material.FILLED_MAP
&& item.hasItemMeta()
&& item.getItemMeta()
.getPersistentDataContainer()
.has(Main.getInstance().pictureIdFlag, PersistentDataType.STRING);
}
/** Loaded film on a camera, or 0 if the item is not a camera. */
/** Loaded film on a camera, 0 for any other item. */
public static int getFilmCount(ItemStack item) {
if (!isCamera(item)) return 0;
Integer v = item.getItemMeta()
return item.getItemMeta()
.getPersistentDataContainer()
.get(Main.getInstance().filmCountKey, PersistentDataType.INTEGER);
return v == null ? 0 : v;
.getOrDefault(Main.getInstance().filmCountKey, PersistentDataType.INTEGER, 0);
}
// --- internals ---
private static boolean hasMarker(ItemStack item, NamespacedKey key) {
if (item == null || item.getType() != Material.PLAYER_HEAD || !item.hasItemMeta()) return false;
PersistentDataContainer pdc = item.getItemMeta().getPersistentDataContainer();
return pdc.has(key, PersistentDataType.BYTE);
return item != null
&& item.getType() == Material.PLAYER_HEAD
&& item.hasItemMeta()
&& item.getItemMeta().getPersistentDataContainer().has(key, PersistentDataType.BYTE);
}
private static Component plain(String text, NamedTextColor color) {
return Component.text(text, color).decoration(TextDecoration.ITALIC, false);
}
private static void applyCameraLore(ItemMeta meta, int count) {
// The first line carries the word "Kamera" so the recipe book search (which indexes the result
// item's lore, not its custom display name — every item here is a PLAYER_HEAD named "Spielerkopf"
// by default) can find it when the player types "kamera".
// The recipe book search indexes the result's lore, not its custom name (every item here is a
// PLAYER_HEAD), so the first line carries the word "Kamera".
meta.lore(List.of(
Component.text("Kamera für Sofortbildfotos", NamedTextColor.GRAY)
.decoration(TextDecoration.ITALIC, false),
Component.text(
"Film: " + count + " / " + MAX_FILM,
count > 0 ? NamedTextColor.YELLOW : NamedTextColor.RED)
.decoration(TextDecoration.ITALIC, false),
Component.text("Rechtsklick: Foto aufnehmen", NamedTextColor.DARK_GRAY)
.decoration(TextDecoration.ITALIC, false)));
plain("Kamera für Sofortbildfotos", NamedTextColor.GRAY),
plain("Film: " + count + " / " + MAX_FILM, count > 0 ? NamedTextColor.YELLOW : NamedTextColor.RED),
plain("Rechtsklick: Foto aufnehmen", NamedTextColor.DARK_GRAY)));
}
/**
* Overrides the {@code PLAYER_HEAD}'s default head-equippable component so the item cannot be worn:
* the slot is moved to {@code SADDLE} (no player slot accepts it, so the helmet slot rejects it —
* blocking right-click, shift-click and dispenser equipping) and right-click swap is disabled.
* Overrides the head's equippable component: no player slot accepts {@code SADDLE}, which blocks right-click,
* shift-click and dispenser equipping; swapping is disabled as well.
*/
private static void makeUnwearable(ItemMeta meta) {
EquippableComponent eq = meta.getEquippable();
@@ -157,7 +140,6 @@ public final class CameraItems {
}
private static void applyHead(SkullMeta meta, String seed, String textureB64) {
if (textureB64 == null || textureB64.isBlank()) return;
UUID synthetic = UUID.nameUUIDFromBytes(seed.getBytes(StandardCharsets.UTF_8));
PlayerProfile profile = Bukkit.createProfileExact(synthetic, null);
profile.setProperty(new ProfileProperty("textures", textureB64));
@@ -15,9 +15,8 @@ import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.inventory.ItemStack;
/**
* Takes a photo when a player right-clicks while holding a camera. Consumes one loaded film per shot;
* with no film loaded it gives a short fail feedback. Guards against the well-known double-fire of
* {@link PlayerInteractEvent} (off-hand fire + air/block) via a hand filter and a per-player cooldown.
* Right-click with a camera takes a photo and uses one film. {@link PlayerInteractEvent} can fire twice per click
* (off hand, air + block), hence the hand filter and the per-player cooldown.
*/
public class CameraListener implements Listener {
@@ -33,25 +32,19 @@ public class CameraListener implements Listener {
ItemStack inHand = event.getItem();
if (!CameraItems.isCamera(inHand)) return;
event.setCancelled(true);
Player player = event.getPlayer();
long now = System.currentTimeMillis();
if (now - lastUse.getOrDefault(player.getUniqueId(), 0L) < COOLDOWN_MILLIS) {
event.setCancelled(true);
return;
}
if (now - lastUse.getOrDefault(player.getUniqueId(), 0L) < COOLDOWN_MILLIS) return;
lastUse.put(player.getUniqueId(), now);
event.setCancelled(true);
int film = CameraItems.getFilmCount(inHand);
if (film <= 0) {
// Dry "empty shutter" click — no film loaded.
player.playSound(player.getLocation(), Sound.BLOCK_DISPENSER_FAIL, 1f, 1.2f);
player.sendActionBar(Component.text("Kein Film geladen!", NamedTextColor.RED));
return;
}
// Only charge film if the shot was actually accepted (renderer ready, not queue-rejected).
if (PhotoService.takePhoto(player)) {
player.getInventory().setItemInMainHand(CameraItems.withFilmCount(inHand, film - 1));
}
@@ -21,12 +21,10 @@ import org.bukkit.inventory.meta.MapMeta;
import org.bukkit.persistence.PersistentDataType;
/**
* Drives the two dynamic recipes whose inputs carry variable NBT: <em>load film</em>
* (camera + film → camera+1) and <em>copy photo</em> (photo + film → 2 photos sharing the same map).
* {@link PrepareItemCraftEvent} computes the result preview and validates the loose
* {@code MaterialChoice} matches; {@link CraftItemEvent} performs the consumption/doubling manually
* (the vanilla machinery cannot, given the variable-NBT ingredients) and blocks shift-click and any
* attempt to leak our custom items into other recipes.
* Load film (camera + film -> camera with one more film) and copy photo (photo + film -> a second photo on the same
* map) have variable-NBT inputs, so the result is computed here: {@link PrepareItemCraftEvent} shows it and rejects
* loose {@code MaterialChoice} matches, {@link CraftItemEvent} performs the craft by hand. Shift-click is refused, and
* our items never feed any other recipe.
*/
public class CraftingListener implements Listener {
@@ -36,30 +34,16 @@ public class CraftingListener implements Listener {
NONE
}
/** Scanned crafting grid: the relevant items and whether any of our marked items are present. */
private record Scan(Kind kind, ItemStack camera, ItemStack film, ItemStack photo, boolean hasOurItems) {}
private record Scan(Kind kind, ItemStack camera, ItemStack photo, boolean hasOurItems) {}
@EventHandler
public void onPrepare(PrepareItemCraftEvent event) {
CraftingInventory inv = event.getInventory();
Scan scan = scan(inv.getMatrix());
switch (scan.kind()) {
case LOAD -> {
int count = CameraItems.getFilmCount(scan.camera());
inv.setResult(
count >= CameraItems.MAX_FILM
? null
: single(CameraItems.withFilmCount(scan.camera(), count + 1)));
}
case COPY -> inv.setResult(buildPhotoCopy(scan.photo()));
case NONE -> {
// Block our loose MaterialChoice recipes from matching garbage, and stop our items
// from being consumed by any unrelated (vanilla) recipe.
if (isDynamicRecipe(event.getRecipe()) || scan.hasOurItems()) {
inv.setResult(null);
}
}
if (scan.kind() != Kind.NONE) {
inv.setResult(result(scan));
} else if (isDynamicRecipe(event.getRecipe()) || scan.hasOurItems()) {
inv.setResult(null);
}
}
@@ -67,50 +51,36 @@ public class CraftingListener implements Listener {
public void onCraft(CraftItemEvent event) {
CraftingInventory inv = event.getInventory();
Scan scan = scan(inv.getMatrix());
if (scan.kind() == Kind.NONE) {
// Defensive: never let our items be consumed by another recipe.
if (scan.hasOurItems()) event.setCancelled(true);
return;
}
event.setCancelled(true);
Player player = (Player) event.getWhoClicked();
if (event.isShiftClick()) {
player.sendActionBar(Component.text("Bitte einzeln herstellen.", NamedTextColor.YELLOW));
return;
}
ItemStack result;
ItemStack result = result(scan);
if (result == null) return;
ItemStack[] matrix = inv.getMatrix();
if (scan.kind() == Kind.LOAD) {
int count = CameraItems.getFilmCount(scan.camera());
if (count >= CameraItems.MAX_FILM) return;
result = single(CameraItems.withFilmCount(scan.camera(), count + 1));
consumeFirst(matrix, CameraItems::isCamera);
consumeFirst(matrix, CameraItems::isFilm);
} else { // COPY
result = buildPhotoCopy(scan.photo());
if (result == null) return;
consumeFirst(matrix, CameraItems::isFilm); // original photo stays
}
if (scan.kind() == Kind.LOAD) consumeFirst(matrix, CameraItems::isCamera);
consumeFirst(matrix, CameraItems::isFilm); // a copied photo keeps its original
// Apply on the next tick so the click settles before we mutate the grid and hand over the item.
ItemStack finalResult = result;
// Next tick, so the click settles before the grid changes and the item is handed over.
Bukkit.getScheduler().runTask(Main.getInstance(), () -> {
inv.setMatrix(matrix);
give(player, finalResult);
give(player, result);
player.playSound(player.getLocation(), Sound.UI_TOAST_IN, 0.7f, 1.4f);
player.updateInventory();
});
}
// --- helpers ---
private static Scan scan(ItemStack[] matrix) {
int cameras = 0, films = 0, photos = 0, others = 0;
ItemStack camera = null, film = null, photo = null;
ItemStack camera = null, photo = null;
for (ItemStack it : matrix) {
if (it == null || it.getType().isAir()) continue;
if (CameraItems.isCamera(it)) {
@@ -118,67 +88,64 @@ public class CraftingListener implements Listener {
camera = it;
} else if (CameraItems.isFilm(it)) {
films++;
film = it;
} else if (CameraItems.isPhoto(it)) {
photos++;
photo = it;
} else others++;
}
boolean ours = cameras + films + photos > 0;
Kind kind = Kind.NONE;
if (others == 0) {
if (cameras == 1 && films == 1 && photos == 0) kind = Kind.LOAD;
else if (photos == 1 && films == 1 && cameras == 0) kind = Kind.COPY;
if (others == 0 && films == 1) {
if (cameras == 1 && photos == 0) kind = Kind.LOAD;
else if (photos == 1 && cameras == 0) kind = Kind.COPY;
}
return new Scan(kind, camera, film, photo, ours);
return new Scan(kind, camera, photo, cameras + films + photos > 0);
}
/** The crafted item, or null when the camera is full or the photo has no map. */
private static ItemStack result(Scan scan) {
if (scan.kind() == Kind.COPY) return photoCopy(scan.photo());
int count = CameraItems.getFilmCount(scan.camera());
if (count >= CameraItems.MAX_FILM) return null;
ItemStack camera = CameraItems.withFilmCount(scan.camera(), count + 1);
camera.setAmount(1); // the grid may hold an old camera stack
return camera;
}
private static boolean isDynamicRecipe(Recipe recipe) {
if (!(recipe instanceof Keyed keyed)) return false;
NamespacedKey key = keyed.getKey();
return SurvivalRecipes.LOAD.equals(key) || SurvivalRecipes.COPY.equals(key);
return recipe instanceof Keyed keyed
&& (SurvivalRecipes.LOAD.equals(keyed.getKey()) || SurvivalRecipes.COPY.equals(keyed.getKey()));
}
/** A second photo referencing the same {@link org.bukkit.map.MapView} and picture id as {@code photo}. */
private static ItemStack buildPhotoCopy(ItemStack photo) {
private static ItemStack photoCopy(ItemStack photo) {
MapMeta src = (MapMeta) photo.getItemMeta();
if (!src.hasMapView() || src.getMapView() == null) return null;
ItemStack copy = new ItemStack(Material.FILLED_MAP);
MapMeta dst = (MapMeta) copy.getItemMeta();
dst.setMapView(src.getMapView());
String pid = src.getPersistentDataContainer().get(Main.getInstance().pictureIdFlag, PersistentDataType.STRING);
if (pid != null) {
dst.getPersistentDataContainer().set(Main.getInstance().pictureIdFlag, PersistentDataType.STRING, pid);
}
NamespacedKey key = Main.getInstance().pictureIdFlag;
dst.getPersistentDataContainer()
.set(
key,
PersistentDataType.STRING,
src.getPersistentDataContainer().get(key, PersistentDataType.STRING));
dst.displayName(CameraItems.photoDisplayName());
copy.setItemMeta(dst);
return copy;
}
/** Forces a crafting result to a single item — one craft yields exactly one camera, never a stack. */
private static ItemStack single(ItemStack item) {
item.setAmount(1);
return item;
}
private static void consumeFirst(ItemStack[] matrix, Predicate<ItemStack> pred) {
for (int i = 0; i < matrix.length; i++) {
ItemStack it = matrix[i];
if (it != null && !it.getType().isAir() && pred.test(it)) {
int amt = it.getAmount();
if (amt <= 1) matrix[i] = null;
else {
it.setAmount(amt - 1);
matrix[i] = it;
}
if (it != null && pred.test(it)) {
if (it.getAmount() <= 1) matrix[i] = null;
else it.setAmount(it.getAmount() - 1);
return;
}
}
}
private static void give(Player player, ItemStack item) {
ItemStack cursor = player.getItemOnCursor();
if (cursor.getType().isAir()) {
if (player.getItemOnCursor().getType().isAir()) {
player.setItemOnCursor(item);
} else {
player.getInventory().addItem(item).values().forEach(left -> player.getWorld()
@@ -9,7 +9,6 @@ import eu.mhsl.minecraft.pixelpics.utils.ImageMapRenderer;
import eu.mhsl.minecraft.pixelpics.utils.MapImageDither;
import eu.mhsl.minecraft.pixelpics.utils.MapManager;
import java.awt.image.BufferedImage;
import java.util.Objects;
import java.util.UUID;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
@@ -23,26 +22,18 @@ import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.MapMeta;
import org.bukkit.map.MapView;
import org.bukkit.persistence.PersistentDataType;
import org.bukkit.util.Vector;
/**
* Renders a photo of a player's current view and delivers it as a developing {@code FILLED_MAP},
* with a shutter sound + flash particles on capture and a chime when the picture finishes developing.
* Heavy work is funneled through {@link RenderManager} (per-player single slot, global concurrency
* limit + queue, bounded low-priority CPU). Shared by the camera item (survival) and the
* {@code /pixelPic} debug command. Film accounting is the caller's responsibility.
* Photographs a player's view into a {@code FILLED_MAP} that develops once the render (throttled by
* {@link RenderManager}) is done. Used by the camera and {@code /pixelPic}; film is the caller's business.
*/
public final class PhotoService {
/** Render result carried back to the main thread: PNG source + dithered map indices. */
private record RenderOutput(BufferedImage image, byte[] indices) {}
private PhotoService() {}
/**
* Captures and delivers a photo. Returns {@code false} (with action-bar feedback) if the renderer
* is unavailable or the request is rejected by the queue — callers should not charge film then.
*/
/** False (with action-bar feedback) when no photo was taken: no renderer, or rejected by the queue. */
public static boolean takePhoto(Player player) {
Main plugin = Main.getInstance();
DefaultScreenRenderer renderer = plugin.getScreenRenderer();
@@ -66,18 +57,17 @@ public final class PhotoService {
boolean dispatched = false;
try {
Resolution resolution = new Resolution(128, 128);
// The world snapshot is taken now, on the main thread; tracing happens later off-thread.
RenderJob job = renderer.prepare(
player.getEyeLocation(), new Resolution(MapImageDither.SIZE, MapImageDither.SIZE), user);
// Capture the world snapshot on the main thread (the moment of the shot).
RenderJob job = renderer.prepare(player.getEyeLocation(), resolution, user);
// Hand the map over immediately, showing blank "film"; it develops once the render is ready.
// The map is handed over right away and shows blank film until the picture develops.
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);
ItemStack map = new ItemStack(Material.FILLED_MAP);
MapMeta meta = (MapMeta) map.getItemMeta();
meta.getPersistentDataContainer()
.set(
@@ -88,12 +78,8 @@ public final class PhotoService {
meta.displayName(CameraItems.photoDisplayName());
map.setItemMeta(meta);
player.getInventory().addItem(map);
// Feedback is sound + particles only; no action-bar text on a normal shot.
playShutter(player);
// Trace + dither off-thread (queued), then develop on the main thread. The cancel token is
// tripped by the watchdog on timeout; we bail out before the (now pointless) dithering.
manager.dispatch(
user,
cancelled -> {
@@ -102,7 +88,7 @@ public final class PhotoService {
return new RenderOutput(image, MapImageDither.dither(image));
},
out -> {
MapManager.saveImage(Objects.requireNonNull(out).image(), id);
MapManager.saveImage(out.image(), id);
MapManager.saveIndices(out.indices(), id);
mapRenderer.develop(out.indices());
player.playSound(player.getLocation(), Sound.BLOCK_AMETHYST_BLOCK_CHIME, 0.8f, 1.2f);
@@ -110,7 +96,6 @@ public final class PhotoService {
() -> player.sendActionBar(Component.text("Rendern fehlgeschlagen.", NamedTextColor.RED)));
dispatched = true;
} finally {
// prepare()/map setup threw before handing off — free the reserved slot.
if (!dispatched) manager.release(user);
}
@@ -120,8 +105,7 @@ public final class PhotoService {
private static void playShutter(Player player) {
player.playSound(player.getLocation(), Sound.BLOCK_VAULT_OPEN_SHUTTER, 1f, 1.4f);
Location eye = player.getEyeLocation();
Vector forward = eye.getDirection().normalize().multiply(1.0);
Location flash = eye.clone().add(forward);
Location flash = eye.clone().add(eye.getDirection());
player.getWorld().spawnParticle(Particle.END_ROD, flash, 12, 0.2, 0.2, 0.2, 0.01);
player.getWorld().spawnParticle(Particle.FIREWORK, flash, 6, 0.1, 0.1, 0.1, 0.02);
}
@@ -1,8 +1,8 @@
package eu.mhsl.minecraft.pixelpics.survival;
import eu.mhsl.minecraft.pixelpics.Main;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.IntStream;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
@@ -12,10 +12,8 @@ import org.bukkit.inventory.ShapedRecipe;
import org.bukkit.inventory.ShapelessRecipe;
/**
* Registers the four survival recipes and makes them discoverable in the recipe book. Camera and
* film have fixed results; load-film and copy-photo use {@code MaterialChoice} ingredients (their
* inputs carry variable NBT) and their real results are computed dynamically in {@link CraftingListener}
* — the registered result here is only the book/preview icon.
* The four survival recipes. Load film and copy photo only register a recipe book icon as result; the real result
* is computed in {@link CraftingListener}.
*/
public final class SurvivalRecipes {
@@ -37,7 +35,6 @@ public final class SurvivalRecipes {
public static void register() {
unregister();
// Camera
ShapedRecipe camera = new ShapedRecipe(CAMERA, CameraItems.createCamera(0));
camera.shape("IDI", "GLG", "IRI");
camera.setIngredient('I', Material.IRON_INGOT);
@@ -47,35 +44,29 @@ public final class SurvivalRecipes {
camera.setIngredient('R', Material.REDSTONE);
Bukkit.addRecipe(camera, false);
// Film roll
ShapedRecipe film = new ShapedRecipe(FILM, CameraItems.createFilm());
film.shape(" P ", "PIP", " P ");
film.setIngredient('P', Material.PAPER);
film.setIngredient('I', Material.INK_SAC);
Bukkit.addRecipe(film, false);
// Load film: camera + film. Use ExactChoice so the recipe book renders the real camera/film
// textures instead of generic player heads. The camera choice enumerates every loadable fill
// level (0..MAX_FILM-1) so a partly loaded camera still matches; the actual result is still
// validated and computed dynamically in CraftingListener.
List<ItemStack> loadableCameras = new ArrayList<>();
for (int i = 0; i < CameraItems.MAX_FILM; i++) {
loadableCameras.add(CameraItems.createCamera(i));
}
// ExactChoice so the recipe book shows the real head textures; every loadable fill level is listed
// because a used camera must still match.
List<ItemStack> loadableCameras = IntStream.range(0, CameraItems.MAX_FILM)
.mapToObj(CameraItems::createCamera)
.toList();
ShapelessRecipe load = new ShapelessRecipe(LOAD, CameraItems.createCamera(1));
load.addIngredient(RecipeChoice.exactChoice(loadableCameras));
load.addIngredient(RecipeChoice.exactChoice(CameraItems.createFilm()));
Bukkit.addRecipe(load, false);
// Copy photo: photo + film. The photo carries a variable map id (cannot be enumerated), so it
// stays a MaterialChoice (a filled-map icon, not a player head); the film uses ExactChoice so
// its real texture shows in the book.
// The photo's map id cannot be enumerated, so it stays a MaterialChoice.
ShapelessRecipe copy = new ShapelessRecipe(COPY, new ItemStack(Material.FILLED_MAP));
copy.addIngredient(new RecipeChoice.MaterialChoice(Material.FILLED_MAP));
copy.addIngredient(RecipeChoice.exactChoice(CameraItems.createFilm()));
Bukkit.addRecipe(copy, false);
// Cover /reload while players are online; fresh joins are handled by JoinListener.
// Players already online after a reload; later joins are handled by JoinListener.
Bukkit.getOnlinePlayers().forEach(p -> p.discoverRecipes(allKeys()));
}
@@ -1,6 +1,5 @@
package eu.mhsl.minecraft.pixelpics.utils;
import java.awt.image.BufferedImage;
import org.bukkit.entity.Player;
import org.bukkit.map.MapCanvas;
import org.bukkit.map.MapRenderer;
@@ -8,41 +7,31 @@ import org.bukkit.map.MapView;
import org.jetbrains.annotations.NotNull;
/**
* 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.
* Draws precomputed map palette indices. A fresh photo shows blank film until {@link #develop} and then dissolves in
* over {@link #DEVELOP_MILLIS} like an instant camera picture; persisted maps are drawn at once. Bukkit calls
* {@code render} every tick for viewing players; progress follows the wall clock, so looking away does not stall it.
*/
@SuppressWarnings("deprecation") // MapCanvas.setPixel(int, int, byte)
public class ImageMapRenderer extends MapRenderer {
public static final int MAP_SIZE = MapImageDither.SIZE;
private static final long DEVELOP_MILLIS = 3000;
private static final byte FILM = MapColorPalette.mapIndex(MapColorPalette.nearestPos(206, 202, 190));
private static byte filmIndex = 0;
private static boolean filmResolved = false;
private volatile byte[] indices; // MAP_SIZE*MAP_SIZE, null while still rendering
private volatile byte[] indices; // null while still rendering
private final boolean animate;
private volatile long developStart = 0; // 0 until indices are available
private volatile long developStart;
private boolean finished = false;
private boolean blankDrawn = false;
/** Persisted/instant map: draw the full picture immediately, no animation. */
/** A persisted picture, drawn without 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(MapImageDither.dither(image));
}
/** Pending capture: shows blank film until {@link #develop} supplies the picture. */
/** A pending capture. */
public ImageMapRenderer() {
this.indices = null;
this.animate = true;
}
@@ -50,58 +39,39 @@ public class ImageMapRenderer extends MapRenderer {
return indices;
}
/** Supplies the finished picture and starts the developing animation. */
public void develop(byte[] indices) {
this.indices = indices;
this.developStart = System.currentTimeMillis();
this.finished = false;
this.indices = indices;
}
@Override
public void render(@NotNull MapView map, @NotNull MapCanvas canvas, @NotNull Player player) {
if (finished) return;
byte[] data = this.indices;
// Still rendering: show a blank "film" once.
if (data == null) {
if (!blankDrawn) {
fill(canvas, film());
blit(canvas, null, 0);
blankDrawn = true;
}
return;
}
if (!animate) {
blit(canvas, data, 1.0);
finished = true;
return;
}
double progress = (System.currentTimeMillis() - developStart) / (double) DEVELOP_MILLIS;
double progress = animate ? (System.currentTimeMillis() - developStart) / (double) DEVELOP_MILLIS : 1.0;
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();
/** Draws the pixels revealed at {@code progress}; the rest (all of them without data) stay film-coloured. */
private static void blit(MapCanvas canvas, byte[] data, double progress) {
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);
boolean revealed = data != null && (progress >= 1.0 || revealThreshold(x, y) <= progress);
canvas.setPixel(x, y, revealed ? data[y * MAP_SIZE + x] : FILM);
}
}
}
@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) {
/** Per-pixel reveal time in [0,1): mostly noise plus a gentle top-to-bottom sweep. */
private static double revealThreshold(int x, int y) {
int h = (x * 374761393 + y * 668265263);
h = (h ^ (h >>> 13)) * 1274126177;
double noise = ((h >>> 8) & 0xFFFF) / 65535.0;
@@ -109,16 +79,8 @@ public class ImageMapRenderer extends MapRenderer {
return Math.min(0.999, noise * 0.7 + sweep * 0.3);
}
/** The blank-film map colour a photo shows while it is still being rendered. */
/** The blank-film colour of a photo that is still rendering. */
public static byte filmColor() {
return film();
}
private static byte film() {
if (!filmResolved) {
filmIndex = MapColorPalette.mapIndex(MapColorPalette.nearestPos(206, 202, 190));
filmResolved = true;
}
return filmIndex;
return FILM;
}
}
@@ -2,84 +2,63 @@ package eu.mhsl.minecraft.pixelpics.utils;
import eu.mhsl.minecraft.pixelpics.render.util.ColorUtil;
import java.awt.Color;
import java.util.ArrayList;
import java.util.List;
import java.util.Arrays;
import org.bukkit.map.MapPalette;
/**
* 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).
* The opaque Minecraft map colours with nearest-colour matching in CIELAB (in RGB, greens collapse to grey). Built
* from {@link MapPalette} on class initialisation, which {@code Main} triggers on the main thread.
*/
public final class MapColorPalette {
private static volatile boolean initialized = false;
private static byte[] indices;
private static int[] rgb;
private static double[][] lab;
private static int[] argbByIndex; // all 256 map colour bytes → ARGB (transparent slots alpha 0)
private static final byte[] INDICES;
private static final double[][] LAB;
private static final int[] ARGB_BY_INDEX = new int[256]; // raw map colour byte -> ARGB, transparent = 0
static {
byte[] indices = new byte[256];
double[][] lab = new double[256][];
int n = 0;
for (int i = 0; i < 256; i++) {
Color c = color((byte) i);
if (c == null || c.getAlpha() < 255) continue;
ARGB_BY_INDEX[i] = 0xFF000000 | (c.getRGB() & 0xFFFFFF);
indices[n] = (byte) i;
lab[n++] = rgbToLab(c.getRed(), c.getGreen(), c.getBlue());
}
INDICES = Arrays.copyOf(indices, n);
LAB = Arrays.copyOf(lab, n);
}
private MapColorPalette() {}
// MapPalette.getColor(byte) is deprecated for removal with no replacement for enumerating the
// index->colour palette; suppress until the API offers an alternative.
// MapPalette.getColor is deprecated for removal without a replacement for enumerating the palette; it throws
// past the last colour.
@SuppressWarnings("removal")
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.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()));
private static Color color(byte index) {
try {
return MapPalette.getColor(index);
} catch (Throwable t) {
return null;
}
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);
}
argbByIndex = new int[256];
for (int i = 0; i < 256; i++) {
try {
Color c = MapPalette.getColor((byte) i);
argbByIndex[i] = c.getAlpha() < 255 ? 0 : (0xFF << 24) | (c.getRGB() & 0xFFFFFF);
} catch (Throwable t) {
argbByIndex[i] = 0;
}
}
initialized = true;
}
/** ARGB of a raw map colour byte as the client draws it; transparent slots are 0. */
public static int argb(byte mapColor) {
ensure();
return argbByIndex[mapColor & 0xFF];
return ARGB_BY_INDEX[mapColor & 0xFF];
}
public static int size() {
ensure();
return indices.length;
return INDICES.length;
}
/** Index into the internal arrays of the perceptually nearest palette color. */
/** Position of the perceptually nearest palette colour. */
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];
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) {
@@ -90,18 +69,13 @@ public final class MapColorPalette {
return bestPos;
}
/** CIELAB of the palette color at {@code pos} (do not modify). */
/** CIELAB of the palette colour at {@code pos} (do not modify). */
public static double[] lab(int pos) {
ensure();
return lab[pos];
return LAB[pos];
}
public static byte mapIndex(int pos) {
return indices[pos];
}
public static int mapRgb(int pos) {
return rgb[pos];
return INDICES[pos];
}
public static double[] rgbToLab(int r, int g, int b) {
@@ -3,38 +3,26 @@ package eu.mhsl.minecraft.pixelpics.utils;
import java.awt.image.BufferedImage;
/**
* Quantizes a rendered image to Minecraft map color indices. The result is a {@code 128*128} byte
* array of palette indices, ready to blit onto a {@link org.bukkit.map.MapCanvas}.
*
* <p>Uses dampened Floyd–Steinberg error diffusion in CIELAB space with <em>constrained</em>
* candidates: a pixel may only take a palette color that is nearly as close to its original color
* as the best match. Colors the map palette cannot represent well (e.g. light sky blue, sunset
* tones) would otherwise be approximated by mixing far-apart colors, producing a harsh,
* high-contrast checkerboard; the constraint keeps the dither between neighbouring colors only,
* so flat areas stay calm while gradients still blend.
*
* <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.
* Quantizes an image to {@code SIZE*SIZE} map colour indices: dampened Floyd–Steinberg in CIELAB with
* <em>constrained</em> candidates, i.e. a pixel may only take a colour nearly as close to its original as the best
* match. Colours the palette represents badly (light sky blue, sunset tones) would otherwise be mixed from far-apart
* colours into a harsh checkerboard; this way flat areas stay calm while gradients still blend. Expensive, hence the
* on-disk index cache in {@link MapManager}.
*/
public final class MapImageDither {
public static final int SIZE = 128;
/** Fraction of quantization error diffused. */
/** Fraction of the quantization error that is diffused. */
private static final double DITHER_STRENGTH = 0.6;
/**
* A candidate is allowed when its Lab distance to the original pixel is at most
* {@code best * CANDIDATE_RATIO + CANDIDATE_SLACK}, where {@code best} is the distance of the
* nearest palette color.
*/
// A candidate may be at most best * CANDIDATE_RATIO + CANDIDATE_SLACK (Lab distance) from the original pixel.
private static final double CANDIDATE_RATIO = 1.1;
private static final double CANDIDATE_SLACK = 1.5;
private MapImageDither() {}
/** Returns a {@code SIZE*SIZE} array of map palette indices (row-major, y*SIZE + x). */
/** Row-major ({@code y*SIZE + x}) map colour indices. */
public static byte[] dither(BufferedImage image) {
int w = Math.min(SIZE, image.getWidth());
int h = Math.min(SIZE, image.getHeight());
@@ -20,9 +20,8 @@ import org.bukkit.inventory.meta.MapMeta;
import org.bukkit.map.MapView;
/**
* 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.
* Photo storage in {@code images/}: the PNG is the source of truth, {@code .mcmap} caches its dithered map indices.
* Also (re)attaches renderers to map views and finds orphans for cleanup.
*/
public class MapManager {
@@ -42,12 +41,12 @@ public class MapManager {
mapView.setTrackingPosition(false);
}
public static File getImagePath(int mapId) {
return new File(imageFolder(), String.format("mapId_%d.png", mapId));
private static File getImagePath(int mapId) {
return new File(imageFolder(), "mapId_" + mapId + ".png");
}
public static File getIndexPath(int mapId) {
return new File(imageFolder(), String.format("mapId_%d.mcmap", mapId));
private static File getIndexPath(int mapId) {
return new File(imageFolder(), "mapId_" + mapId + ".mcmap");
}
private static boolean ensureFolder() {
@@ -77,7 +76,7 @@ public class MapManager {
}
}
/** Reads the cached dithered indices, or {@code null} if absent/invalid. */
/** The cached indices, or {@code null} if absent or invalid. */
public static byte[] readIndices(int id) {
File path = getIndexPath(id);
if (!path.isFile()) return null;
@@ -89,7 +88,7 @@ public class MapManager {
}
}
/** Reads the stored image, or {@code null} if it does not exist or cannot be read. */
/** The stored image, or {@code null} if absent or unreadable. */
public static BufferedImage readImage(int id) {
File path = getImagePath(id);
if (!path.isFile()) return null;
@@ -105,16 +104,12 @@ public class MapManager {
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. */
/** A stored map id with the newest last-modified time of its files. */
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();
File[] files = imageFolder().listFiles();
if (files == null) return result;
Set<Integer> seen = new HashSet<>();
for (File f : files) {
@@ -132,7 +127,7 @@ public class MapManager {
return f.isFile() ? f.lastModified() : 0L;
}
/** Map ids referenced by currently-loaded item frames and online players (inventory + ender chest). */
/** Map ids held by online players (inventory, ender chest, cursor) and loaded item frames. */
public static Set<Integer> collectInUseMapIds() {
Set<Integer> inUse = new HashSet<>();
for (Player p : Bukkit.getOnlinePlayers()) {
@@ -149,18 +144,20 @@ public class MapManager {
}
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) {
if (item != null
&& item.getType() == Material.FILLED_MAP
&& 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. */
/** Deletes both files of a map id; true if anything was deleted. */
public static boolean delete(int id) {
boolean deleted = false;
File png = getImagePath(id);
+1 -1
View File
@@ -2071,7 +2071,7 @@ public class PPTest extends JavaPlugin {
g.fillRect(48, 56, 32, 12);
g.dispose();
org.bukkit.map.MapView v = Bukkit.createMap(w);
eu.mhsl.minecraft.pixelpics.utils.MapManager.attachView(v, new eu.mhsl.minecraft.pixelpics.utils.ImageMapRenderer(img));
eu.mhsl.minecraft.pixelpics.utils.MapManager.attachView(v, new eu.mhsl.minecraft.pixelpics.utils.ImageMapRenderer(eu.mhsl.minecraft.pixelpics.utils.MapImageDither.dither(img)));
return synthMap = mapItem(v);
}