/*
* This file is part of the Illarion project.
*
* Copyright © 2015 - Illarion e.V.
*
* Illarion is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Illarion is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*/
package illarion.client.world;
import illarion.client.net.server.TileUpdate;
import illarion.client.resources.TileFactory;
import illarion.client.util.GlobalExecutorService;
import illarion.common.graphics.TileInfo;
import illarion.common.types.ServerCoordinate;
import illarion.common.util.Stoppable;
import org.illarion.engine.Engine;
import org.illarion.engine.EngineException;
import org.illarion.engine.GameContainer;
import org.illarion.engine.graphic.WorldMap;
import org.illarion.engine.graphic.WorldMapDataProvider;
import org.illarion.engine.graphic.WorldMapDataProviderCallback;
import org.illarion.engine.nifty.IgeMiniMapRenderImage;
import org.illarion.engine.nifty.IgeRenderImage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.annotation.concurrent.NotThreadSafe;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.lang.ref.Reference;
import java.lang.ref.SoftReference;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.channels.Channels;
import java.nio.channels.ReadableByteChannel;
import java.nio.channels.WritableByteChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.Callable;
import java.util.zip.GZIPInputStream;
import java.util.zip.GZIPOutputStream;
/**
* This class stores a reduced version of the full map the character knows. The map data is packed to a minimized and
* fast readable size that can be stored on the hard disk.
*
* @author Martin Karing <nitram@illarion.org>
*/
@NotThreadSafe
public final class GameMiniMap implements WorldMapDataProvider, Stoppable {
/**
* The height of the world map in tiles.
*/
public static final int WORLDMAP_HEIGHT = 1024;
/**
* The width of the world map in tiles.
*/
public static final int WORLDMAP_WIDTH = 1024;
/**
* The bytes that are reserved for one tile in the internal storage.
*/
private static final int BYTES_PER_TILE = 2;
/**
* The log file handler that takes care for the logging output of this class.
*/
@Nonnull
private static final Logger LOGGER = LoggerFactory.getLogger(GameMiniMap.class);
/**
* Indicated how many bits the blocked bit is shifted.
*/
private static final int SHIFT_BLOCKED = 10;
/**
* Indicated how many bit the value of the overlay tile is shifted.
*/
private static final int SHIFT_OVERLAY = 5;
/**
* The radius of the mini map.
*/
private static final int MINI_RADIUS = 81;
/**
* This map contains all mini map data that is load. All map data is stored as soft references to allow them to
* be cleaned up as needed.
*/
@Nonnull
private final Map<ServerCoordinate, Reference<ByteBuffer>> mapDataStorage;
/**
* This list is used to keep strong references to the map data that is currently active used in order to avoid
* that this map data is cleared too soon.
*/
@SuppressWarnings("MismatchedQueryAndUpdateOfCollection")
@Nonnull
private final List<ByteBuffer> strongMapDataStorage;
/**
* The origin location of the map.
*/
@Nullable
private ServerCoordinate mapOrigin;
/**
* The engine implementation of the world map.
*/
@Nonnull
private final WorldMap worldMap;
/**
* The image of the mini map as its rendered by the Nifty-GUI.
*/
@Nonnull
private final IgeRenderImage miniMapImage;
/**
* Constructor of the game map that sets up all instance variables.
*/
public GameMiniMap(@Nonnull Engine engine) throws EngineException {
worldMap = engine.getAssets().createWorldMap(this);
miniMapImage = new IgeMiniMapRenderImage(engine, worldMap, MINI_RADIUS);
mapDataStorage = new HashMap<>();
strongMapDataStorage = new ArrayList<>(5);
}
@Nonnull
private static ByteBuffer createMapBuffer() {
int size = WorldMap.WORLD_MAP_WIDTH * WorldMap.WORLD_MAP_HEIGHT * BYTES_PER_TILE;
ByteBuffer buffer = ByteBuffer.allocate(size);
buffer.order(ByteOrder.nativeOrder());
return buffer;
}
/**
* Get the entire origin of the current world map. The origin is stored in a
* {@link ServerCoordinate} class instance that
* is newly fetched from the buffer. In case its not used anymore it should be put back into the buffer. <p> The
* server X and Y coordinate are the coordinates of the current origin of the world map. The Z coordinate is the
* current level. </p> <p> For details on each coordinate see the functions that request the single coordinates.
* </p>
*
* @return the location of the overview map origin
*/
@Nonnull
public ServerCoordinate getMapOrigin() {
if (mapOrigin == null) {
throw new IllegalStateException("The origin of the mini map was not set yet.");
}
return mapOrigin;
}
/**
* Get the render image used to display the mini map.
*
* @return the render image used to display the mini map on the GUI
*/
@Nonnull
public IgeRenderImage getMiniMap() {
return miniMapImage;
}
/**
* Update the world map texture.
*/
public void render(@Nonnull GameContainer container) {
worldMap.render(container);
}
/**
* Encode a server location to the index in the map data buffer.
*
* @param x the x coordinate of the location on the map
* @param y the y coordinate of the location on the map
* @return the index of the location in the map data buffer
* @throws IllegalArgumentException in case either x or y is out of the local range
*/
private int encodeLocation(int x, int y) {
if (mapOrigin == null) {
throw new IllegalStateException(
"Encoding the location is not possible until the origin of the map is set.");
}
int mapOriginY = mapOrigin.getY();
if ((y < mapOriginY) || (y >= (mapOriginY + WORLDMAP_HEIGHT))) {
throw new IllegalArgumentException("y out of range");
}
int mapOriginX = mapOrigin.getX();
if ((x < mapOriginX) || (x >= (mapOriginX + WORLDMAP_WIDTH))) {
throw new IllegalArgumentException("x out of range");
}
return ((y - mapOriginY) * WORLDMAP_WIDTH * BYTES_PER_TILE) + ((x - mapOriginX) * BYTES_PER_TILE);
}
/**
* Check if a location is within the coordinate space of the currently load set of mini maps.
*
* @param x the x coordinate
* @param y the y coordinate
* @param z the z coordinate
* @return {@code true} in case both coordinates are inside the legal range for the currently load maps.
*/
private boolean isLocationOnMap(int x, int y, int z) {
if (mapOrigin == null) {
throw new IllegalStateException(
"Checking if a location is on the mini map is not possible until the origin of the map is set.");
}
int mapOriginZ = mapOrigin.getZ();
if ((z < (mapOriginZ - 2)) || (z > (mapOriginZ + 2))) {
return false;
}
int mapOriginY = mapOrigin.getY();
if ((y < mapOriginY) || (y >= (mapOriginY + WORLDMAP_HEIGHT))) {
return false;
}
int mapOriginX = mapOrigin.getX();
return !((x < mapOriginX) || (x >= (mapOriginX + WORLDMAP_WIDTH)));
}
/**
* Check if a location is within the coordinate space of the currently load set of mini maps.
*
* @param loc the location
* @return {@code true} in case both coordinates are inside the legal range for the currently load maps.
*/
private boolean isLocationOnMap(@Nonnull ServerCoordinate loc) {
return isLocationOnMap(loc.getX(), loc.getY(), loc.getZ());
}
/**
* This function causes the byte buffer that holds the mini map data in the storage to be stored only with a soft
* reference. After this was called its possible that the data is cleared at some point in future.
*
* @param origin the origin location of the map.
*/
private void weakenMapDataStorage(@Nonnull ServerCoordinate origin) {
ByteBuffer mapData = getMapDataStorage(origin);
if (mapData == null) {
mapDataStorage.remove(origin);
} else {
strongMapDataStorage.remove(mapData);
}
}
/**
* Fetch the map data from the storage.
*
* @param mapOrigin the origin of the map
* @return the map data or {@code null} in case the data for this map is not load
*/
@Nullable
private ByteBuffer getMapDataStorage(@Nonnull ServerCoordinate mapOrigin) {
@Nullable Reference<ByteBuffer> mapDataRef = mapDataStorage.get(mapOrigin);
if (mapDataRef == null) {
return null;
}
@Nullable ByteBuffer mapData = mapDataRef.get();
return mapData;
}
/**
* Get the list of origins that needs to be handled as alive.
*
* @param origin the tested origin
* @return the list that contains the five origin locations
*/
@Nonnull
private static List<ServerCoordinate> getOriginsList(@Nonnull ServerCoordinate origin) {
List<ServerCoordinate> list = new ArrayList<>(5);
for (int i = -2; i <= 2; i++) {
list.add(new ServerCoordinate(origin, 0, 0, i));
}
return list;
}
/**
* This function handles everything that is needed to change the origin.
*
* @param newOrigin the new origin that should be applied
*/
private void changeOrigin(@Nonnull ServerCoordinate newOrigin) {
if (Objects.equals(mapOrigin, newOrigin)) {
return;
}
ServerCoordinate oldOrigin = mapOrigin;
mapOrigin = newOrigin;
if (oldOrigin == null) {
List<ServerCoordinate> loadList = getOriginsList(newOrigin);
loadList.forEach(this::strengthenOrLoadMap);
return;
}
/* Find out what origins turn in active and what new ones get active. */
List<ServerCoordinate> oldActive = getOriginsList(oldOrigin);
List<ServerCoordinate> newActive = getOriginsList(newOrigin);
@Nonnull Iterator<ServerCoordinate> oldActiveItr = oldActive.iterator();
while (oldActiveItr.hasNext()) {
ServerCoordinate checkedOrigin = oldActiveItr.next();
if (newActive.contains(checkedOrigin)) {
newActive.remove(checkedOrigin);
oldActiveItr.remove();
}
}
Collection<Callable<Void>> updateList = new ArrayList<>();
/* Save all the old data to the file system and weaken the storage of each map. */
for (@Nonnull ServerCoordinate loc : oldActive) {
updateList.add(new Callable<Void>() {
@Nullable
@Override
public Void call() {
saveMap(loc);
weakenMapDataStorage(loc);
return null;
}
});
}
/* Reactivate or load all the maps that are newly inside the player range. */
for (@Nonnull ServerCoordinate loc : newActive) {
updateList.add(new Callable<Void>() {
@Nullable
@Override
public Void call() {
strengthenOrLoadMap(loc);
return null;
}
});
}
boolean executionInProgress = true;
while (executionInProgress) {
executionInProgress = false;
try {
GlobalExecutorService.getService().invokeAll(updateList);
} catch (@Nonnull InterruptedException ignored) {
executionInProgress = true;
}
}
}
/**
* The mask to fetch the ID of the tile.
*/
private static final int MASK_TILE_ID = 0x1F;
/**
* The mask to fetch the ID of the overlay.
*/
private static final int MASK_OVERLAY_ID = 0x3E0;
/**
* The mask to fetch the blocked flag.
*/
private static final int MASK_BLOCKED = 0x400;
@Override
public void requestTile(@Nonnull ServerCoordinate location, @Nonnull WorldMapDataProviderCallback callback) {
if (mapOrigin == null) {
throw new IllegalStateException("Requesting a new tile is illegal while the origin of the map is not set.");
}
if ((location.getZ() != mapOrigin.getZ()) || !isLocationOnMap(location)) {
callback.setTile(location, WorldMap.NO_TILE, WorldMap.NO_TILE, false);
return;
}
ByteBuffer mapData = getMapDataStorage(mapOrigin);
if (mapData == null) {
callback.setTile(location, WorldMap.NO_TILE, WorldMap.NO_TILE, false);
return;
}
int tileData = mapData.getShort(encodeLocation(location));
if (tileData == 0) {
callback.setTile(location, WorldMap.NO_TILE, WorldMap.NO_TILE, false);
} else {
int tileId = tileData & MASK_TILE_ID;
int overlayId = tileData & MASK_OVERLAY_ID;
boolean blocked = (tileData & MASK_BLOCKED) > 0;
int tileMapColor;
int overlayMapColor;
if (TileFactory.getInstance().hasTemplate(tileId)) {
tileMapColor = TileFactory.getInstance().getTemplate(tileId).getTileInfo().getMapColor();
if (TileFactory.getInstance().hasTemplate(overlayId)) {
overlayMapColor = TileFactory.getInstance().getTemplate(overlayId).getTileInfo().getMapColor();
} else {
overlayMapColor = WorldMap.NO_TILE;
}
} else {
tileMapColor = WorldMap.NO_TILE;
overlayMapColor = WorldMap.NO_TILE;
}
callback.setTile(location, tileMapColor, overlayMapColor, blocked);
}
}
@Nonnull
private static ServerCoordinate getOriginLocation(@Nonnull ServerCoordinate playerLoc) {
int newMapLevel = playerLoc.getZ();
int newMapOriginX;
if (playerLoc.getX() >= 0) {
newMapOriginX = (playerLoc.getX() / WORLDMAP_WIDTH) * WORLDMAP_WIDTH;
} else {
newMapOriginX = ((playerLoc.getX() / WORLDMAP_WIDTH) * WORLDMAP_WIDTH) - WORLDMAP_WIDTH;
}
int newMapOriginY;
if (playerLoc.getY() >= 0) {
newMapOriginY = (playerLoc.getY() / WORLDMAP_HEIGHT) * WORLDMAP_HEIGHT;
} else {
newMapOriginY = ((playerLoc.getY() / WORLDMAP_HEIGHT) * WORLDMAP_HEIGHT) - WORLDMAP_HEIGHT;
}
return new ServerCoordinate(newMapOriginX, newMapOriginY, newMapLevel);
}
/**
* Set the location of the player. This tells the world map handler what map it needs to draw.
*
* @param playerLoc the location of the player
*/
public void setPlayerLocation(@Nonnull ServerCoordinate playerLoc) {
@Nonnull ServerCoordinate newOrigin = getOriginLocation(playerLoc);
if (!Objects.equals(mapOrigin, newOrigin)) {
changeOrigin(newOrigin);
worldMap.setMapOrigin(newOrigin);
}
worldMap.setPlayerLocation(playerLoc);
}
/**
* Save all maps that are currently load to the hard disk.
*/
public void saveAllMaps() {
if (mapOrigin != null) {
List<ServerCoordinate> origins = getOriginsList(mapOrigin);
origins.forEach(this::saveMap);
}
}
/**
* Save the current map to its file.
*/
private void saveMap(@Nonnull ServerCoordinate origin) {
@Nullable ByteBuffer mapData = getMapDataStorage(origin);
if (mapData == null) {
return;
}
Path mapFile = getMapFilename(origin);
if (Files.exists(mapFile) && !Files.isWritable(mapFile)) {
LOGGER.error("The map file is not accessible. Can't write anything.");
return;
}
try (WritableByteChannel outChannel = Channels
.newChannel(new GZIPOutputStream(Files.newOutputStream(mapFile)))) {
//noinspection SynchronizationOnLocalVariableOrMethodParameter
synchronized (mapData) {
mapData.rewind();
int toWrite = mapData.remaining();
while (toWrite > 0) {
toWrite -= outChannel.write(mapData);
}
}
} catch (@Nonnull FileNotFoundException e) {
LOGGER.error("Target file not found", e);
} catch (@Nonnull IOException e) {
LOGGER.error("Error while writing minimap file", e);
}
}
/**
* Get the full path string to the file for the currently selected map. This file needs to be used to store and
* load
* the map data.
*
* @return the path and the filename of the map file
*/
@Nonnull
private static Path getMapFilename(@Nonnull ServerCoordinate mapOrigin) {
StringBuilder builder = new StringBuilder();
builder.setLength(0);
builder.append("map");
builder.append(mapOrigin.getX() / WORLDMAP_WIDTH);
builder.append(mapOrigin.getY() / WORLDMAP_HEIGHT);
builder.append(mapOrigin.getZ());
builder.append(".dat");
return World.getPlayer().getPath().resolve(builder.toString());
}
/**
* This function either creates a strong storage entry of the map data or loads the map data from the file system
* and create the strong storage after.
*
* @param mapOrigin the origin of the map
*/
private void strengthenOrLoadMap(@Nonnull ServerCoordinate mapOrigin) {
/* First check if the map is still around. */
ByteBuffer existingMapData = getMapDataStorage(mapOrigin);
if (existingMapData != null) {
strongMapDataStorage.add(existingMapData);
return;
}
@Nonnull ByteBuffer mapData = createMapBuffer();
Path mapFile = getMapFilename(mapOrigin);
mapDataStorage.put(mapOrigin, new SoftReference<>(mapData));
strongMapDataStorage.add(mapData);
if (!Files.isRegularFile(mapFile)) {
return;
}
try (ReadableByteChannel inChannel = Channels.newChannel(new GZIPInputStream(Files.newInputStream(mapFile)))) {
//noinspection SynchronizationOnLocalVariableOrMethodParameter
synchronized (mapData) {
mapData.rewind();
int read = 1;
while (read > 0) {
read = inChannel.read(mapData);
}
inChannel.close();
}
performFullUpdate();
} catch (@Nonnull IOException e) {
LOGGER.error("Failed loading the map data from its file.", e);
//noinspection SynchronizationOnLocalVariableOrMethodParameter
synchronized (mapData) {
mapData.rewind();
while (mapData.hasRemaining()) {
mapData.put((byte) 0);
}
}
}
}
/**
* Once this function is called the mini map will be rendered completely again.
*/
public void performFullUpdate() {
GlobalExecutorService.getService().submit(worldMap::setMapChanged);
}
/**
* Update one tile of the overview map.
*
* @param updateData the data that is needed for the update
*/
public void update(@Nonnull TileUpdate updateData) {
ServerCoordinate tileLoc = updateData.getLocation();
if (isLocationOnMap(tileLoc)) {
if (saveTile(tileLoc, updateData.getTileId(), updateData.isBlocked())) {
worldMap.setTileChanged(tileLoc);
}
}
}
/**
* Save the information about a tile within the map data. This will overwrite any existing data about a tile.
*
* @param loc the location of the tile
* @param tileID the ID of tile that is located at the position
* @param blocked true in case this tile is not passable
* @return {@code true} in case the new ID of the tile and the already set ID are not equal and the ID did change
* this way
*/
@SuppressWarnings("BooleanMethodNameMustStartWithQuestion")
private boolean saveTile(@Nonnull ServerCoordinate loc, int tileID, boolean blocked) {
int index = encodeLocation(loc);
ServerCoordinate origin = getOriginLocation(loc);
ByteBuffer mapData = getMapDataStorage(origin);
if (mapData == null) {
return false;
}
if (tileID == MapTile.ID_NONE) {
//noinspection SynchronizationOnLocalVariableOrMethodParameter
synchronized (mapData) {
if (mapData.getShort(index) == 0) {
return false;
}
mapData.putShort(index, (short) 0);
}
return true;
}
short encodedTileValue = (short) TileInfo.getBaseID(tileID);
encodedTileValue += TileInfo.getOverlayID(tileID) << SHIFT_OVERLAY;
if (blocked) {
encodedTileValue += 1 << SHIFT_BLOCKED;
}
//noinspection SynchronizationOnLocalVariableOrMethodParameter
synchronized (mapData) {
if (mapData.getShort(index) == encodedTileValue) {
return false;
}
mapData.putShort(index, encodedTileValue);
}
return true;
}
/**
* Encode a server location to the index in the map data buffer.
*
* @param loc the server location that shall be encoded
* @return the index of the location in the map data buffer
*/
private int encodeLocation(@Nonnull ServerCoordinate loc) {
return encodeLocation(loc.getX(), loc.getY());
}
@Override
public void saveShutdown() {
worldMap.dispose();
}
}