Add render statistics, support for tile hashcodes to stop non-updates

This commit is contained in:
Mike Primm 2011-05-31 00:33:54 -05:00
parent 4b30fff8a7
commit d393ccf6e9
11 changed files with 418 additions and 115 deletions

View File

@ -65,6 +65,9 @@ display-whitelist: false
# How often a tile gets rendered (in seconds).
renderinterval: 1
# Tile hashing is used to minimize tile file updates when no changes have occurred - set to false to disable
enabletilehash: true
render-triggers:
# - chunkloaded
# - playermove

View File

@ -277,7 +277,9 @@ public class DynmapPlugin extends JavaPlugin {
"hide",
"show",
"fullrender",
"reload" }));
"reload",
"stats",
"resetstats" }));
@Override
public boolean onCommand(CommandSender sender, Command cmd, String commandLabel, String[] args) {
@ -343,6 +345,16 @@ public class DynmapPlugin extends JavaPlugin {
sender.sendMessage("Reloading Dynmap...");
reload();
sender.sendMessage("Dynmap reloaded");
} else if (c.equals("stats") && checkPlayerPermission(sender, "stats")) {
if(args.length == 1)
mapManager.printStats(sender, null);
else
mapManager.printStats(sender, args[1]);
} else if (c.equals("resetstats") && checkPlayerPermission(sender, "resetstats")) {
if(args.length == 1)
mapManager.resetStats(sender, null);
else
mapManager.resetStats(sender, args[1]);
}
return true;
}

View File

