FakeImage.MAX_DIMENSION
)
+ * @return Height in blocks (capped at maximum image dimension for sender)
*/
- public static int getProportionalHeight(@NotNull Dimension sizeInPixels, int width) {
+ public static int getProportionalHeight(@NotNull Dimension sizeInPixels, @NotNull CommandSender sender, int width) {
float imageRatio = (float) sizeInPixels.height / sizeInPixels.width;
int height = Math.round(width * imageRatio);
- height = Math.min(height, MAX_DIMENSION);
+ height = Math.min(height, getMaxImageDimension(sender));
return height;
}
@@ -177,7 +187,7 @@ public FakeImage(
}
}
- plugin.fine("Created FakeImage#(" + location + "," + face + ") from ImageFile#(" + filename + ")");
+ LOGGER.fine("Created FakeImage#(" + location + "," + face + ") from ImageFile#(" + filename + ")");
}
/**
@@ -193,7 +203,7 @@ public FakeImage(
* @return Image file instance or NULL if not found
*/
public @Nullable ImageFile getFile() {
- return plugin.getStorage().get(filename);
+ return YamipaPlugin.getInstance().getStorage().get(filename);
}
/**
@@ -310,6 +320,7 @@ public int getDelay() {
* @param face Block face
* @return TRUE for contained, FALSE otherwise
*/
+ @SuppressWarnings("RedundantIfStatement")
public boolean contains(@NotNull Location location, @NotNull BlockFace face) {
// Is point facing the same plane as the image?
if (face != this.face) {
@@ -350,18 +361,18 @@ public void setOnLoadedListener(@NotNull Runnable onLoadedListener) {
*/
private void load() {
ImageFile file = getFile();
- FakeMapsContainer container;
+
+ // Get maps to use
+ FakeMap[][][] maps;
if (file == null) {
- container = FakeMap.getErrorMatrix(width, height);
- plugin.warning("File \"" + filename + "\" does not exist");
+ maps = FakeMap.getErrorMatrix(width, height);
+ LOGGER.warning("File \"" + filename + "\" does not exist");
} else {
- container = file.getMapsAndSubscribe(this);
+ CachedMapsFile cachedMapsFile = file.getMapsAndSubscribe(this);
+ maps = cachedMapsFile.getMaps();
+ delay = cachedMapsFile.getDelay();
}
-
- // Extract data from container
- FakeMap[][][] maps = container.getFakeMaps();
numOfSteps = maps[0][0].length;
- delay = container.getDelay();
// Generate frames
FakeItemFrame[] newFrames = new FakeItemFrame[width*height];
@@ -375,14 +386,16 @@ private void load() {
frames = newFrames;
// Start animation task (if needed)
- if (animateImages && task == null && hasFlag(FLAG_ANIMATABLE) && numOfSteps > 1) {
+ YamipaPlugin plugin = YamipaPlugin.getInstance();
+ boolean isAnimationEnabled = plugin.getRenderer().isAnimationEnabled();
+ if (isAnimationEnabled && task == null && hasFlag(FLAG_ANIMATABLE) && numOfSteps > 1) {
task = plugin.getScheduler().scheduleAtFixedRate(
this::nextStep,
0,
delay*50L,
TimeUnit.MILLISECONDS
);
- plugin.fine("Spawned animation task for FakeImage#(" + location + "," + face + ")");
+ LOGGER.fine("Spawned animation task for FakeImage#(" + location + "," + face + ")");
}
// Notify listener
@@ -397,7 +410,7 @@ private void load() {
* @param player Player instance
*/
public void spawn(@NotNull Player player) {
- plugin.fine("Received request to spawn FakeImage#(" + location + "," + face + ") for Player#" + player.getName());
+ LOGGER.fine("Received request to spawn FakeImage#(" + location + "," + face + ") for Player#" + player.getName());
// Send pixels if instance is already loaded
if (frames != null) {
@@ -439,7 +452,7 @@ private void spawnOnceLoaded(@NotNull Player player) {
for (FakeItemFrame frame : frames) {
packets.add(frame.getSpawnPacket());
packets.addAll(frame.getRenderPackets(player, 0));
- plugin.fine("Spawned FakeItemFrame#" + frame.getId() + " for Player#" + playerName);
+ LOGGER.fine("Spawned FakeItemFrame#" + frame.getId() + " for Player#" + playerName);
}
// Send packets
@@ -460,7 +473,7 @@ public void destroy() {
* @param player Player instance or NULL for all observing players
*/
public void destroy(@Nullable Player player) {
- plugin.fine(
+ LOGGER.fine(
"Received request to destroy FakeImage#(" + location + "," + face + ") for " +
(player == null ? "all players" : "Player#" + player.getName())
);
@@ -473,7 +486,7 @@ public void destroy(@Nullable Player player) {
List0
if not applicable
- */
- public int getDelay() {
- return delay;
- }
-}
diff --git a/src/main/java/io/josemmo/bukkit/plugin/renderer/ImageRenderer.java b/src/main/java/io/josemmo/bukkit/plugin/renderer/ImageRenderer.java
index deb0d59..7685460 100644
--- a/src/main/java/io/josemmo/bukkit/plugin/renderer/ImageRenderer.java
+++ b/src/main/java/io/josemmo/bukkit/plugin/renderer/ImageRenderer.java
@@ -2,6 +2,7 @@
import io.josemmo.bukkit.plugin.YamipaPlugin;
import io.josemmo.bukkit.plugin.utils.CsvConfiguration;
+import io.josemmo.bukkit.plugin.utils.Logger;
import org.bukkit.*;
import org.bukkit.block.BlockFace;
import org.bukkit.entity.Entity;
@@ -17,18 +18,19 @@
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Files;
-import java.nio.file.Paths;
+import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.logging.Level;
import java.util.stream.Collectors;
public class ImageRenderer implements Listener {
- public static final long SAVE_INTERVAL = 20L * 90; // In server ticks
- private static final YamipaPlugin plugin = YamipaPlugin.getInstance();
- private final String configPath;
+ private static final long SAVE_INTERVAL = 20L * 90; // In server ticks
+ private static final Logger LOGGER = Logger.getLogger("ImageRenderer");
+ private final Path configPath;
+ private final boolean animateImages;
+ private final int maxImageDimension;
private BukkitTask saveTask;
private final AtomicBoolean hasConfigChanged = new AtomicBoolean(false);
private final ConcurrentMap0
if not applicable
+ */
+ public int getDelay() {
+ return delay;
+ }
+
+ /**
+ * Load maps
+ */
+ private void load() {
+ // Try to load maps from disk
+ if (exists() && getLastModified() > imageFile.getLastModified()) {
+ try {
+ loadFromDisk();
+ return;
+ } catch (IllegalArgumentException e) {
+ LOGGER.info("Cache file \"" + path + "\" is outdated and will be overwritten");
+ } catch (Exception e) {
+ LOGGER.warning("Cache file \"" + path + "\" is corrupted", e);
+ }
+ }
+
+ // Generate maps from image file
+ try {
+ generateFromImage();
+ tryToWriteToDisk();
+ return;
+ } catch (Exception e) {
+ LOGGER.severe("Failed to render image step(s) from file \"" + path + "\"", e);
+ }
+
+ // Fallback to error matrix
+ maps = FakeMap.getErrorMatrix(width, height);
+ delay = 0;
+ }
+
+ /**
+ * Load data from disk
+ * @throws IllegalArgumentException if cache file is outdated
+ * @throws IOException if cache file is corrupted
+ */
+ private void loadFromDisk() throws IllegalArgumentException, IOException {
+ try (RandomAccessFile stream = read()) {
+ // Validate file signature
+ for (byte expectedByte : CACHE_SIGNATURE) {
+ if ((byte) stream.read() != expectedByte) {
+ throw new IllegalArgumentException("Invalid file signature");
+ }
+ }
+
+ // Validate version number
+ if ((byte) stream.read() != CACHE_VERSION) {
+ throw new IllegalArgumentException("Incompatible file format version");
+ }
+
+ // Get number of animation steps
+ int numOfSteps = stream.read() | (stream.read() << 8);
+ if (numOfSteps < 1 || numOfSteps > FakeImage.MAX_STEPS) {
+ throw new IOException("Invalid number of animation steps: " + numOfSteps);
+ }
+
+ // Get delay between steps
+ int delay = 0;
+ if (numOfSteps > 1) {
+ delay = stream.read();
+ if (delay < FakeImage.MIN_DELAY || delay > FakeImage.MAX_DELAY) {
+ throw new IOException("Invalid delay between steps: " + delay);
+ }
+ }
+
+ // Read pixels
+ FakeMap[][][] maps = new FakeMap[width][height][numOfSteps];
+ for (int col=0; col
- * This method is called by FakeImage instances when they get invalidated by a WorldArea.
- * By notifying their respective source ImageFile, the latter can clear cached maps from memory when no more
- * FakeItemFrames are using them.
+ * This method is called by {@link FakeImage} instances when they get invalidated by a world area change.
+ * By notifying their respective source {@link ImageFile}, the latter can clear cached maps from memory when no
+ * more {@link FakeItemFrame}s are using them.
* @param subscriber Fake image instance
*/
public synchronized void unsubscribe(@NotNull FakeImage subscriber) {
String cacheKey = subscriber.getWidth() + "-" + subscriber.getHeight();
- if (!subscribers.containsKey(cacheKey)) return;
+ if (!subscribers.containsKey(cacheKey)) {
+ // Not subscribed to this image file
+ return;
+ }
// Remove subscriber
Set
- * Removes all references to pre-generated map sets.
+ * Removes all references to cached map instances.
* This way, next time an image is requested to be rendered, maps will be regenerated.
*/
public synchronized void invalidate() {
+ size = null;
cache.clear();
-
- // Delete disk cache files
- File[] files = Paths.get(plugin.getStorage().getCachePath()).toFile().listFiles((__, filename) -> {
- return filename.matches(Pattern.quote(name) + "\\.[0-9]+-[0-9]+\\." + CACHE_EXT);
- });
- if (files == null) {
- plugin.warning("An error occurred when listing cache files for image \"" + name + "\"");
- return;
- }
- for (File file : files) {
- if (!file.delete()) {
- plugin.warning("Failed to delete cache file \"" + file.getAbsolutePath() + "\"");
- }
- }
+ CachedMapsFile.deleteAll(this);
}
}
diff --git a/src/main/java/io/josemmo/bukkit/plugin/storage/ImageStorage.java b/src/main/java/io/josemmo/bukkit/plugin/storage/ImageStorage.java
index 51bda79..e6085d4 100644
--- a/src/main/java/io/josemmo/bukkit/plugin/storage/ImageStorage.java
+++ b/src/main/java/io/josemmo/bukkit/plugin/storage/ImageStorage.java
@@ -1,42 +1,56 @@
package io.josemmo.bukkit.plugin.storage;
-import io.josemmo.bukkit.plugin.YamipaPlugin;
-import org.bukkit.Bukkit;
-import org.bukkit.scheduler.BukkitTask;
+import com.sun.nio.file.ExtendedWatchEventModifier;
+import io.josemmo.bukkit.plugin.utils.Logger;
+import io.josemmo.bukkit.plugin.utils.Permissions;
+import org.bukkit.command.CommandSender;
+import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.File;
import java.io.IOException;
import java.nio.file.*;
-import java.util.Objects;
-import java.util.SortedMap;
-import java.util.TreeMap;
-import java.util.logging.Level;
+import java.util.*;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.regex.PatternSyntaxException;
+/**
+ * A service whose purpose is to keep track of all available image files in a given directory.
+ * It supports recursive storage (e.g., nested directories) and watches for file system changes in realtime.
+ *
+ * All files are indexed based on their filename.
+ * Due to recursion, filenames can contain forward slashes (i.e., "/") and act as relative paths to the base
+ * directory.
+ */
public class ImageStorage {
- static public final long POLLING_INTERVAL = 20L * 5; // In server ticks
- static private final YamipaPlugin plugin = YamipaPlugin.getInstance();
- private final String basePath;
- private final String cachePath;
- private final SortedMap
+ * Creates the directory containing this file and its parents if they don't exist.
+ */
+ public void mkdirs() {
+ try {
+ Files.createDirectories(path.getParent());
+ } catch (IOException __) {
+ // Silently ignore exception
+ }
+ }
+
+ /**
+ * Get file reader
+ * @return Readable stream of the file
+ * @throws IOException if failed to acquire lock
+ */
+ public RandomAccessFile read() throws IOException {
+ return new SynchronizedFileStream(path, true);
+ }
+
+ /**
+ * Get file writer
+ * @return Writable stream of the file
+ * @throws IOException if failed to acquire lock
+ */
+ public RandomAccessFile write() throws IOException {
+ return new SynchronizedFileStream(path, false);
+ }
+}
diff --git a/src/main/java/io/josemmo/bukkit/plugin/storage/SynchronizedFileStream.java b/src/main/java/io/josemmo/bukkit/plugin/storage/SynchronizedFileStream.java
new file mode 100644
index 0000000..7410d16
--- /dev/null
+++ b/src/main/java/io/josemmo/bukkit/plugin/storage/SynchronizedFileStream.java
@@ -0,0 +1,26 @@
+package io.josemmo.bukkit.plugin.storage;
+
+import org.jetbrains.annotations.Blocking;
+import org.jetbrains.annotations.NotNull;
+import java.io.IOException;
+import java.io.RandomAccessFile;
+import java.nio.file.Path;
+
+/**
+ * Blocking file stream implementing {@link RandomAccessFile} for reading and writing data.
+ * It will lock the file for reading/writing when opened, and release the lock when closed.
+ */
+public class SynchronizedFileStream extends RandomAccessFile {
+ /**
+ * Class constructor
+ * @param path Path to file
+ * @param readOnly Whether to open stream in read-only mode
+ * @throws IOException if failed to acquire lock
+ */
+ @Blocking
+ public SynchronizedFileStream(@NotNull Path path, boolean readOnly) throws IOException {
+ super(path.toFile(), readOnly ? "r" : "rw");
+ //noinspection ResultOfMethodCallIgnored
+ getChannel().lock(0L, Long.MAX_VALUE, readOnly);
+ }
+}
diff --git a/src/main/java/io/josemmo/bukkit/plugin/utils/ActionBar.java b/src/main/java/io/josemmo/bukkit/plugin/utils/ActionBar.java
index d5fda77..b1f6c9c 100644
--- a/src/main/java/io/josemmo/bukkit/plugin/utils/ActionBar.java
+++ b/src/main/java/io/josemmo/bukkit/plugin/utils/ActionBar.java
@@ -7,9 +7,9 @@
import org.bukkit.entity.Player;
import org.bukkit.scheduler.BukkitTask;
import org.jetbrains.annotations.NotNull;
-import java.util.logging.Level;
public class ActionBar {
+ private static final Logger LOGGER = Logger.getLogger("ActionBar");
private static final YamipaPlugin plugin = YamipaPlugin.getInstance();
private final Player player;
private String message;
@@ -72,7 +72,7 @@ public ActionBar sendOnce() {
try {
ProtocolLibrary.getProtocolManager().sendServerPacket(player, actionBarPacket);
} catch (Exception e) {
- plugin.log(Level.SEVERE, "Failed to send ActionBar to " + player.getName(), e);
+ LOGGER.severe("Failed to send ActionBar to " + player.getName(), e);
}
return this;
}
diff --git a/src/main/java/io/josemmo/bukkit/plugin/utils/CsvConfiguration.java b/src/main/java/io/josemmo/bukkit/plugin/utils/CsvConfiguration.java
index 2c2e163..bd809f1 100644
--- a/src/main/java/io/josemmo/bukkit/plugin/utils/CsvConfiguration.java
+++ b/src/main/java/io/josemmo/bukkit/plugin/utils/CsvConfiguration.java
@@ -1,20 +1,24 @@
package io.josemmo.bukkit.plugin.utils;
import org.jetbrains.annotations.NotNull;
-import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
-import java.nio.file.Paths;
+import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;
+/**
+ * A class for parsing simple CSV files.
+ * It's used for loading and writing Yamipa's configuration file ("image.dat" by default) and does not
+ * support escaping of special characters, as this is not currently needed.
+ */
public class CsvConfiguration {
public static final Charset CHARSET = StandardCharsets.UTF_8;
- public static final String COLUMN_DELIMITER = "/";
+ public static final String COLUMN_DELIMITER = "\t";
private final List0
in case of error
+ */
+ public long getLastModified() {
+ try {
+ return Files.getLastModifiedTime(path).toMillis();
+ } catch (Exception __) {
+ return 0L;
+ }
+ }
+
+ /**
+ * Make directories
+ *