diff --git a/src/main/java/com/easywebmap/commands/EasyWebMapCommand.java b/src/main/java/com/easywebmap/commands/EasyWebMapCommand.java index 29680ef..46f3613 100644 --- a/src/main/java/com/easywebmap/commands/EasyWebMapCommand.java +++ b/src/main/java/com/easywebmap/commands/EasyWebMapCommand.java @@ -12,7 +12,7 @@ import com.hypixel.hytale.server.core.universe.world.World; import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; import com.hypixel.hytale.math.vector.Transform; -import com.hypixel.hytale.math.vector.Vector3d; +import org.joml.Vector3d; import java.awt.Color; import java.time.Duration; import java.time.Instant; diff --git a/src/main/java/com/easywebmap/map/CompositeTileGenerator.java b/src/main/java/com/easywebmap/map/CompositeTileGenerator.java index b213059..201baa9 100644 --- a/src/main/java/com/easywebmap/map/CompositeTileGenerator.java +++ b/src/main/java/com/easywebmap/map/CompositeTileGenerator.java @@ -123,8 +123,10 @@ private byte[] compositeFromPixels(List tiles, int chunksPerAx return PngEncoder.encodeEmpty(outputSize); } - // Use RGB (no alpha) - faster encoding - BufferedImage composite = new BufferedImage(outputSize, outputSize, BufferedImage.TYPE_INT_RGB); + // ARGB: present sub-tiles are opaque, missing/unexplored sub-chunks stay + // at 0 (fully transparent) instead of opaque black — so composite gaps + // show the map background seamlessly rather than black blocks. + BufferedImage composite = new BufferedImage(outputSize, outputSize, BufferedImage.TYPE_INT_ARGB); composite.setRGB(0, 0, outputSize, outputSize, compositePixels, 0, outputSize); return encodeFast(composite, outputSize); diff --git a/src/main/java/com/easywebmap/map/PngDecoder.java b/src/main/java/com/easywebmap/map/PngDecoder.java index bac7694..02894aa 100644 --- a/src/main/java/com/easywebmap/map/PngDecoder.java +++ b/src/main/java/com/easywebmap/map/PngDecoder.java @@ -35,7 +35,7 @@ public static int[] decode(byte[] pngBytes, int expectedSize) { // Ensure expected dimensions if (width != expectedSize || height != expectedSize) { // Scale if needed (shouldn't normally happen) - BufferedImage scaled = new BufferedImage(expectedSize, expectedSize, BufferedImage.TYPE_INT_RGB); + BufferedImage scaled = new BufferedImage(expectedSize, expectedSize, BufferedImage.TYPE_INT_ARGB); scaled.getGraphics().drawImage(image, 0, 0, expectedSize, expectedSize, null); image = scaled; width = expectedSize; diff --git a/src/main/java/com/easywebmap/map/PngEncoder.java b/src/main/java/com/easywebmap/map/PngEncoder.java index 207b9b5..02beb59 100644 --- a/src/main/java/com/easywebmap/map/PngEncoder.java +++ b/src/main/java/com/easywebmap/map/PngEncoder.java @@ -25,10 +25,45 @@ public class PngEncoder { return writers.hasNext() ? writers.next() : null; }); + /** + * Reconstruct a flat colour int[] (length width*height) from the 2026-05 + * MapImage wire format. The old flat {@code int[] data} field was removed; + * pixels are now a {@code palette} indexed by {@code bitsPerIndex}-bit + * indices bit-packed (LSB-first) into {@code packedIndices}. + * + * NOTE: the per-channel extraction in encode()/encodeWithPixels() still + * treats each colour as 0xRRGGBBAA (the old data layout). If terrain renders + * with swapped channels, the palette is 0xAARRGGBB — flip the shifts there + * to (>>16,>>8,&0xFF). If tiles render as noise, the index bit-packing is + * MSB-first — reverse the bit offset here. + */ + private static int[] toArgb(MapImage img) { + int count = img.width * img.height; + int[] out = new int[count]; + int[] palette = img.palette; + byte[] packed = img.packedIndices; + int bits = img.bitsPerIndex & 0xFF; + if (count <= 0 || palette == null || packed == null || bits <= 0) { + return out; // nothing to draw + } + int mask = (1 << bits) - 1; + for (int i = 0; i < count; i++) { + int bitPos = i * bits; + int byteIdx = bitPos >> 3; + int bitOff = bitPos & 7; + int raw = packed[byteIdx] & 0xFF; + if (byteIdx + 1 < packed.length) raw |= (packed[byteIdx + 1] & 0xFF) << 8; + if (byteIdx + 2 < packed.length) raw |= (packed[byteIdx + 2] & 0xFF) << 16; + int idx = (raw >> bitOff) & mask; + out[i] = (idx >= 0 && idx < palette.length) ? palette[idx] : 0; + } + return out; + } + public static byte[] encode(MapImage mapImage, int outputSize) { int srcWidth = mapImage.width; int srcHeight = mapImage.height; - int[] srcData = mapImage.data; + int[] srcData = toArgb(mapImage); // Pre-allocate output pixel array for bulk setRGB int[] destData = new int[outputSize * outputSize]; @@ -47,12 +82,13 @@ public static byte[] encode(MapImage mapImage, int outputSize) { int r = (rgba >> 24) & 0xFF; int g = (rgba >> 16) & 0xFF; int b = (rgba >> 8) & 0xFF; - destData[destRowStart + x] = (r << 16) | (g << 8) | b; + destData[destRowStart + x] = 0xFF000000 | (r << 16) | (g << 8) | b; } } - // Use RGB (no alpha) - faster encoding - BufferedImage buffered = new BufferedImage(outputSize, outputSize, BufferedImage.TYPE_INT_RGB); + // ARGB: rendered pixels are forced opaque above; this keeps the format + // consistent with composites/empty tiles so transparency works. + BufferedImage buffered = new BufferedImage(outputSize, outputSize, BufferedImage.TYPE_INT_ARGB); buffered.setRGB(0, 0, outputSize, outputSize, destData, 0, outputSize); return encodeFast(buffered, outputSize); @@ -64,7 +100,7 @@ public static byte[] encode(MapImage mapImage, int outputSize) { public static TileData encodeWithPixels(MapImage mapImage, int outputSize) { int srcWidth = mapImage.width; int srcHeight = mapImage.height; - int[] srcData = mapImage.data; + int[] srcData = toArgb(mapImage); int[] destData = new int[outputSize * outputSize]; float scaleX = (float) srcWidth / outputSize; @@ -80,11 +116,11 @@ public static TileData encodeWithPixels(MapImage mapImage, int outputSize) { int r = (rgba >> 24) & 0xFF; int g = (rgba >> 16) & 0xFF; int b = (rgba >> 8) & 0xFF; - destData[destRowStart + x] = (r << 16) | (g << 8) | b; + destData[destRowStart + x] = 0xFF000000 | (r << 16) | (g << 8) | b; } } - BufferedImage buffered = new BufferedImage(outputSize, outputSize, BufferedImage.TYPE_INT_RGB); + BufferedImage buffered = new BufferedImage(outputSize, outputSize, BufferedImage.TYPE_INT_ARGB); buffered.setRGB(0, 0, outputSize, outputSize, destData, 0, outputSize); byte[] pngBytes = encodeFast(buffered, outputSize); @@ -134,11 +170,21 @@ private static byte[] encodeFast(BufferedImage image, int outputSize) { */ public static byte[] encodeEmpty(int size) { return EMPTY_TILE_CACHE.computeIfAbsent(size, s -> { - BufferedImage buffered = new BufferedImage(s, s, BufferedImage.TYPE_INT_RGB); + BufferedImage buffered = new BufferedImage(s, s, BufferedImage.TYPE_INT_ARGB); return encodeFast(buffered, s); }); } + /** + * True if {@code png} is byte-identical to the blank placeholder for + * {@code size} (i.e. an empty/unrendered/black tile). This is the reliable + * empty check: a byte-length threshold does NOT work because minimal PNG + * compression (quality 1.0) makes even a solid-black tile ~197 KB. + */ + public static boolean isEmptyTile(byte[] png, int size) { + return png != null && java.util.Arrays.equals(png, encodeEmpty(size)); + } + public static class TileData { public final byte[] pngBytes; public final int[] pixels; @@ -151,7 +197,10 @@ public TileData(byte[] pngBytes, int[] pixels, int size) { } public boolean isEmpty() { - return pngBytes == null || pngBytes.length < 500; + // The empty/black-placeholder path sets pixels to an empty array. + // Byte length is unreliable here: minimal PNG compression makes even + // a solid-black tile ~197 KB, so length never flags it as empty. + return pngBytes == null || pixels == null || pixels.length == 0; } } } diff --git a/src/main/java/com/easywebmap/map/TileManager.java b/src/main/java/com/easywebmap/map/TileManager.java index be8bb13..894c224 100644 --- a/src/main/java/com/easywebmap/map/TileManager.java +++ b/src/main/java/com/easywebmap/map/TileManager.java @@ -9,8 +9,8 @@ import com.hypixel.hytale.server.core.universe.world.worldmap.WorldMapManager; import com.hypixel.hytale.server.core.universe.PlayerRef; import com.hypixel.hytale.math.vector.Transform; -import com.hypixel.hytale.math.vector.Vector3d; -import it.unimi.dsi.fastutil.longs.LongSet; +import org.joml.Vector3d; +import it.unimi.dsi.fastutil.longs.LongCollection; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Semaphore; @@ -77,7 +77,9 @@ private CompletableFuture getCompositeTile(String worldName, int zoom, i // 3. Check disk cache if (this.plugin.getConfig().isUseDiskCache()) { byte[] diskCached = this.diskCache.get(worldName, zoom, tileX, tileZ); - if (diskCached != null) { + // Skip stale empty/black placeholders (e.g. cached before a fix) so + // they fall through to regeneration instead of being served forever. + if (diskCached != null && !PngEncoder.isEmptyTile(diskCached, this.plugin.getConfig().getTileSize())) { long tileAge = this.diskCache.getTileAge(worldName, zoom, tileX, tileZ); // Use longer refresh interval for composite tiles (they're more expensive) long refreshInterval = this.plugin.getConfig().getTileRefreshIntervalMs() * 2; @@ -105,7 +107,7 @@ private CompletableFuture getCompositeTile(String worldName, int zoom, i this.pendingRequests.put(cacheKey, future); future.whenComplete((data, ex) -> { this.pendingRequests.remove(cacheKey); - if (data != null && data.length > EMPTY_TILE_THRESHOLD && ex == null) { + if (data != null && ex == null && !PngEncoder.isEmptyTile(data, this.plugin.getConfig().getTileSize())) { this.memoryCache.put(cacheKey, data); if (this.plugin.getConfig().isUseDiskCache()) { this.diskCache.putAsync(worldName, zoom, tileX, tileZ, data); @@ -137,7 +139,8 @@ public CompletableFuture getBaseTile(String worldName, int tileX, int ti // 3. Check disk cache if enabled if (this.plugin.getConfig().isUseDiskCache()) { byte[] diskCached = this.diskCache.get(worldName, 0, tileX, tileZ); - if (diskCached != null) { + // Skip stale empty/black placeholders so they regenerate (self-heal). + if (diskCached != null && !PngEncoder.isEmptyTile(diskCached, this.plugin.getConfig().getTileSize())) { long tileAge = this.diskCache.getTileAge(worldName, 0, tileX, tileZ); long refreshInterval = this.plugin.getConfig().getTileRefreshIntervalMs(); @@ -165,7 +168,7 @@ public CompletableFuture getBaseTile(String worldName, int tileX, int ti future.whenComplete((data, ex) -> { this.pendingRequests.remove(cacheKey); // Don't cache empty tiles - they should regenerate when chunk gets explored - if (data != null && data.length > EMPTY_TILE_THRESHOLD && ex == null) { + if (data != null && ex == null && !PngEncoder.isEmptyTile(data, this.plugin.getConfig().getTileSize())) { this.memoryCache.put(cacheKey, data); if (this.plugin.getConfig().isUseDiskCache()) { this.diskCache.putAsync(worldName, 0, tileX, tileZ, data); @@ -198,7 +201,7 @@ public CompletableFuture getBaseTileWithPixels(String world // 3. Check disk cache and decode PNG to pixels (faster than regenerating) if (this.plugin.getConfig().isUseDiskCache()) { byte[] diskCached = this.diskCache.get(worldName, 0, tileX, tileZ); - if (diskCached != null && diskCached.length > EMPTY_TILE_THRESHOLD) { + if (diskCached != null && !PngEncoder.isEmptyTile(diskCached, tileSize)) { try { int[] pixels = PngDecoder.decode(diskCached, tileSize); if (pixels != null) { @@ -221,7 +224,7 @@ public CompletableFuture getBaseTileWithPixels(String world future.whenComplete((data, ex) -> { this.pendingPixelRequests.remove(cacheKey); // Don't cache empty tiles - they should regenerate when chunk gets explored - if (data != null && !data.isEmpty() && data.pngBytes.length > EMPTY_TILE_THRESHOLD && ex == null) { + if (data != null && ex == null && !data.isEmpty()) { // Cache pixels for compositing, evict if too many if (this.pixelCache.size() < MAX_PIXEL_CACHE) { this.pixelCache.put(cacheKey, data); @@ -369,7 +372,7 @@ private CompletableFuture generateTile(String worldName, int zoom, int t private boolean isChunkExplored(World world, int chunkX, int chunkZ) { try { - LongSet indexes = this.getCachedChunkIndexes(world); + LongCollection indexes = this.getCachedChunkIndexes(world); if (indexes == null) { return true; // Fail open if we can't get indexes } @@ -396,7 +399,7 @@ public boolean hasAnyExploredChunks(String worldName, int baseChunkX, int baseCh } try { - LongSet indexes = this.getCachedChunkIndexes(world); + LongCollection indexes = this.getCachedChunkIndexes(world); if (indexes == null) { return true; // Fail open } @@ -433,7 +436,7 @@ public boolean hasAnyExploredChunks(String worldName, int baseChunkX, int baseCh } } - private LongSet getCachedChunkIndexes(World world) { + private LongCollection getCachedChunkIndexes(World world) { String worldName = world.getName(); CachedChunkIndexes cached = this.chunkIndexCache.get(worldName); long now = System.currentTimeMillis(); @@ -449,7 +452,7 @@ private LongSet getCachedChunkIndexes(World world) { if (loader == null) { return null; } - LongSet indexes = loader.getIndexes(); + LongCollection indexes = loader.getIndexes(); this.chunkIndexCache.put(worldName, new CachedChunkIndexes(indexes, now)); return indexes; } catch (Exception e) { @@ -522,10 +525,10 @@ public DiskTileCache getDiskCache() { } private static class CachedChunkIndexes { - final LongSet indexes; + final LongCollection indexes; final long timestamp; - CachedChunkIndexes(LongSet indexes, long timestamp) { + CachedChunkIndexes(LongCollection indexes, long timestamp) { this.indexes = indexes; this.timestamp = timestamp; } diff --git a/src/main/java/com/easywebmap/tracker/PlayerTracker.java b/src/main/java/com/easywebmap/tracker/PlayerTracker.java index e56cc72..c743950 100644 --- a/src/main/java/com/easywebmap/tracker/PlayerTracker.java +++ b/src/main/java/com/easywebmap/tracker/PlayerTracker.java @@ -3,9 +3,9 @@ import com.easywebmap.EasyWebMap; import com.google.gson.Gson; import com.google.gson.GsonBuilder; +import com.hypixel.hytale.math.vector.Rotation3f; import com.hypixel.hytale.math.vector.Transform; -import com.hypixel.hytale.math.vector.Vector3d; -import com.hypixel.hytale.math.vector.Vector3f; +import org.joml.Vector3d; import com.hypixel.hytale.server.core.universe.PlayerRef; import com.hypixel.hytale.server.core.universe.Universe; import com.hypixel.hytale.server.core.universe.world.World; @@ -107,18 +107,20 @@ private List> getPlayersInWorld(World world) { Transform transform = playerRef.getTransform(); if (transform != null) { Vector3d pos = transform.getPosition(); - Vector3f rot = transform.getRotation(); + Rotation3f rot = transform.getRotation(); Map playerData = new HashMap<>(); playerData.put("name", playerRef.getUsername()); playerData.put("uuid", playerRef.getUuid().toString()); playerData.put("x", pos.x); playerData.put("y", pos.y); playerData.put("z", pos.z); - playerData.put("yaw", rot != null ? rot.y : 0f); + playerData.put("yaw", rot != null ? rot.yaw() : 0f); players.add(playerData); } - } catch (Exception e) { - // Player may have disconnected + } catch (Throwable e) { + // Player may have disconnected, or a future API drift threw an + // Error — swallow per-player so one bad read can't kill the + // scheduled broadcast task (which would silently stop all updates). } } return players; diff --git a/src/main/java/com/easywebmap/web/handlers/PlayerHandler.java b/src/main/java/com/easywebmap/web/handlers/PlayerHandler.java index 596ce8e..da01db8 100644 --- a/src/main/java/com/easywebmap/web/handlers/PlayerHandler.java +++ b/src/main/java/com/easywebmap/web/handlers/PlayerHandler.java @@ -3,9 +3,9 @@ import com.easywebmap.EasyWebMap; import com.google.gson.Gson; import com.google.gson.GsonBuilder; +import com.hypixel.hytale.math.vector.Rotation3f; import com.hypixel.hytale.math.vector.Transform; -import com.hypixel.hytale.math.vector.Vector3d; -import com.hypixel.hytale.math.vector.Vector3f; +import org.joml.Vector3d; import com.hypixel.hytale.server.core.universe.PlayerRef; import com.hypixel.hytale.server.core.universe.Universe; import com.hypixel.hytale.server.core.universe.world.World; @@ -82,14 +82,14 @@ private List> getPlayersInWorld(World world) { Transform transform = playerRef.getTransform(); if (transform != null) { Vector3d pos = transform.getPosition(); - Vector3f rot = transform.getRotation(); + Rotation3f rot = transform.getRotation(); Map playerData = new HashMap<>(); playerData.put("name", playerRef.getUsername()); playerData.put("uuid", playerRef.getUuid().toString()); playerData.put("x", pos.x); playerData.put("y", pos.y); playerData.put("z", pos.z); - playerData.put("yaw", rot != null ? rot.y : 0f); + playerData.put("yaw", rot != null ? rot.yaw() : 0f); players.add(playerData); } } catch (Exception e) { diff --git a/src/main/resources/web/js/map.js b/src/main/resources/web/js/map.js index fabfd75..fcaec7d 100644 --- a/src/main/resources/web/js/map.js +++ b/src/main/resources/web/js/map.js @@ -275,21 +275,15 @@ function initMap() { // Using CRS.Simple: 1 unit = 1 pixel at zoom 0 - // We want 1 unit = 1 block, so we need to scale tiles - // Set large bounds to allow zooming out - const worldBounds = L.latLngBounds( - L.latLng(-100000, -100000), - L.latLng(100000, 100000) - ); - + // No maxBounds set - allow unlimited panning + // Bounds will only affect tile loading, not map navigation map = L.map('map', { crs: L.CRS.Simple, minZoom: -4, maxZoom: 4, zoomSnap: 0.5, - zoomDelta: 0.5, - maxBounds: worldBounds, - maxBoundsViscosity: 1.0 + zoomDelta: 0.5 + // No maxBounds - unlimited panning allowed }); // Start at origin @@ -320,6 +314,7 @@ // minNativeZoom: -3 means server provides composite tiles at negative zoom levels // Using -3 instead of -4 reduces base tiles per composite from 256 to 64 (4x faster) // Users can still zoom out to -4, tiles will be scaled from -3 + // No bounds set on tile layer - tiles load for any coordinate tileLayer = L.tileLayer.batch('/api/tiles/' + currentWorld + '/{z}/{x}/{y}.png', { tileSize: TILE_SIZE, minNativeZoom: -3, // Server provides tiles from -3 to 0 (64 base tiles max) @@ -327,7 +322,7 @@ minZoom: -4, // User can still zoom out to -4 (scaled from -3) maxZoom: 4, noWrap: true, - bounds: [[-100000, -100000], [100000, 100000]], + // No bounds - tiles load for any coordinate batchDelay: 150, // Fast batching at zoom >= 0 batchDelayNegative: 400, // Slower batching at negative zoom (composite tiles) maxBatchSize: 2000,