diff --git a/common/src/main/java/xyz/jpenilla/squaremap/common/data/Image.java b/common/src/main/java/xyz/jpenilla/squaremap/common/data/Image.java index 39fc0f0f..051adca6 100644 --- a/common/src/main/java/xyz/jpenilla/squaremap/common/data/Image.java +++ b/common/src/main/java/xyz/jpenilla/squaremap/common/data/Image.java @@ -1,39 +1,23 @@ package xyz.jpenilla.squaremap.common.data; import java.awt.Color; -import java.awt.image.BufferedImage; -import java.io.BufferedOutputStream; -import java.io.IOException; -import java.io.OutputStream; -import java.nio.file.Files; -import java.nio.file.Path; +import java.awt.image.DataBufferInt; import java.util.Arrays; -import javax.imageio.IIOImage; -import javax.imageio.ImageIO; -import javax.imageio.ImageWriteParam; -import javax.imageio.ImageWriter; -import javax.imageio.stream.ImageOutputStream; import net.minecraft.util.Mth; import org.checkerframework.checker.nullness.qual.NonNull; import org.checkerframework.checker.nullness.qual.Nullable; import org.checkerframework.framework.qual.DefaultQualifier; -import xyz.jpenilla.squaremap.common.Logging; -import xyz.jpenilla.squaremap.common.config.Config; -import xyz.jpenilla.squaremap.common.config.Messages; -import xyz.jpenilla.squaremap.common.util.FileUtil; @DefaultQualifier(NonNull.class) public final class Image { private static final int TRANSPARENT = new Color(0, 0, 0, 0).getRGB(); public static final int SIZE = 512; private final RegionCoordinate region; - private final Path directory; private final int maxZoom; private int @Nullable [][] pixels = null; - public Image(final RegionCoordinate region, final Path directory, final int maxZoom) { + public Image(final RegionCoordinate region, final int maxZoom) { this.region = region; - this.directory = directory; this.maxZoom = maxZoom; } @@ -48,115 +32,41 @@ public synchronized void setPixel(final int x, final int z, final int color) { this.pixels[x & (SIZE - 1)][z & (SIZE - 1)] = color; } - public synchronized void save() { - if (this.pixels == null) { + public synchronized void drawTo(final TileCache cache) { + final int @Nullable [][] pixels = this.pixels; + if (pixels == null) { return; } for (int zoom = 0; zoom <= this.maxZoom; zoom++) { - int step = (int) Math.pow(2, zoom); - int size = SIZE / step; - int scaledX = Mth.floor((double) this.region.x() / step); - int scaledZ = Mth.floor((double) this.region.z() / step); - - final BufferedImage image = this.getOrCreate(this.maxZoom - zoom, scaledX, scaledZ); - - int baseX = (this.region.x() * size) & (SIZE - 1); - int baseZ = (this.region.z() * size) & (SIZE - 1); - for (int x = 0; x < SIZE; x += step) { - for (int z = 0; z < SIZE; z += step) { - final int pixel = this.pixels[x][z]; - if (pixel != Integer.MIN_VALUE) { - final int color = pixel == 0 ? TRANSPARENT : pixel; - image.setRGB(baseX + (x / step), baseZ + (z / step), color); + final int step = (int) Math.pow(2, zoom); + final int size = SIZE / step; + final int fileZoom = this.maxZoom - zoom; + final int scaledX = Mth.floor((double) this.region.x() / step); + final int scaledZ = Mth.floor((double) this.region.z() / step); + + final int baseX = (this.region.x() * size) & (SIZE - 1); + final int baseZ = (this.region.z() * size) & (SIZE - 1); + + // the most detailed zoom level holds one tile per region, so no other region ever + // draws into it and there is nothing to gain from keeping it in memory + final boolean retain = fileZoom != this.maxZoom; + + cache.draw(new TileCoordinate(fileZoom, scaledX, scaledZ), retain, image -> { + // tiles are always SIZE wide and of type int argb, so the backing array is + // addressed directly instead of going through setRGB for every pixel + final int[] target = ((DataBufferInt) image.getRaster().getDataBuffer()).getData(); + for (int x = 0; x < SIZE; x += step) { + final int[] column = pixels[x]; + final int targetX = baseX + (x / step); + for (int z = 0; z < SIZE; z += step) { + final int pixel = column[z]; + if (pixel != Integer.MIN_VALUE) { + target[(baseZ + (z / step)) * SIZE + targetX] = pixel == 0 ? TRANSPARENT : pixel; + } } } - } - - this.save(this.maxZoom - zoom, scaledX, scaledZ, image); - } - } - - private BufferedImage getOrCreate(final int zoom, final int scaledX, final int scaledZ) { - final Path file = this.imageInDirectory(zoom, scaledX, scaledZ); - - if (!Files.isRegularFile(file)) { - return newBufferedImage(); - } - - try { - final @Nullable BufferedImage read = ImageIO.read(file.toFile()); - if (read == null) { - throw new IOException("Failed to read image file '" + file.toAbsolutePath() + "', ImageIO.read(File) result is null. This means no " + - "supported image format was able to read it. The image file may have been malformed or corrupted, it will be overwritten."); - } - return read; - } catch (final IOException ex) { - try { - Files.deleteIfExists(file); - } catch (final IOException ex0) { - ex.addSuppressed(ex0); - } - this.logCouldNotRead(ex); - return newBufferedImage(); - } - } - - private void save(final int zoom, final int scaledX, final int scaledZ, final BufferedImage image) { - final Path out = this.imageInDirectory(zoom, scaledX, scaledZ); - try { - FileUtil.atomicWrite(out, tmp -> { - try (final OutputStream outputStream = new BufferedOutputStream(Files.newOutputStream(tmp))) { - save(image, outputStream); - } }); - } catch (final IOException ex) { - this.logCouldNotSave(ex); } } - - private static void save(final BufferedImage image, final OutputStream out) throws IOException { - final ImageWriter writer = ImageIO.getImageWritersByFormatName("png").next(); - try (final ImageOutputStream imageOutputStream = ImageIO.createImageOutputStream(out)) { - writer.setOutput(imageOutputStream); - final ImageWriteParam param = writer.getDefaultWriteParam(); - if (Config.COMPRESS_IMAGES && param.canWriteCompressed()) { - param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); - if (param.getCompressionType() == null) { - param.setCompressionType(param.getCompressionTypes()[0]); - } - param.setCompressionQuality(Config.COMPRESSION_RATIO); - } - writer.write(null, new IIOImage(image, null, null), param); - } - } - - private Path imageInDirectory(final int zoom, final int scaledX, final int scaledZ) { - final Path dir = this.directory.resolve(Integer.toString(zoom)); - if (!Files.exists(dir)) { - try { - Files.createDirectories(dir); - } catch (final IOException e) { - throw new RuntimeException(Logging.replace(Messages.LOG_COULD_NOT_CREATE_DIR, "path", dir.toAbsolutePath()), e); - } - } - final String fileName = scaledX + "_" + scaledZ + ".png"; - return dir.resolve(fileName); - } - - private static BufferedImage newBufferedImage() { - return new BufferedImage(Image.SIZE, Image.SIZE, BufferedImage.TYPE_INT_ARGB); - } - - private void logCouldNotRead(final IOException ex) { - Logging.logger().error(xz(Messages.LOG_COULD_NOT_READ_REGION), ex); - } - - private void logCouldNotSave(final IOException ex) { - Logging.logger().error(xz(Messages.LOG_COULD_NOT_SAVE_REGION), ex); - } - - private String xz(final String s) { - return Logging.replace(s, "x", this.region.x(), "z", this.region.z()); - } } diff --git a/common/src/main/java/xyz/jpenilla/squaremap/common/data/MapWorldInternal.java b/common/src/main/java/xyz/jpenilla/squaremap/common/data/MapWorldInternal.java index 8ddc4e64..624e6b08 100644 --- a/common/src/main/java/xyz/jpenilla/squaremap/common/data/MapWorldInternal.java +++ b/common/src/main/java/xyz/jpenilla/squaremap/common/data/MapWorldInternal.java @@ -30,6 +30,7 @@ import xyz.jpenilla.squaremap.common.config.ConfigManager; import xyz.jpenilla.squaremap.common.config.WorldAdvanced; import xyz.jpenilla.squaremap.common.config.WorldConfig; +import xyz.jpenilla.squaremap.common.httpd.JsonCache; import xyz.jpenilla.squaremap.common.layer.SpawnIconLayer; import xyz.jpenilla.squaremap.common.layer.WorldBorderLayer; import xyz.jpenilla.squaremap.common.task.render.RenderFactory; @@ -61,12 +62,11 @@ protected MapWorldInternal( final ServerLevel level, final RenderFactory renderFactory, final DirectoryProvider directoryProvider, - final ConfigManager configManager + final ConfigManager configManager, + final JsonCache jsonCache ) { this.level = level; - this.imageIOExecutor = ImageIOExecutor.create(level); - this.worldConfig = configManager.worldConfig(this.level); this.advancedWorldConfig = configManager.worldAdvanced(this.level); @@ -76,6 +76,9 @@ protected MapWorldInternal( this.dataPath = directoryProvider.getAndCreateDataDirectory(this.serverLevel()); this.tilesPath = directoryProvider.getAndCreateTilesDirectory(this.serverLevel()); + final TileUpdates tileUpdates = new TileUpdates(directoryProvider, this.tilesPath, jsonCache); + this.imageIOExecutor = ImageIOExecutor.create(level, new TileCache(this.tilesPath, tileUpdates), tileUpdates); + this.layerRegistry(); // init the layer registry if (this.config().SPAWN_MARKER_ICON_ENABLED) { this.layerRegistry().register(SpawnIconLayer.KEY, new SpawnIconLayer(this)); diff --git a/common/src/main/java/xyz/jpenilla/squaremap/common/data/TileCache.java b/common/src/main/java/xyz/jpenilla/squaremap/common/data/TileCache.java new file mode 100644 index 00000000..118299de --- /dev/null +++ b/common/src/main/java/xyz/jpenilla/squaremap/common/data/TileCache.java @@ -0,0 +1,215 @@ +package xyz.jpenilla.squaremap.common.data; + +import java.awt.AlphaComposite; +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.io.BufferedOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; +import javax.imageio.IIOImage; +import javax.imageio.ImageIO; +import javax.imageio.ImageWriteParam; +import javax.imageio.ImageWriter; +import javax.imageio.stream.ImageOutputStream; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.checkerframework.framework.qual.DefaultQualifier; +import xyz.jpenilla.squaremap.common.Logging; +import xyz.jpenilla.squaremap.common.config.Config; +import xyz.jpenilla.squaremap.common.config.Messages; +import xyz.jpenilla.squaremap.common.util.FileUtil; + +/** + * Owns the tile image files of a single world. Tiles below the most detailed zoom level are + * shared by several regions, so they are held in memory and only encoded once the render has + * moved away from them, instead of being decoded and re-encoded for every region that draws + * into them. + */ +@DefaultQualifier(NonNull.class) +public final class TileCache { + private static final int MAX_ENTRIES = Integer.getInteger("squaremap.tileCacheSize", 16); + + private final Path directory; + private final TileUpdates tileUpdates; + private final Map entries = new LinkedHashMap<>(); + private final Set createdDirectories = new HashSet<>(); + + public TileCache(final Path directory, final TileUpdates tileUpdates) { + this.directory = directory; + this.tileUpdates = tileUpdates; + } + + /** + * Draws into the tile at the given coordinate. + * + * @param coordinate tile to draw into + * @param retain whether the tile is shared with other regions and should be held in + * memory until it is evicted or flushed, instead of written immediately + * @param painter receives the tile image + */ + public synchronized void draw(final TileCoordinate coordinate, final boolean retain, final Consumer painter) { + if (!retain) { + final BufferedImage image = this.read(coordinate); + painter.accept(image); + this.write(coordinate, image); + return; + } + + final Entry entry = this.entry(coordinate); + painter.accept(entry.image); + entry.dirty = true; + } + + /** + * Writes out every tile that has been drawn into since the last flush. Tiles are kept in + * memory so that a render still working in the same area doesn't have to decode them + * again; a flush that finds nothing to write means the render has moved on, and drops + * them so that an idle world holds no tile images. + */ + public synchronized void flush() { + boolean wrote = false; + for (final Map.Entry entry : this.entries.entrySet()) { + wrote |= this.writeIfDirty(entry.getKey(), entry.getValue()); + } + if (!wrote) { + this.entries.clear(); + } + } + + private Entry entry(final TileCoordinate coordinate) { + // remove before putting so the map stays ordered from least to most recently used + final @Nullable Entry existing = this.entries.remove(coordinate); + if (existing != null) { + this.entries.put(coordinate, existing); + return existing; + } + + final Entry entry = new Entry(this.read(coordinate)); + this.entries.put(coordinate, entry); + + final Iterator> it = this.entries.entrySet().iterator(); + while (this.entries.size() > MAX_ENTRIES && it.hasNext()) { + final Map.Entry eldest = it.next(); + it.remove(); + this.writeIfDirty(eldest.getKey(), eldest.getValue()); + } + + return entry; + } + + private boolean writeIfDirty(final TileCoordinate coordinate, final Entry entry) { + if (!entry.dirty) { + return false; + } + this.write(coordinate, entry.image); + entry.dirty = false; + return true; + } + + private BufferedImage read(final TileCoordinate coordinate) { + final Path file = this.tileFile(coordinate); + + if (!Files.isRegularFile(file)) { + return newBufferedImage(); + } + + try { + final @Nullable BufferedImage read = ImageIO.read(file.toFile()); + if (read == null) { + throw new IOException("Failed to read image file " + file.toAbsolutePath() + ", ImageIO.read(File) result is null. This means no " + + "supported image format was able to read it. The image file may have been malformed or corrupted, it will be overwritten."); + } + return toArgb(read); + } catch (final IOException ex) { + try { + Files.deleteIfExists(file); + } catch (final IOException ex0) { + ex.addSuppressed(ex0); + } + Logging.logger().error(xz(Messages.LOG_COULD_NOT_READ_REGION, coordinate), ex); + return newBufferedImage(); + } + } + + private void write(final TileCoordinate coordinate, final BufferedImage image) { + final Path out = this.tileFile(coordinate); + try { + FileUtil.atomicWrite(out, tmp -> { + try (final OutputStream outputStream = new BufferedOutputStream(Files.newOutputStream(tmp))) { + encode(image, outputStream); + } + }); + } catch (final IOException ex) { + Logging.logger().error(xz(Messages.LOG_COULD_NOT_SAVE_REGION, coordinate), ex); + return; + } + this.tileUpdates.record(coordinate); + } + + private static void encode(final BufferedImage image, final OutputStream out) throws IOException { + final ImageWriter writer = ImageIO.getImageWritersByFormatName("png").next(); + try (final ImageOutputStream imageOutputStream = ImageIO.createImageOutputStream(out)) { + writer.setOutput(imageOutputStream); + final ImageWriteParam param = writer.getDefaultWriteParam(); + if (Config.COMPRESS_IMAGES && param.canWriteCompressed()) { + param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); + if (param.getCompressionType() == null) { + param.setCompressionType(param.getCompressionTypes()[0]); + } + param.setCompressionQuality(Config.COMPRESSION_RATIO); + } + writer.write(null, new IIOImage(image, null, null), param); + } + } + + private Path tileFile(final TileCoordinate coordinate) { + final Path dir = this.directory.resolve(Integer.toString(coordinate.zoom())); + if (this.createdDirectories.add(coordinate.zoom()) && !Files.exists(dir)) { + try { + Files.createDirectories(dir); + } catch (final IOException e) { + throw new RuntimeException(Logging.replace(Messages.LOG_COULD_NOT_CREATE_DIR, "path", dir.toAbsolutePath()), e); + } + } + return dir.resolve(coordinate.x() + "_" + coordinate.z() + ".png"); + } + + private static BufferedImage newBufferedImage() { + return new BufferedImage(Image.SIZE, Image.SIZE, BufferedImage.TYPE_INT_ARGB); + } + + // a decoded png is whatever type the reader picked, so normalize it to the type tiles are + // created with, which is the one callers write into directly + private static BufferedImage toArgb(final BufferedImage image) { + if (image.getType() == BufferedImage.TYPE_INT_ARGB && image.getWidth() == Image.SIZE && image.getHeight() == Image.SIZE) { + return image; + } + final BufferedImage converted = newBufferedImage(); + final Graphics2D graphics = converted.createGraphics(); + graphics.setComposite(AlphaComposite.Src); + graphics.drawImage(image, 0, 0, null); + graphics.dispose(); + return converted; + } + + private static String xz(final String message, final TileCoordinate coordinate) { + return Logging.replace(message, "x", coordinate.x(), "z", coordinate.z()); + } + + private static final class Entry { + private final BufferedImage image; + private boolean dirty = false; + + private Entry(final BufferedImage image) { + this.image = image; + } + } +} diff --git a/common/src/main/java/xyz/jpenilla/squaremap/common/data/TileCoordinate.java b/common/src/main/java/xyz/jpenilla/squaremap/common/data/TileCoordinate.java new file mode 100644 index 00000000..16ff704a --- /dev/null +++ b/common/src/main/java/xyz/jpenilla/squaremap/common/data/TileCoordinate.java @@ -0,0 +1,19 @@ +package xyz.jpenilla.squaremap.common.data; + +import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.framework.qual.DefaultQualifier; + +@DefaultQualifier(NonNull.class) +public record TileCoordinate(int zoom, int x, int z) { + + /** + * Get the key identifying this tile in the tile update manifest. The format matches + * the path of the tile image relative to the world tiles directory, without the file + * extension, so that the web interface can derive it from Leaflet tile coordinates. + * + * @return the manifest key + */ + public String key() { + return this.zoom + "/" + this.x + "_" + this.z; + } +} diff --git a/common/src/main/java/xyz/jpenilla/squaremap/common/data/TileUpdates.java b/common/src/main/java/xyz/jpenilla/squaremap/common/data/TileUpdates.java new file mode 100644 index 00000000..f8e643ae --- /dev/null +++ b/common/src/main/java/xyz/jpenilla/squaremap/common/data/TileUpdates.java @@ -0,0 +1,101 @@ +package xyz.jpenilla.squaremap.common.data; + +import java.nio.file.Path; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Map; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.framework.qual.DefaultQualifier; +import xyz.jpenilla.squaremap.common.httpd.JsonCache; +import xyz.jpenilla.squaremap.common.util.Util; + +/** + * Tracks which tile images have recently been rewritten and publishes them to a manifest + * in the world tiles directory. The web interface polls the manifest so that it only + * refetches tiles that actually changed, instead of periodically discarding every tile in + * the viewport. + */ +@DefaultQualifier(NonNull.class) +public final class TileUpdates { + public static final String FILE_NAME = "updates.json"; + // a client that last read the manifest within this window can still be told exactly which + // tiles changed; one that has been away longer has to reload what it is displaying + private static final long RETENTION_MS = 120_000L; + // bound on the manifest size, only reached by a render rewriting tiles faster than the + // retention window can drop them + private static final int MAX_ENTRIES = 8192; + private static final long MIN_WRITE_INTERVAL_MS = 500L; + + private final String jsonPathString; + private final JsonCache jsonCache; + private final Map tiles = new LinkedHashMap<>(); + private long newestDropped = 0L; + private boolean dirty = false; + private long lastWrite = 0L; + + public TileUpdates(final DirectoryProvider directoryProvider, final Path tilesPath, final JsonCache jsonCache) { + final Path jsonPath = tilesPath.resolve(FILE_NAME); + this.jsonPathString = "/" + directoryProvider.webDirectory().relativize(jsonPath).toString().replace("\\", "/"); + this.jsonCache = jsonCache; + // publish an empty manifest so clients don't read leftover state from a previous run + this.write(); + } + + /** + * Records that the given tile image has been rewritten. + * + * @param tile tile written to disk + */ + public synchronized void record(final TileCoordinate tile) { + final long now = System.currentTimeMillis(); + final String key = tile.key(); + // remove before putting so the map stays ordered from oldest to newest + this.tiles.remove(key); + this.tiles.put(key, now); + + final long oldest = now - RETENTION_MS; + final Iterator> it = this.tiles.entrySet().iterator(); + while (it.hasNext()) { + final Map.Entry entry = it.next(); + if (entry.getValue() > oldest && this.tiles.size() <= MAX_ENTRIES) { + break; + } + this.newestDropped = Math.max(this.newestDropped, entry.getValue()); + it.remove(); + } + + this.dirty = true; + } + + /** + * Publishes the manifest if it has changed and the minimum write interval has elapsed. + */ + public synchronized void writeIfDue() { + if (this.dirty && System.currentTimeMillis() - this.lastWrite >= MIN_WRITE_INTERVAL_MS) { + this.write(); + } + } + + /** + * Publishes the manifest if it has changed, ignoring the minimum write interval. + */ + public synchronized void writeIfDirty() { + if (this.dirty) { + this.write(); + } + } + + private void write() { + final long now = System.currentTimeMillis(); + + final Map manifest = new LinkedHashMap<>(); + manifest.put("timestamp", now); + // clients that last saw a manifest at or before this time may have missed an update + manifest.put("dropped", this.newestDropped); + manifest.put("tiles", this.tiles); + + this.jsonCache.put(this.jsonPathString, Util.gson().toJson(manifest)); + this.dirty = false; + this.lastWrite = now; + } +} diff --git a/common/src/main/java/xyz/jpenilla/squaremap/common/httpd/IntegratedServer.java b/common/src/main/java/xyz/jpenilla/squaremap/common/httpd/IntegratedServer.java index 0651cbf1..52e911b0 100644 --- a/common/src/main/java/xyz/jpenilla/squaremap/common/httpd/IntegratedServer.java +++ b/common/src/main/java/xyz/jpenilla/squaremap/common/httpd/IntegratedServer.java @@ -99,9 +99,15 @@ private static HttpHandler createHttpHandler(final ResourceHandler resourceHandl } if (exchange.getRelativePath().startsWith("/tiles")) { + // tile images are requested with a version parameter that changes whenever the tile is + // rewritten, so those responses can be cached indefinitely + final boolean versioned = exchange.getRelativePath().endsWith(".png") + && !exchange.getQueryString().isEmpty(); exchange.getResponseHeaders().put( Headers.CACHE_CONTROL, - "max-age=0, must-revalidate, no-cache" + versioned + ? "public, max-age=31536000, immutable" + : "max-age=0, must-revalidate, no-cache" ); } diff --git a/common/src/main/java/xyz/jpenilla/squaremap/common/httpd/JsonCache.java b/common/src/main/java/xyz/jpenilla/squaremap/common/httpd/JsonCache.java index 2d1c3b30..b07b4688 100644 --- a/common/src/main/java/xyz/jpenilla/squaremap/common/httpd/JsonCache.java +++ b/common/src/main/java/xyz/jpenilla/squaremap/common/httpd/JsonCache.java @@ -48,6 +48,12 @@ boolean handle(final HttpServerExchange exchange) { Headers.ETAG, timestamp ); + // clients poll these on a fixed interval, revalidating lets an unchanged document + // answer with a 304 instead of the full body + exchange.getResponseHeaders().put( + Headers.CACHE_CONTROL, + "no-cache" + ); final String requestedEtag = exchange.getRequestHeaders().getFirst(Headers.IF_NONE_MATCH); if (requestedEtag != null && requestedEtag.equals(timestamp)) { diff --git a/common/src/main/java/xyz/jpenilla/squaremap/common/task/render/AbstractRender.java b/common/src/main/java/xyz/jpenilla/squaremap/common/task/render/AbstractRender.java index 8efd8465..c6ef8c21 100644 --- a/common/src/main/java/xyz/jpenilla/squaremap/common/task/render/AbstractRender.java +++ b/common/src/main/java/xyz/jpenilla/squaremap/common/task/render/AbstractRender.java @@ -202,7 +202,7 @@ public final void restartProgressLogger() { } protected final void mapRegion(final RegionCoordinate region) { - final Image image = new Image(region, this.mapWorld.tilesPath(), this.mapWorld.config().ZOOM_MAX); + final Image image = new Image(region, this.mapWorld.config().ZOOM_MAX); final int startX = region.getChunkX(); final int startZ = region.getChunkZ(); final List> futures = new ArrayList<>(); @@ -223,7 +223,12 @@ protected final void mapRegion(final RegionCoordinate region) { } } - protected final CompletableFuture mapSingleChunk(final Image image, final int chunkX, final int chunkZ) { + /** + * Maps a single chunk into the given image. + * + * @return future completing with whether the chunk itself could be read and was scanned + */ + protected final CompletableFuture mapSingleChunk(final Image image, final int chunkX, final int chunkZ) { final CompletableFuture<@Nullable ChunkSnapshot> chunkFuture = this.chunks.snapshot(new ChunkPos(chunkX, chunkZ)); final CompletableFuture<@Nullable ChunkSnapshot> northChunk = this.chunks.snapshotDirect(new ChunkPos(chunkX, chunkZ - 1)); @@ -242,9 +247,9 @@ protected final CompletableFuture mapSingleChunk(final Image image, final southChunk = CompletableFuture.completedFuture(null); } - return CompletableFuture.allOf(northChunk, chunkFuture, southChunk).thenRunAsync(() -> { + return CompletableFuture.allOf(northChunk, chunkFuture, southChunk).thenApplyAsync($ -> { if (!this.running()) { - return; + return false; } int[] lastY = new int[16]; @@ -266,9 +271,10 @@ protected final CompletableFuture mapSingleChunk(final Image image, final } this.processedChunks.incrementAndGet(); + return chunk != null && this.running(); }, this.executor).exceptionally(thr -> { Logging.logger().warn("Exception mapping chunk at [{}, {}] in {}", chunkX, chunkZ, this.mapWorld.identifier().asString(), thr); - return null; + return false; }); } diff --git a/common/src/main/java/xyz/jpenilla/squaremap/common/task/render/BackgroundRender.java b/common/src/main/java/xyz/jpenilla/squaremap/common/task/render/BackgroundRender.java index 6f6dc738..aae0784e 100644 --- a/common/src/main/java/xyz/jpenilla/squaremap/common/task/render/BackgroundRender.java +++ b/common/src/main/java/xyz/jpenilla/squaremap/common/task/render/BackgroundRender.java @@ -3,6 +3,9 @@ import com.google.inject.assistedinject.Assisted; import com.google.inject.assistedinject.AssistedInject; import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; @@ -26,7 +29,13 @@ @DefaultQualifier(NonNull.class) public final class BackgroundRender extends AbstractRender { + // how many cycles a chunk that could not be read is retried for before it is given up on. + // a freshly generated chunk can briefly be readable from neither memory nor disk, but a + // chunk that simply does not exist must not be requeued forever + private static final int MAX_ATTEMPTS = 3; + private final ServerAccess serverAccess; + private final Map failedAttempts = new HashMap<>(); @AssistedInject private BackgroundRender( @@ -71,17 +80,26 @@ private void render(final long time, final Set chunks) { final Map> regionChunksMap = chunks.stream().collect(Collectors.groupingBy(ChunkCoordinate::regionCoordinate)); regionChunksMap.forEach((region, chunksToRenderInRegion) -> { - final Image image = new Image(region, this.mapWorld.tilesPath(), this.mapWorld.config().ZOOM_MAX); + final Image image = new Image(region, this.mapWorld.config().ZOOM_MAX); - final CompletableFuture[] chunkFutures = chunksToRenderInRegion.stream() - .map(coord -> this.mapSingleChunk(image, coord.x(), coord.z())) - .toArray(CompletableFuture[]::new); + final Map> chunkFutures = new LinkedHashMap<>(); + for (final ChunkCoordinate coord : chunksToRenderInRegion) { + chunkFutures.put(coord, this.mapSingleChunk(image, coord.x(), coord.z())); + } - regionFutures.add(CompletableFuture.allOf(chunkFutures).thenRun(() -> { + regionFutures.add(CompletableFuture.allOf(chunkFutures.values().toArray(CompletableFuture[]::new)).thenRun(() -> { if (!this.running()) { return; } - chunksToRenderInRegion.forEach(chunks::remove); + // only drop the chunks that were actually drawn. one that could not be read + // contributed nothing to the image, so leaving it queued lets it be retried + // instead of leaving a permanent hole in the map + chunkFutures.forEach((coord, future) -> { + if (future.join()) { + this.failedAttempts.remove(coord); + chunks.remove(coord); + } + }); this.mapWorld.saveImage(image); })); }); @@ -95,7 +113,7 @@ private void render(final long time, final Set chunks) { this.clearCaches(); - chunks.forEach(this.mapWorld::chunkModified); + this.requeueFailed(chunks); Logging.debug(() -> String.format( "Finished background render cycle in %.2f seconds", @@ -103,6 +121,25 @@ private void render(final long time, final Set chunks) { )); } + /** + * Requeues chunks that could not be read this cycle, giving up on any that have failed + * {@link #MAX_ATTEMPTS} times so that chunks which will never be readable do not + * accumulate in the queue. + * + * @param chunks chunks left unrendered + */ + private void requeueFailed(final Set chunks) { + final Iterator it = chunks.iterator(); + while (it.hasNext()) { + final ChunkCoordinate coord = it.next(); + if (this.failedAttempts.merge(coord, 1, Integer::sum) >= MAX_ATTEMPTS) { + this.failedAttempts.remove(coord); + it.remove(); + } + } + chunks.forEach(this.mapWorld::chunkModified); + } + private static ExecutorService createBackgroundRenderWorkerPool(final MapWorldInternal world) { return Util.newFixedThreadPool( getThreads(world.config().BACKGROUND_RENDER_MAX_THREADS, 3), diff --git a/common/src/main/java/xyz/jpenilla/squaremap/common/task/render/RadiusRender.java b/common/src/main/java/xyz/jpenilla/squaremap/common/task/render/RadiusRender.java index 7a4ceef0..0cf7c0d3 100644 --- a/common/src/main/java/xyz/jpenilla/squaremap/common/task/render/RadiusRender.java +++ b/common/src/main/java/xyz/jpenilla/squaremap/common/task/render/RadiusRender.java @@ -112,8 +112,8 @@ private void render0() { this.mapRegion(region); continue; } - final Image image = new Image(region, this.mapWorld.tilesPath(), this.mapWorld.config().ZOOM_MAX); - final List> chunkFutures = new ArrayList<>(); + final Image image = new Image(region, this.mapWorld.config().ZOOM_MAX); + final List> chunkFutures = new ArrayList<>(); for (final ChunkCoordinate chunkCoord : chunkCoords) { chunkFutures.add(this.mapSingleChunk(image, chunkCoord.x(), chunkCoord.z())); } diff --git a/common/src/main/java/xyz/jpenilla/squaremap/common/util/ImageIOExecutor.java b/common/src/main/java/xyz/jpenilla/squaremap/common/util/ImageIOExecutor.java index 1d69da3b..45899560 100644 --- a/common/src/main/java/xyz/jpenilla/squaremap/common/util/ImageIOExecutor.java +++ b/common/src/main/java/xyz/jpenilla/squaremap/common/util/ImageIOExecutor.java @@ -1,27 +1,43 @@ package xyz.jpenilla.squaremap.common.util; -import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.locks.LockSupport; import net.minecraft.server.level.ServerLevel; import org.checkerframework.checker.nullness.qual.NonNull; import org.checkerframework.framework.qual.DefaultQualifier; +import xyz.jpenilla.squaremap.common.Logging; import xyz.jpenilla.squaremap.common.data.Image; +import xyz.jpenilla.squaremap.common.data.TileCache; +import xyz.jpenilla.squaremap.common.data.TileUpdates; @DefaultQualifier(NonNull.class) public final class ImageIOExecutor { private static final int IMAGE_IO_MAX_TASKS = 100; + private static final long FLUSH_INTERVAL_SECONDS = 2L; - private final ExecutorService executor; + private final ScheduledExecutorService executor; + private final TileCache tileCache; + private final TileUpdates tileUpdates; private final AtomicLong submittedTasks = new AtomicLong(); private final AtomicLong executedTasks = new AtomicLong(); - private ImageIOExecutor(final ServerLevel level) { - this.executor = Executors.newSingleThreadExecutor( + private ImageIOExecutor(final ServerLevel level, final TileCache tileCache, final TileUpdates tileUpdates) { + this.executor = Executors.newSingleThreadScheduledExecutor( Util.squaremapThreadFactory("imageio", level) ); + this.tileCache = tileCache; + this.tileUpdates = tileUpdates; + // tiles shared by several regions are held back so they are only encoded once, so they + // need writing out on a timer rather than as each region is drawn + this.executor.scheduleWithFixedDelay( + this::flush, + FLUSH_INTERVAL_SECONDS, + FLUSH_INTERVAL_SECONDS, + TimeUnit.SECONDS + ); } /** @@ -36,7 +52,8 @@ public void saveImage(final Image image) { this.submittedTasks.getAndIncrement(); this.executor.execute(() -> { try { - image.save(); + image.drawTo(this.tileCache); + this.tileUpdates.writeIfDue(); } finally { this.executedTasks.getAndIncrement(); } @@ -56,11 +73,22 @@ public void saveImage(final Image image) { } } + private void flush() { + try { + this.tileCache.flush(); + this.tileUpdates.writeIfDirty(); + } catch (final Exception ex) { + // a failed flush must not cancel the scheduled task + Logging.logger().warn("Failed to flush map tiles", ex); + } + } + public void shutdown() { Util.shutdownExecutor(this.executor, TimeUnit.SECONDS, 5L); + this.flush(); } - public static ImageIOExecutor create(final ServerLevel level) { - return new ImageIOExecutor(level); + public static ImageIOExecutor create(final ServerLevel level, final TileCache tileCache, final TileUpdates tileUpdates) { + return new ImageIOExecutor(level, tileCache, tileUpdates); } } diff --git a/fabric/src/main/java/xyz/jpenilla/squaremap/fabric/data/FabricMapWorld.java b/fabric/src/main/java/xyz/jpenilla/squaremap/fabric/data/FabricMapWorld.java index f27b67b9..13d09342 100644 --- a/fabric/src/main/java/xyz/jpenilla/squaremap/fabric/data/FabricMapWorld.java +++ b/fabric/src/main/java/xyz/jpenilla/squaremap/fabric/data/FabricMapWorld.java @@ -8,6 +8,7 @@ import xyz.jpenilla.squaremap.common.config.ConfigManager; import xyz.jpenilla.squaremap.common.data.DirectoryProvider; import xyz.jpenilla.squaremap.common.data.MapWorldInternal; +import xyz.jpenilla.squaremap.common.httpd.JsonCache; import xyz.jpenilla.squaremap.common.task.TaskFactory; import xyz.jpenilla.squaremap.common.task.UpdateMarkers; import xyz.jpenilla.squaremap.common.task.render.RenderFactory; @@ -22,9 +23,10 @@ private FabricMapWorld( final RenderFactory renderFactory, final DirectoryProvider directoryProvider, final ConfigManager configManager, - final TaskFactory taskFactory + final TaskFactory taskFactory, + final JsonCache jsonCache ) { - super(level, renderFactory, directoryProvider, configManager); + super(level, renderFactory, directoryProvider, configManager, jsonCache); this.updateMarkers = taskFactory.createUpdateMarkers(this); } diff --git a/neoforge/src/main/java/xyz/jpenilla/squaremap/forge/data/ForgeMapWorld.java b/neoforge/src/main/java/xyz/jpenilla/squaremap/forge/data/ForgeMapWorld.java index 3f30158e..c216c392 100644 --- a/neoforge/src/main/java/xyz/jpenilla/squaremap/forge/data/ForgeMapWorld.java +++ b/neoforge/src/main/java/xyz/jpenilla/squaremap/forge/data/ForgeMapWorld.java @@ -8,6 +8,7 @@ import xyz.jpenilla.squaremap.common.config.ConfigManager; import xyz.jpenilla.squaremap.common.data.DirectoryProvider; import xyz.jpenilla.squaremap.common.data.MapWorldInternal; +import xyz.jpenilla.squaremap.common.httpd.JsonCache; import xyz.jpenilla.squaremap.common.task.TaskFactory; import xyz.jpenilla.squaremap.common.task.UpdateMarkers; import xyz.jpenilla.squaremap.common.task.render.RenderFactory; @@ -22,9 +23,10 @@ private ForgeMapWorld( final RenderFactory renderFactory, final DirectoryProvider directoryProvider, final ConfigManager configManager, - final TaskFactory taskFactory + final TaskFactory taskFactory, + final JsonCache jsonCache ) { - super(level, renderFactory, directoryProvider, configManager); + super(level, renderFactory, directoryProvider, configManager, jsonCache); this.updateMarkers = taskFactory.createUpdateMarkers(this); } diff --git a/paper/src/main/java/xyz/jpenilla/squaremap/paper/data/PaperMapWorld.java b/paper/src/main/java/xyz/jpenilla/squaremap/paper/data/PaperMapWorld.java index 5dd81762..6457dfe9 100644 --- a/paper/src/main/java/xyz/jpenilla/squaremap/paper/data/PaperMapWorld.java +++ b/paper/src/main/java/xyz/jpenilla/squaremap/paper/data/PaperMapWorld.java @@ -16,6 +16,7 @@ import xyz.jpenilla.squaremap.common.config.ConfigManager; import xyz.jpenilla.squaremap.common.data.DirectoryProvider; import xyz.jpenilla.squaremap.common.data.MapWorldInternal; +import xyz.jpenilla.squaremap.common.httpd.JsonCache; import xyz.jpenilla.squaremap.common.task.TaskFactory; import xyz.jpenilla.squaremap.common.task.render.RenderFactory; import xyz.jpenilla.squaremap.common.util.ExceptionLoggingScheduledThreadPoolExecutor; @@ -34,9 +35,10 @@ private PaperMapWorld( final DirectoryProvider directoryProvider, final Server server, final ConfigManager configManager, - final TaskFactory taskFactory + final TaskFactory taskFactory, + final JsonCache jsonCache ) { - super(level, renderFactory, directoryProvider, configManager); + super(level, renderFactory, directoryProvider, configManager, jsonCache); if (Folia.FOLIA) { this.markerTaskHandler = new FoliaMarkerTaskHandler(level, taskFactory); diff --git a/sponge/src/main/java/xyz/jpenilla/squaremap/sponge/data/SpongeMapWorld.java b/sponge/src/main/java/xyz/jpenilla/squaremap/sponge/data/SpongeMapWorld.java index 96ab38b1..a787cb63 100644 --- a/sponge/src/main/java/xyz/jpenilla/squaremap/sponge/data/SpongeMapWorld.java +++ b/sponge/src/main/java/xyz/jpenilla/squaremap/sponge/data/SpongeMapWorld.java @@ -13,6 +13,7 @@ import xyz.jpenilla.squaremap.common.config.ConfigManager; import xyz.jpenilla.squaremap.common.data.DirectoryProvider; import xyz.jpenilla.squaremap.common.data.MapWorldInternal; +import xyz.jpenilla.squaremap.common.httpd.JsonCache; import xyz.jpenilla.squaremap.common.task.TaskFactory; import xyz.jpenilla.squaremap.common.task.render.RenderFactory; @@ -28,9 +29,10 @@ private SpongeMapWorld( final Game game, final PluginContainer pluginContainer, final ConfigManager configManager, - final TaskFactory taskFactory + final TaskFactory taskFactory, + final JsonCache jsonCache ) { - super(level, renderFactory, directoryProvider, configManager); + super(level, renderFactory, directoryProvider, configManager, jsonCache); this.updateMarkers = game.server().scheduler().submit( Task.builder() diff --git a/web/src/js/LayerControl.js b/web/src/js/LayerControl.js index 989ae437..ccb29ffd 100644 --- a/web/src/js/LayerControl.js +++ b/web/src/js/LayerControl.js @@ -3,24 +3,19 @@ import L from "leaflet"; import { SquaremapTileLayer } from "./SquaremapTileLayer.js"; class LayerControl { - /** @type {number} */ - currentLayer; - /** @type {number} */ - updateInterval; /** @type {L.LayerGroup} */ playersLayer; /** @type {L.Control.Layers} */ controls; - /** @type {L.TileLayer} */ - tileLayer1; - /** @type {L.TileLayer} */ - tileLayer2; + /** @type {SquaremapTileLayer} */ + tileLayer; + /** @type {number | null} */ + lastTileUpdate; /** @type {L.Layer} */ ignoreLayer; init() { - this.currentLayer = 0; - this.updateInterval = 60; + this.lastTileUpdate = null; this.playersLayer = new L.LayerGroup(); this.playersLayer.id = "players_layer"; @@ -87,17 +82,13 @@ class LayerControl { /** * @param world {World} */ - setupTileLayers(world) { - // setup the map tile layers - // we need 2 layers to swap between for seamless refreshing - if (this.tileLayer1 != null) { - S.map.removeLayer(this.tileLayer1); + setupTileLayer(world) { + // setup the map tile layer + if (this.tileLayer != null) { + S.map.removeLayer(this.tileLayer); } - if (this.tileLayer2 != null) { - S.map.removeLayer(this.tileLayer2); - } - this.tileLayer1 = this.createTileLayer(world); - this.tileLayer2 = this.createTileLayer(world); + this.tileLayer = this.createTileLayer(world); + this.lastTileUpdate = null; // refresh player's control this.removeOverlay(this.playersLayer); @@ -109,7 +100,7 @@ class LayerControl { } /** * @param world {World} - * @returns {L.TileLayer} + * @returns {SquaremapTileLayer} */ createTileLayer(world) { return new SquaremapTileLayer(`tiles/${world.name}/{z}/{x}_{y}.png`, { @@ -117,31 +108,40 @@ class LayerControl { minNativeZoom: 0, maxNativeZoom: world.zoom.max, errorTileUrl: "images/clear.png", - }) - .addTo(S.map) - .addEventListener("load", () => { - // when all tiles are loaded, switch to this layer - this.switchTileLayer(); - }); + }).addTo(S.map); } - updateTileLayer() { - // redraw background tile layer - if (this.currentLayer === 1) { - this.tileLayer2.redraw(); - } else { - this.tileLayer1.redraw(); + /** + * Reload the tiles the server has rewritten since the last manifest we read. + * + * @param json {TileUpdates} + */ + updateTileLayer(json) { + if (this.tileLayer == null || json == null) { + return; } - } - switchTileLayer() { - // swap current tile layer - if (this.currentLayer === 1) { - this.tileLayer1.setZIndex(0); - this.tileLayer2.setZIndex(1); - this.currentLayer = 2; - } else { - this.tileLayer1.setZIndex(1); - this.tileLayer2.setZIndex(0); - this.currentLayer = 1; + + const previous = this.lastTileUpdate; + this.lastTileUpdate = json.timestamp; + + if (previous == null) { + // first manifest of this session, the tiles we already loaded are current + return; + } + + if (json.dropped > previous) { + // the server discarded updates before we read them, so we can't tell what changed + this.tileLayer.reloadDisplayedTiles(json.timestamp); + return; + } + + const changed = new Map(); + for (const key in json.tiles) { + if (json.tiles[key] > previous) { + changed.set(key, json.tiles[key]); + } + } + if (changed.size > 0) { + this.tileLayer.updateTiles(changed); } } } diff --git a/web/src/js/SquaremapTileLayer.js b/web/src/js/SquaremapTileLayer.js index f10864d6..06f21a78 100644 --- a/web/src/js/SquaremapTileLayer.js +++ b/web/src/js/SquaremapTileLayer.js @@ -1,44 +1,111 @@ import L from "leaflet"; +//Cap on how many tile versions are remembered for tiles that aren't currently displayed +const MAX_TRACKED_VERSIONS = 4096; + +/** + * @param coords {L.Coords} + * @returns {string} + */ +function tileKey(coords) { + return `${coords.z}/${coords.x}_${coords.y}`; +} + export const SquaremapTileLayer = L.TileLayer.extend({ - // @method createTile(coords: Object, done?: Function): HTMLElement - // Called only internally, overrides GridLayer's [`createTile()`](#gridlayer-createtile) - // to return an `` HTML element with the appropriate image URL given `coords`. The `done` - // callback is called when the tile has been loaded. - createTile: function (coords, done) { - var tile = document.createElement("img"); - - L.DomEvent.on(tile, "load", () => { - //Once image has loaded revoke the object URL as we don't need it anymore - URL.revokeObjectURL(tile.src); - this._tileOnLoad(done, tile); - }); - L.DomEvent.on(tile, "error", L.Util.bind(this._tileOnError, this, done, tile)); + initialize: function (url, options) { + L.TileLayer.prototype.initialize.call(this, url, options); + //Version published by the server for each tile, keyed as it appears in the update manifest + this._tileVersions = new Map(); + }, + // @method getTileUrl(coords: Object): String + // Overrides TileLayer's [`getTileUrl()`](#tilelayer-gettileurl) to append the version the server + // last published for this tile. Tiles carrying a version are served with a long cache lifetime, + // so the changing URL is what makes the browser fetch a tile that has been rewritten. + getTileUrl: function (coords) { + const url = L.TileLayer.prototype.getTileUrl.call(this, coords); + const version = this._tileVersions.get(tileKey(coords)); + return version === undefined ? url : `${url}?v=${version}`; + }, + + // @method updateTiles(versions: Map): void + // Records the given tile versions and reloads any of those tiles that are currently displayed. + // Tiles that aren't displayed are picked up by `getTileUrl` whenever they are next created. + updateTiles: function (versions) { + for (const [key, version] of versions) { + //Delete first so that the map stays ordered from least to most recently updated + this._tileVersions.delete(key); + this._tileVersions.set(key, version); + } + this._pruneTileVersions(); + for (const key in this._tiles) { + const tile = this._tiles[key]; + if (versions.has(tileKey(tile.coords))) { + this._reloadTile(tile); + } + } + }, + + // @method reloadDisplayedTiles(version: Number): void + // Reloads every displayed tile at the given version. Used when the server dropped updates before + // this client read them, leaving no way to tell which tiles changed. + reloadDisplayedTiles: function (version) { + const versions = new Map(); + for (const key in this._tiles) { + versions.set(tileKey(this._tiles[key].coords), version); + } + this.updateTiles(versions); + }, + + /** + * @param tile {{el: HTMLImageElement, coords: L.Coords, loaded?: number}} + */ + _reloadTile: function (tile) { + const url = this.getTileUrl(tile.coords); + const key = this._tileCoordsToKey(tile.coords); + + const swap = () => { + //The tile may have been pruned or replaced while the new image was loading + if (this._tiles[key] !== tile) { + return; + } + if (tile.loaded) { + //GridLayer fades a tile in from fully transparent every time its load event fires, so + //drop its listener before swapping. A tile that has loaded no longer needs to report in. + L.DomEvent.off(tile.el, "load"); + } + tile.el.src = url; + }; + + //Fully decode the replacement before swapping it in, otherwise the tile blanks out while it loads + const next = document.createElement("img"); if (this.options.crossOrigin || this.options.crossOrigin === "") { - tile.crossOrigin = this.options.crossOrigin === true ? "" : this.options.crossOrigin; - } - - tile.alt = ""; - tile.setAttribute("role", "presentation"); - - //Retrieve image via a fetch instead of just setting the src - //This works around the fact that browsers usually don't make a request for an image that was previously loaded, - //without resorting to changing the URL (which would break caching). - fetch(this.getTileUrl(coords)) - .then((res) => { - //Call leaflet's error handler if request fails for some reason - if (!res.ok) { - this._tileOnError(done, tile, null); - return; - } - - //Get image data and convert into object URL so it can be used as a src - //Leaflet's onload listener will take it from here - res.blob().then((blob) => (tile.src = URL.createObjectURL(blob))); - }) - .catch(() => this._tileOnError(done, tile, null)); - - return tile; + next.crossOrigin = this.options.crossOrigin === true ? "" : this.options.crossOrigin; + } + next.src = url; + if (typeof next.decode === "function") { + //Leave the existing image in place if the replacement fails to load + next.decode().then(swap, () => {}); + } else { + L.DomEvent.on(next, "load", swap); + } + }, + + _pruneTileVersions: function () { + if (this._tileVersions.size <= MAX_TRACKED_VERSIONS) { + return; + } + const displayed = new Set(); + for (const key in this._tiles) { + displayed.add(tileKey(this._tiles[key].coords)); + } + for (const key of this._tileVersions.keys()) { + if (this._tileVersions.size <= MAX_TRACKED_VERSIONS) { + break; + } + if (!displayed.has(key)) { + this._tileVersions.delete(key); + } + } }, }); diff --git a/web/src/js/types.ts b/web/src/js/types.ts index 80579ed3..6e5af498 100644 --- a/web/src/js/types.ts +++ b/web/src/js/types.ts @@ -72,6 +72,12 @@ export interface WorldSettings { tiles_update_interval: number; } +export interface TileUpdates { + timestamp: number; + dropped: number; + tiles: Record; +} + export interface PlayerData { name: string; display_name?: string; diff --git a/web/src/js/util/World.js b/web/src/js/util/World.js index 64afe796..5297327e 100644 --- a/web/src/js/util/World.js +++ b/web/src/js/util/World.js @@ -40,7 +40,7 @@ class World { tick() { // refresh map tile layer if (!S.staticMode && S.tick_count % this.tiles_update_interval === 0) { - S.layerControl.updateTileLayer(); + this.tickTiles(); } // load and draw markers if (!S.staticMode && S.tick_count % this.marker_update_interval === 0) { @@ -51,6 +51,18 @@ class World { this.staticNeedsMarkerTick = false; } } + tickTiles() { + S.getJSON( + `tiles/${this.name}/updates.json`, + /** @param {TileUpdates} json */ + (json) => { + if (this === S.worldList.curWorld) { + S.layerControl.updateTileLayer(json); + } + }, + true, + ); + } tickMarkers() { S.getJSON( `tiles/${this.name}/markers.json`, @@ -102,7 +114,7 @@ class World { document.getElementById("map").style.background = this.getBackground(); // setup tile layers - S.layerControl.setupTileLayers(this); + S.layerControl.setupTileLayer(this); // force clear player markers S.playerList.clearPlayerMarkers();