@ -8,6 +8,7 @@ import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.TreeSet;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.Callable;
@ -15,6 +16,7 @@ import java.util.concurrent.Future;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.scheduler.BukkitScheduler;
import org.bukkit.command.CommandSender;
import org.dynmap.debug.Debug;
public class MapManager {
@ -27,16 +29,26 @@ public class MapManager {
private long timeslice_int = 0; /* In milliseconds */
/* Which fullrenders are active */
private HashMap<String, FullWorldRenderState> active_renders = new HashMap<String, FullWorldRenderState>();
/* Tile hash manager */
public TileHashManager hashman;
/* lock for our data structures */
public static final Object lock = new Object();
public static MapManager mapman; /* Our singleton */
/* Thread pool for processing renders */
private ScheduledThreadPoolExecutor renderpool;
private DynmapScheduledThreadPoolExecutor renderpool;
private static final int POOL_SIZE = 3;
private HashMap<String, MapStats> mapstats = new HashMap<String, MapStats>();
private static class MapStats {
int loggedcnt;
int renderedcnt;
int updatedcnt;
int transparentcnt;
}
public DynmapWorld getWorld(String name) {
DynmapWorld world = worldsLookup.get(name);
return world;
@ -46,6 +58,21 @@ public class MapManager {
return worlds;
}
private class DynmapScheduledThreadPoolExecutor extends ScheduledThreadPoolExecutor {
DynmapScheduledThreadPoolExecutor() {
super(POOL_SIZE);
}
protected void afterExecute(Runnable r, Throwable x) {
if(r instanceof FullWorldRenderState) {
((FullWorldRenderState)r).cleanup();
}
if(x != null) {
Log.severe("Exception during render job: " + r);
x.printStackTrace();
}
}
}
/* This always runs on render pool threads - no bukkit calls from here */
private class FullWorldRenderState implements Runnable {
DynmapWorld world; /* Which world are we rendering */
@ -56,6 +83,7 @@ public class MapManager {
HashSet<MapTile> rendered = null;
LinkedList<MapTile> renderQueue = null;
MapTile tile0 = null;
MapTile tile = null;
int rendercnt = 0;
/* Full world, all maps render */
@ -73,8 +101,18 @@ public class MapManager {
tile0 = t;
}
public String toString() {
return "world=" + world.world.getName() + ", map=" + map + " tile=" + tile;
}
public void cleanup() {
if(tile0 == null) {
synchronized(lock) {
active_renders.remove(world.world.getName());
}
}
}
public void run() {
MapTile tile;
long tstart = System.currentTimeMillis();
if(tile0 == null) { /* Not single tile render */
@ -90,9 +128,7 @@ public class MapManager {
map_index++; /* Next map */
if(map_index >= world.maps.size()) { /* Last one done? */
Log.info("Full render of '" + world.world.getName() + "' finished.");
synchronized(lock) {
active_renders.remove(world.world.getName());
}
cleanup();
return;
}
map = world.maps.get(map_index);
@ -125,6 +161,7 @@ public class MapManager {
DynmapChunk[] requiredChunks = tile.getMap().getRequiredChunks(tile);
MapChunkCache cache = createMapChunkCache(w, requiredChunks);
if(cache == null) {
cleanup();
return; /* Cancelled/aborted */
}
if(tile0 != null) { /* Single tile? */
@ -159,6 +196,9 @@ public class MapManager {
renderpool.execute(this);
}
}
else {
cleanup();
}
}
}
@ -193,6 +233,8 @@ public class MapManager {
scheduler = plugin.getServer().getScheduler();
hashman = new TileHashManager(DynmapPlugin.tilesDirectory, configuration.getBoolean("enabletilehash", true));
tileQueue.start();
for (World world : plug_in.getServer().getWorlds()) {
@ -308,7 +350,7 @@ public class MapManager {
public void startRendering() {
tileQueue.start();
renderpool = new ScheduledThreadPoolExecutor(POOL_SIZE);
renderpool = new DynmapScheduledThreadPoolExecutor();
}
public void stopRendering() {
@ -379,4 +421,65 @@ public class MapManager {
return null;
}
}
/**
* Update map tile statistics
*/
public void updateStatistics(MapTile tile, String subtype, boolean rendered, boolean updated, boolean transparent) {
synchronized(lock) {
String k = tile.getKey();
if(subtype != null)
k += "." + subtype;
MapStats ms = mapstats.get(k);
if(ms == null) {
ms = new MapStats();
mapstats.put(k, ms);
}
ms.loggedcnt++;
if(rendered)
ms.renderedcnt++;
if(updated)
ms.updatedcnt++;
if(transparent)
ms.transparentcnt++;
}
}
/**
* Print statistics command
*/
public void printStats(CommandSender sender, String prefix) {
sender.sendMessage("Tile Render Statistics:");
MapStats tot = new MapStats();
synchronized(lock) {
for(String k: new TreeSet<String>(mapstats.keySet())) {
if((prefix != null) && !k.startsWith(prefix))
continue;
MapStats ms = mapstats.get(k);
sender.sendMessage(" " + k + ": processed=" + ms.loggedcnt + ", rendered=" + ms.renderedcnt +
", updated=" + ms.updatedcnt + ", transparent=" + ms.transparentcnt);
tot.loggedcnt += ms.loggedcnt;
tot.renderedcnt += ms.renderedcnt;
tot.updatedcnt += ms.updatedcnt;
tot.transparentcnt += ms.transparentcnt;
}
}
sender.sendMessage(" TOTALS: processed=" + tot.loggedcnt + ", rendered=" + tot.renderedcnt +
", updated=" + tot.updatedcnt + ", transparent=" + tot.transparentcnt);
}
/**
* Reset statistics
*/
public void resetStats(CommandSender sender, String prefix) {
synchronized(lock) {
for(String k : mapstats.keySet()) {
if((prefix != null) && !k.startsWith(prefix))
continue;
MapStats ms = mapstats.get(k);
ms.loggedcnt = 0;
ms.renderedcnt = 0;
ms.updatedcnt = 0;
ms.transparentcnt = 0;
}
}
sender.sendMessage("Tile Render Statistics reset");
}
}

View File

@ -36,4 +36,8 @@ public abstract class MapTile {
}
return super.equals(obj);
}
public String getKey() {
return world.getName() + "." + map.getName();
}
}

View File

@ -18,4 +18,6 @@ public abstract class MapType {
public void buildClientConfiguration(JSONObject worldObject) {
}
public abstract String getName();
}

View File

@ -0,0 +1,152 @@
package org.dynmap;
import java.io.File;
import java.io.RandomAccessFile;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.io.IOException;
import java.util.zip.CRC32;
/**
* Image hash code manager - used to reduce compression and notification of updated tiles that do not actually yield new content
*
*/
public class TileHashManager {
private File tiledir; /* Base tile directory */
private boolean enabled;
/**
* Each tile hash file is a 32x32 tile grid, with each file having a CRC32 hash code generated from its pre-compression frame buffer
*/
private static class TileHashFile {
final String key;
final String subtype;
final int x; /* minimum tile coordinate / 32 */
final int y; /* minimum tile coordinate / 32 */
private File hf;
TileHashFile(String key, String subtype, int x, int y) {
this.key = key;
if(subtype != null)
this.subtype = subtype;
else
this.subtype = "";
this.x = x;
this.y = y;
}
@Override
public boolean equals(Object o) {
if(!(o instanceof TileHashFile))
return false;
TileHashFile fo = (TileHashFile)o;
return (x == fo.x) && (y == fo.y) && key.equals(fo.key) && (subtype.equals(fo.subtype));
}
@Override
public int hashCode() {
return key.hashCode() ^ subtype.hashCode() ^ (x << 16) ^ y;
}
public File getHashFile(File tiledir) {
if(hf == null)
hf = new File(tiledir, key + (subtype.equals("")?"":("." + subtype)) + "_" + x + "_" + y + ".hash");
return hf;
}
/* Write to file */
public void writeToFile(File tiledir, byte[] crcbuf) {
try {
RandomAccessFile fd = new RandomAccessFile(getHashFile(tiledir), "rw");
fd.seek(0);
fd.write(crcbuf);
fd.close();
} catch (IOException iox) {
Log.severe("Error writing hash file - " + getHashFile(tiledir).getPath());
}
}
/* Read from file */
public void readFromFile(File tiledir, byte[] crcbuf) {
try {
RandomAccessFile fd = new RandomAccessFile(getHashFile(tiledir), "r");
fd.seek(0);
fd.read(crcbuf);
fd.close();
} catch (IOException iox) {
Arrays.fill(crcbuf, (byte)0xFF);
writeToFile(tiledir, crcbuf);
}
}
/* Read CRC */
public long getCRC(int tx, int ty, byte[] crcbuf) {
int off = (128 * (ty & 0x1F)) + (4 * (tx & 0x1F));
long crc = 0;
for(int i = 0; i < 4; i++)
crc = (crc << 8) + (0xFF & (int)crcbuf[off+i]);
return crc;
}
/* Set CRC */
public void setCRC(int tx, int ty, byte[] crcbuf, long crc) {
int off = (128 * (ty & 0x1F)) + (4 * (tx & 0x1F));
for(int i = 0; i < 4; i++)
crcbuf[off+i] = (byte)((crc >> ((3-i)*8)) & 0xFF);
}
}
private Object lock = new Object();
private LinkedHashMap<TileHashFile, byte[]> tilehash = new LinkedHashMap<TileHashFile, byte[]>(16, (float) 0.75, true);
private CRC32 crc32 = new CRC32();
public TileHashManager(File tileroot, boolean enabled) {
tiledir = tileroot;
this.enabled = enabled;
}
/* Read cached hashcode for given tile */
public long getImageHashCode(String key, String subtype, int tx, int ty) {
if(!enabled) {
return -1; /* Return value that never matches */
}
TileHashFile thf = new TileHashFile(key, subtype, tx >> 5, ty >> 5);
synchronized(lock) {
byte[] crcbuf = tilehash.get(thf); /* See if we have it cached */
if(crcbuf == null) { /* If not in cache, load it */
crcbuf = new byte[32*32*4]; /* Get our space */
Arrays.fill(crcbuf, (byte)0xFF); /* Fill with -1 */
tilehash.put(thf, crcbuf); /* Add to cache */
thf.readFromFile(tiledir, crcbuf);
}
return thf.getCRC(tx & 0x1F, ty & 0x1F, crcbuf);
}
}
/* Calculate hash code for given buffer */
public long calculateTileHash(int[] newbuf) {
if(!enabled) {
return 0; /* Return value that doesn't match */
}
synchronized(lock) {
/* Calculate CRC-32 for buffer */
crc32.reset();
for(int i = 0; i < newbuf.length; i++) {
int v = newbuf[i];
crc32.update(0xFF & v);
crc32.update(0xFF & (v >> 8));
crc32.update(0xFF & (v >> 16));
crc32.update(0xFF & (v >> 24));
}
return crc32.getValue();
}
}
/* Update hashcode for given tile */
public void updateHashCode(String key, String subtype, int tx, int ty, long newcrc) {
if(!enabled)
return;
synchronized(lock) {
/* Now, find and check existing value */
TileHashFile thf = new TileHashFile(key, subtype, tx >> 5, ty >> 5);
byte[] crcbuf = tilehash.get(thf); /* See if we have it cached */
if(crcbuf == null) { /* If not in cache, load it */
crcbuf = new byte[32*32*4]; /* Get our space */
tilehash.put(thf, crcbuf); /* Add to cache */
thf.readFromFile(tiledir, crcbuf);
}
thf.setCRC(tx & 0x1F, ty & 0x1F, crcbuf, newcrc); /* Update field */
thf.writeToFile(tiledir, crcbuf); /* And write it out */
}
}
}

View File

@ -20,6 +20,7 @@ import org.dynmap.ColorScheme;
import org.dynmap.ConfigurationNode;
import org.dynmap.DynmapChunk;
import org.dynmap.MapManager;
import org.dynmap.TileHashManager;
import org.dynmap.MapTile;
import org.dynmap.MapType;
import org.dynmap.debug.Debug;
@ -234,6 +235,11 @@ public class FlatMap extends MapType {
rendered = true;
}
}
/* Test to see if we're unchanged from older tile */
TileHashManager hashman = MapManager.mapman.hashman;
long crc = hashman.calculateTileHash(argb_buf);
boolean tile_update = false;
if((!outputFile.exists()) || (crc != hashman.getImageHashCode(tile.getKey(), null, t.x, t.y))) {
/* Wrap buffer as buffered image */
Debug.debug("saving image " + outputFile.getPath());
try {
@ -243,12 +249,22 @@ public class FlatMap extends MapType {
} catch (java.lang.NullPointerException e) {
Debug.error("Failed to save image (NullPointerException): " + outputFile.getPath(), e);
}
KzedMap.freeBufferedImage(im);
MapManager.mapman.pushUpdate(tile.getWorld(), new Client.Tile(tile.getFilename()));
hashman.updateHashCode(tile.getKey(), null, t.x, t.y, crc);
tile_update = true;
}
else {
Debug.debug("skipping image " + outputFile.getPath() + " - hash match");
}
KzedMap.freeBufferedImage(im);
MapManager.mapman.updateStatistics(tile, null, true, tile_update, !rendered);
/* If day too, handle it */
if(night_and_day) {
File dayfile = new File(outputFile.getParent(), tile.getDayFilename());
crc = hashman.calculateTileHash(argb_buf_day);
if((!dayfile.exists()) || (crc != hashman.getImageHashCode(tile.getKey(), "day", t.x, t.y))) {
Debug.debug("saving image " + dayfile.getPath());
try {
ImageIO.write(im_day.buf_img, "png", dayfile);
@ -257,13 +273,26 @@ public class FlatMap extends MapType {
} catch (java.lang.NullPointerException e) {
Debug.error("Failed to save image (NullPointerException): " + dayfile.getPath(), e);
}
KzedMap.freeBufferedImage(im_day);
MapManager.mapman.pushUpdate(tile.getWorld(), new Client.Tile(tile.getDayFilename()));
hashman.updateHashCode(tile.getKey(), "day", t.x, t.y, crc);
tile_update = true;
}
else {
Debug.debug("skipping image " + dayfile.getPath() + " - hash match");
tile_update = false;
}
KzedMap.freeBufferedImage(im_day);
MapManager.mapman.updateStatistics(tile, "day", true, tile_update, !rendered);
}
return rendered;
}
public String getName() {
return prefix;
}
public static class FlatMapTile extends MapTile {
FlatMap map;
public int x;
@ -286,6 +315,9 @@ public class FlatMap extends MapType {
public String getDayFilename() {
return map.prefix + "_day_" + size + "_" + -(y+1) + "_" + x + ".png";
}
public String toString() {
return getWorld().getName() + ":" + getFilename();
}
}
@Override

View File

@ -16,6 +16,7 @@ import org.dynmap.Color;
import org.dynmap.ColorScheme;
import org.dynmap.ConfigurationNode;
import org.dynmap.MapManager;
import org.dynmap.TileHashManager;
import org.dynmap.debug.Debug;
import org.dynmap.MapChunkCache;
import org.dynmap.kzedmap.KzedMap.KzedBufferedImage;
@ -188,7 +189,7 @@ public class DefaultTileRenderer implements MapTileRenderer {
(KzedMap) tile.getMap(), tile);
File zoomFile = MapManager.mapman.getTileFile(zmtile);
doFileWrites(outputFile, tile, im, im_day, zmtile, zoomFile, zim, zim_day);
doFileWrites(outputFile, tile, im, im_day, zmtile, zoomFile, zim, zim_day, !isempty);
return !isempty;
}
@ -220,7 +221,17 @@ public class DefaultTileRenderer implements MapTileRenderer {
private void doFileWrites(final File fname, final KzedMapTile mtile,
final KzedBufferedImage img, final KzedBufferedImage img_day,
final KzedZoomedMapTile zmtile, final File zoomFile,
final KzedBufferedImage zimg, final KzedBufferedImage zimg_day) {
final KzedBufferedImage zimg, final KzedBufferedImage zimg_day, boolean rendered) {
/* Get coordinates of zoomed tile */
int ox = (mtile.px == zmtile.getTileX())?0:KzedMap.tileWidth/2;
int oy = (mtile.py == zmtile.getTileY())?0:KzedMap.tileHeight/2;
/* Test to see if we're unchanged from older tile */
TileHashManager hashman = MapManager.mapman.hashman;
long crc = hashman.calculateTileHash(img.argb_buf);
boolean updated_fname = false;
if((!fname.exists()) || (crc != hashman.getImageHashCode(mtile.getKey(), null, mtile.px, mtile.py))) {
Debug.debug("saving image " + fname.getPath());
try {
ImageIO.write(img.buf_img, "png", fname);
@ -229,9 +240,20 @@ public class DefaultTileRenderer implements MapTileRenderer {
} catch (java.lang.NullPointerException e) {
Debug.error("Failed to save image (NullPointerException): " + fname.getPath(), e);
}
MapManager.mapman.pushUpdate(mtile.getWorld(), new Client.Tile(mtile.getFilename()));
hashman.updateHashCode(mtile.getKey(), null, mtile.px, mtile.py, crc);
updated_fname = true;
}
KzedMap.freeBufferedImage(img);
if(img_day != null) {
MapManager.mapman.updateStatistics(mtile, null, true, updated_fname, !rendered);
mtile.file = fname;
boolean updated_dfname = false;
File dfname = new File(fname.getParent(), mtile.getDayFilename());
if(img_day != null) {
crc = hashman.calculateTileHash(img.argb_buf);
if((!dfname.exists()) || (crc != hashman.getImageHashCode(mtile.getKey(), "day", mtile.px, mtile.py))) {
Debug.debug("saving image " + dfname.getPath());
try {
ImageIO.write(img_day.buf_img, "png", dfname);
@ -240,29 +262,42 @@ public class DefaultTileRenderer implements MapTileRenderer {
} catch (java.lang.NullPointerException e) {
Debug.error("Failed to save image (NullPointerException): " + dfname.getPath(), e);
}
KzedMap.freeBufferedImage(img_day);
MapManager.mapman.pushUpdate(mtile.getWorld(), new Client.Tile(mtile.getDayFilename()));
hashman.updateHashCode(mtile.getKey(), "day", mtile.px, mtile.py, crc);
updated_dfname = true;
}
mtile.file = fname;
KzedMap.freeBufferedImage(img_day);
MapManager.mapman.updateStatistics(mtile, "day", true, updated_dfname, !rendered);
}
// Since we've already got the new tile, and we're on an async thread, just
// make the zoomed tile here
int px = mtile.px;
int py = mtile.py;
int zpx = zmtile.getTileX();
int zpy = zmtile.getTileY();
boolean ztile_updated = false;
if(updated_fname || (!zoomFile.exists())) {
saveZoomedTile(zmtile, zoomFile, zimg, ox, oy);
MapManager.mapman.pushUpdate(zmtile.getWorld(),
new Client.Tile(zmtile.getFilename()));
ztile_updated = true;
}
KzedMap.freeBufferedImage(zimg);
MapManager.mapman.updateStatistics(zmtile, null, true, ztile_updated, !rendered);
/* scaled size */
int scw = KzedMap.tileWidth / 2;
int sch = KzedMap.tileHeight / 2;
/* origin in zoomed-out tile */
int ox = 0;
int oy = 0;
if (zpx != px)
ox = scw;
if (zpy != py)
oy = sch;
if(zimg_day != null) {
File zoomFile_day = new File(zoomFile.getParent(), zmtile.getDayFilename());
ztile_updated = false;
if(updated_dfname || (!zoomFile_day.exists())) {
saveZoomedTile(zmtile, zoomFile_day, zimg_day, ox, oy);
MapManager.mapman.pushUpdate(zmtile.getWorld(),
new Client.Tile(zmtile.getDayFilename()));
ztile_updated = true;
}
KzedMap.freeBufferedImage(zimg_day);
MapManager.mapman.updateStatistics(zmtile, "day", true, ztile_updated, !rendered);
}
}
private void saveZoomedTile(final KzedZoomedMapTile zmtile, final File zoomFile,
final KzedBufferedImage zimg, int ox, int oy) {
BufferedImage zIm = null;
KzedBufferedImage kzIm = null;
try {
@ -284,7 +319,6 @@ public class DefaultTileRenderer implements MapTileRenderer {
/* blit scaled rendered tile onto zoom-out tile */
zIm.setRGB(ox, oy, KzedMap.tileWidth/2, KzedMap.tileHeight/2, zimg.argb_buf, 0, KzedMap.tileWidth/2);
KzedMap.freeBufferedImage(zimg);
/* save zoom-out tile */
@ -301,60 +335,7 @@ public class DefaultTileRenderer implements MapTileRenderer {
else
zIm.flush();
if(zimg_day != null) {
File zoomFile_day = new File(zoomFile.getParent(), zmtile.getDayFilename());
zIm = null;
kzIm = null;
try {
zIm = ImageIO.read(zoomFile_day);
} catch (IOException e) {
} catch (IndexOutOfBoundsException e) {
}
zIm_allocated = false;
if (zIm == null) {
/* create new one */
kzIm = KzedMap.allocateBufferedImage(KzedMap.tileWidth, KzedMap.tileHeight);
zIm = kzIm.buf_img;
zIm_allocated = true;
Debug.debug("New zoom-out tile created " + zmtile.getFilename());
} else {
Debug.debug("Loaded zoom-out tile from " + zmtile.getFilename());
}
/* blit scaled rendered tile onto zoom-out tile */
zIm.setRGB(ox, oy, KzedMap.tileWidth/2, KzedMap.tileHeight/2, zimg_day.argb_buf, 0, KzedMap.tileWidth/2);
KzedMap.freeBufferedImage(zimg_day);
/* save zoom-out tile */
try {
ImageIO.write(zIm, "png", zoomFile_day);
Debug.debug("Saved zoom-out tile at " + zoomFile_day.getName());
} catch (IOException e) {
Debug.error("Failed to save zoom-out tile: " + zoomFile_day.getName(), e);
} catch (java.lang.NullPointerException e) {
Debug.error("Failed to save zoom-out tile (NullPointerException): " + zoomFile_day.getName(), e);
}
if(zIm_allocated)
KzedMap.freeBufferedImage(kzIm);
else
zIm.flush();
}
/* Push updates for both files.*/
MapManager.mapman.pushUpdate(mtile.getWorld(),
new Client.Tile(mtile.getFilename()));
MapManager.mapman.pushUpdate(zmtile.getWorld(),
new Client.Tile(zmtile.getFilename()));
if(img_day != null) {
MapManager.mapman.pushUpdate(mtile.getWorld(),
new Client.Tile(mtile.getDayFilename()));
MapManager.mapman.pushUpdate(zmtile.getWorld(),
new Client.Tile(zmtile.getDayFilename()));
}
}
protected void scan(World world, int seq, boolean isnether, final Color result, final Color result_day,
MapChunkCache.MapIterator mapiter) {
int lightlevel = 15;

View File

@ -318,6 +318,10 @@ public class KzedMap extends MapType {
}
}
public String getName() {
return "KzedMap";
}
@Override
public void buildClientConfiguration(JSONObject worldObject) {
for(MapTileRenderer renderer : renderers) {

View File

@ -48,6 +48,10 @@ public class KzedMapTile extends MapTile {
return o.px == px && o.py == py && o.getWorld().equals(getWorld());
}
public String getKey() {
return getWorld().getName() + "." + renderer.getName();
}
public String toString() {
return getWorld().getName() + ":" + getFilename();
}

View File

@ -56,4 +56,10 @@ public class KzedZoomedMapTile extends MapTile {
}
return super.equals(obj);
}
public String getKey() {
return getWorld().getName() + ".z" + originalTile.renderer.getName();
}
}