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