/* * 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.graphics.*; import illarion.client.net.server.TileUpdate; import illarion.client.world.interactive.InteractiveMapTile; import illarion.common.graphics.Layer; import illarion.common.types.Direction; import illarion.common.types.ItemCount; import illarion.common.types.ItemId; import illarion.common.types.ServerCoordinate; import org.illarion.engine.graphic.Color; import org.illarion.engine.graphic.LightSource; import org.illarion.engine.graphic.Sprite; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.annotation.Nonnull; import javax.annotation.Nullable; import javax.annotation.concurrent.GuardedBy; import javax.annotation.concurrent.NotThreadSafe; import java.lang.ref.Reference; import java.lang.ref.SoftReference; import java.lang.ref.WeakReference; import java.util.EnumMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.concurrent.locks.Lock; /** * A tile on the map. Contains the tile graphics and items. * * @author Martin Karing <nitram@illarion.org> */ @NotThreadSafe public final class MapTile { /** * Default Tile ID for no tile at this position. */ public static final int ID_NONE = -1; /** * The instance of the logger that is used to write out the data. */ @Nonnull private static final Logger LOGGER = LoggerFactory.getLogger(MapTile.class); /** * This value contains the value the quest marker is elevated by. */ private int questMarkerElevation; /** * List of items on the tile. */ @Nonnull @GuardedBy("itemsLock") private final ItemStack items; /** * The color value supplied by the light tracer. */ @Nonnull private final Color tracerColor; /** * The calculated light in the center of the tile. */ @Nonnull private final Color targetCenterColor; /** * The storage of the color values. */ @Nonnull private final Map<Direction, AnimatedColor> colors; /** * The color on this tile. */ @Nonnull private final AnimatedColor localColor; /** * Light Source that is on the tile. */ @Nullable private LightSource lightSrc; /** * Light value of the light on this tile. */ private int lightValue; /** * The coordinates where this tile is located. */ @Nonnull private final ServerCoordinate tileCoordinate; /** * Flag if there is still work to do for the LOS calculation on this tile. */ private boolean losDirty; /** * The ID of the sound track that is played while the player is standing on this tile. */ private int musicId; /** * Value for partial obstruction. */ private int obstruction; /** * Graphical representation of the tile. */ @Nullable private Tile tile; /** * ID of the tile. */ private int tileId; /** * The movement cost of this tile. */ private int movementCost; /** * The temporary light instance that is used for the calculations before its applied to the actual light. */ @Nonnull private final Color tmpLight = new Color(Color.WHITE); /** * The reference to the tile that is obstructing this tile. */ @Nullable private Reference<MapTile> obstructingTileRef; /** * The reference to the interactive map tile that was buffered for later usage. */ @Nullable private Reference<InteractiveMapTile> interactiveMapTileRef; /** * The map group this tile is assigned to. */ @Nullable private MapGroup group; /** * Once this value is set {@code true} the tile is assumed to be removed. */ private boolean removedTile; public int getQuestMarkerElevation() { return questMarkerElevation; } public void setObstructingTile(@Nonnull MapTile tile) { obstructingTileRef = new WeakReference<>(tile); } @Nullable public MapTile getObstructingTile() { if (obstructingTileRef == null) { return null; } MapTile obstructingTile = obstructingTileRef.get(); if (obstructingTile == null) { obstructingTileRef = null; return null; } if (obstructingTile.removedTile) { obstructingTileRef = null; return null; } return obstructingTile; } public void setMapGroup(@Nonnull MapGroup group) { this.group = group; } @Nullable public MapGroup getMapGroup() { return group; } /** * Create a new instance of the map and assign its coordinates. * * @param coordinate the coordinates of this tile */ public MapTile(@Nonnull ServerCoordinate coordinate) { tileCoordinate = coordinate; tileId = ID_NONE; tile = null; lightSrc = null; losDirty = true; targetCenterColor = new Color(World.getWeather().getAmbientLight()); tracerColor = new Color(Color.BLACK); localColor = new AnimatedColor(targetCenterColor); colors = new EnumMap<>(Direction.class); items = new ItemStack(coordinate.toDisplayCoordinate(Layer.Items)); } @Nonnull public Color getLight() { return localColor.getCurrentColor(); } @Nonnull public Color getTargetLight() { return localColor.getTargetColor(); } @Nullable public Color getLight(@Nonnull Direction direction) { @Nullable AnimatedColor color = colors.get(direction); return (color == null) ? null : color.getCurrentColor(); } public boolean hasLightGradient() { Color lastColor = getLight(); for (@Nullable AnimatedColor testAnimatedColor : colors.values()) { Color testColor = (testAnimatedColor == null) ? null : testAnimatedColor.getCurrentColor(); if ((testColor != null) && !lastColor.equals(testColor)) { return true; } } return false; } public void updateColor(int delta) { localColor.update(delta); } void linkColors(@Nonnull MapTile otherTile, @Nonnull Direction direction) { Direction reverseDirection = Direction.getReverse(direction); otherTile.colors.put(reverseDirection, localColor); colors.put(direction, otherTile.localColor); } /** * Get the item on the top of this tile. * * @return the top tile or {@code null} in case there is none */ @Nullable public Item getTopItem() { if (removedTile) { LOGGER.debug("Requested top item of a removed tile."); return null; } if (items.hasItems()) { return items.getTopItem(); } return null; } @Nonnull public Item getItem(int index) { return items.get(index); } /** * Remove the light source of this tile in case there is any. */ public void markAsRemoved() { removedTile = true; if (lightSrc != null) { World.getLights().remove(lightSrc); } checkAndClampItems(0); if (tile != null) { tile.markAsRemoved(); tile = null; } } /** * Create a string that identifies the tile and its current state. * * @return the generated string */ @Override @Nonnull public String toString() { return "MapTile " + tileCoordinate + " tile=" + tileId + " items=" + items.size(); } /** * Update the item at the top position of the stack of items. * * @param oldItemId the ID of the item that is currently on the top position * @param itemId the new ID that shall be set on the item * @param count the new count value of the item in top position */ public void changeTopItem( @Nonnull ItemId oldItemId, @Nonnull ItemId itemId, @Nonnull ItemCount count) { if (removedTile) { LOGGER.warn("Changing top item of removed tile requested."); return; } items.getLock().writeLock().lock(); try { if (!items.hasItems()) { LOGGER.warn("There are no items on this field. Change top impossible."); return; } int pos = items.size() - 1; if (pos < 0) { LOGGER.warn("error: change top item on empty field"); return; } if (items.getTopItem().getItemId().equals(oldItemId)) { setItem(pos, itemId, count); } else { LOGGER.warn("change top item mismatch. Expected {} found {}", oldItemId, items.getTopItem().getItemId().getValue()); } } finally { items.getLock().writeLock().unlock(); } itemChanged(); } /** * Remove the item at the top position of the item stack. */ public void removeTopItem() { if (removedTile) { LOGGER.warn("Remove top item of removed tile requested."); return; } items.getLock().writeLock().lock(); try { int pos = items.size() - 1; if (pos < 0) { LOGGER.warn("Remove top item on empty field"); return; } Item removedItem = items.remove(pos); removedItem.markAsRemoved(); } finally { items.getLock().writeLock().unlock(); } itemChanged(); } /** * Add a single item to the item stack. The new item is placed at the last position and is shown on top this way. * * @param itemId the ID of the item that is created * @param count the count value of the item that is created */ public void addItem(@Nonnull ItemId itemId, @Nonnull ItemCount count) { if (removedTile) { LOGGER.warn("Trying to add a item to a removed tile."); return; } items.getLock().writeLock().lock(); try { int pos = items.getItemCount(); setItem(pos, itemId, count); } finally { items.getLock().writeLock().unlock(); } itemChanged(); } /** * Set a item at a special position of the item stack on this tile. * * @param index The index within the item list of this tile * @param itemId The new item ID of the item * @param itemCount The new count value of this item */ private boolean setItem(int index, @Nonnull ItemId itemId, @Nonnull ItemCount itemCount) { // look for present item in map tile boolean changedSomething = false; items.getLock().writeLock().lock(); try { @Nullable Item item = null; if (index < items.size()) { item = items.get(index); // just an update of present item if (ItemId.equals(item.getItemId(), itemId)) { if (!Objects.equals(item.getCount(), itemCount)) { item.setCount(itemCount); changedSomething = true; } } else { // different item: clear old item item = null; } } // add a new item if (item == null) { // create new item item = Item.create(itemId, tileCoordinate, this); item.setCount(itemCount); // add it to list if (index < items.size()) { items.set(index, item); } else if (index == items.size()) { // extend list by 1 row items.add(item); } else { // index mismatch throw new IllegalArgumentException("update behind end of items list"); } changedSomething = true; } } finally { items.getLock().writeLock().unlock(); } return changedSomething; } /** * Notify the tile that the items got changed. That makes a recalculation of the lights and the line of sight * needed. */ private void itemChanged() { updateQuestMarkerElevation(); // invalidate LOS data losDirty = true; // report a change of shadow if (World.getMapDisplay().isActive()) { World.getLights().notifyChange(tileCoordinate); } // check for a light source checkLight(); } /** * Update the elevation of the quest markers. This needs to be called when ever the items or the character on * this tile change. */ public void updateQuestMarkerElevation() { Char character = World.getPeople().getCharacterAt(tileCoordinate); @Nullable Avatar avatar = (character == null) ? null : character.getAvatar(); questMarkerElevation = items.getElevation(); if (avatar == null) { Item topItem = getTopItem(); if (topItem != null) { Sprite topItemSprite = topItem.getTemplate().getSprite(); questMarkerElevation += Math .round((topItemSprite.getOffsetY() + topItemSprite.getHeight()) * topItem.getScale()); } } else { questMarkerElevation += Math.round(avatar.getTemplate().getSprite().getHeight() * avatar.getScale()); } } /** * Determine whether the top item is a light source and needs to be registered. Also removes previous or changed * light sources. */ private void checkLight() { int newLightValue = 0; items.getLock().readLock().lock(); try { for (Item item : items) { if (item.getTemplate().getItemInfo().isLight()) { newLightValue = item.getTemplate().getItemInfo().getLight(); break; } } } finally { items.getLock().readLock().unlock(); } if (lightValue == newLightValue) { return; } LightSource newSource = (newLightValue > 0) ? new LightSource(tileCoordinate, newLightValue) : null; if ((lightSrc != null) && (newSource != null)) { World.getLights().replace(lightSrc, newSource); lightSrc = newSource; } else if (lightSrc != null) { World.getLights().remove(lightSrc); lightSrc = null; } else if (newSource != null) { World.getLights().addLight(newSource); lightSrc = newSource; } lightValue = newLightValue; } /** * Add some light influence to this tile. This is added to the already existing light on this tile * * @param color the light that shall be added */ public void addLight(@Nonnull Color color) { if (removedTile) { LOGGER.warn("Adding light to a removed tile."); return; } tmpLight.add(color); } /** * Check if the player can move the top item on this tile. * * @return true if the player can move the item around */ public boolean canMoveItem() { if (removedTile) { LOGGER.debug("Checking a removed tile for a movable item."); return false; } if (!World.getPlayer().getLocation().isNeighbour(tileCoordinate)) { return false; } Item topItem = getTopItem(); return (topItem != null) && topItem.getTemplate().getItemInfo().isMovable(); } /** * Determine how much of the tile is hidden due items on it. Needed for LOS calculation. * * @return identifier how much of the tile is hidden */ public int getCoverage() { if (removedTile) { LOGGER.debug("Checking the coverage of a removed tile"); return 0; } if (losDirty) { obstruction = 0; items.getLock().readLock().lock(); try { for (Item item : items) { obstruction += item.getTemplate().getItemInfo().getOpacity(); } } finally { items.getLock().readLock().unlock(); } losDirty = false; } return obstruction; } /** * Get the elevation of the item with the highest elevation value. * * @return the elevation value */ public int getElevation() { if (removedTile) { LOGGER.debug("Checking the elevation of a removed tile."); return 0; } return items.getElevation(); } /** * Check from what sides the tile accepts light. * * @return the identifier for the side the tile accepts light from */ public int getFace() { if (removedTile) { LOGGER.debug("Checking the facing flag of a removed tile."); return 0; } // empty tile accept all light items.getLock().readLock().lock(); try { if (items.isEmpty()) { return 0; } // non-movable items are only lit from the front return items.get(0).getTemplate().getItemInfo().getFace(); } finally { items.getLock().readLock().unlock(); } } /** * Get the the interactive instance used for interaction with this tile. * * @return the interactive tile referring to this map tile */ @Nonnull public InteractiveMapTile getInteractive() { if (removedTile) { LOGGER.warn("Request a interactive reference to a removed tile."); } if (interactiveMapTileRef != null) { @Nullable InteractiveMapTile interactiveMapTile = interactiveMapTileRef.get(); if (interactiveMapTile != null) { return interactiveMapTile; } } InteractiveMapTile interactiveMapTile = new InteractiveMapTile(this); interactiveMapTileRef = new SoftReference<>(interactiveMapTile); return interactiveMapTile; } /** * Get the cost of moving over this item. Needed was walking patch calculations. * * @return the costs for moving over this tile */ public int getMovementCost() { if (removedTile) { LOGGER.warn("Checking the movement costs on a removed tile."); return Integer.MAX_VALUE; } Tile localTile = tile; if ((localTile == null) || (movementCost == -1)) { return Integer.MAX_VALUE; } return movementCost; } /** * Get the ID of the background music track that is supposed to be played on this tile. * * @return the background music track for this tile */ public int getTileMusic() { if (removedTile) { LOGGER.debug("Requested the music ID of a removed tile."); return 0; } return musicId; } /** * Check if this tile is at the same level as the player. * * @return {@code true} in case the tile is on the same level as the player */ public boolean isAtPlayerLevel() { return World.getPlayer().isBaseLevel(getCoordinates()); } /** * Get the coordinates of the tile. * * @return the coordinates of the tile */ @Nonnull public ServerCoordinate getCoordinates() { return tileCoordinate; } /** * Check if there is anything on the tile that blocks moving over the tile. * * @return true in case it is not possible to walk over this tile */ public boolean isBlocked() { if (removedTile) { LOGGER.debug("Checking a removed tile if its blocked."); return true; } if (isObstacle()) { return true; } // check for chars return World.getPeople().getCharacterAt(tileCoordinate) != null; } /** * Check if the tile is obstacle or if there is a item that blocks the movement over this tile. * * @return true if the tile is obstacle */ public boolean isObstacle() { if (removedTile) { LOGGER.debug("Checking a removed tile if its a obstacle."); return true; } return getMovementCost() == Integer.MAX_VALUE; } /** * Check if the tile is fully opaque and everything below can be hidden. * * @return {@code true} in case the tile is opaque and everything below is hidden entirely. */ public boolean isOpaque() { if (removedTile) { LOGGER.debug("Checking opaque value of a removed tile."); return true; } Tile localTile = tile; if (tile == null) { return false; } boolean opaqueFlag = localTile.getTemplate().getTileInfo().isOpaque(); boolean transparentFlag = localTile.isTransparent(); return opaqueFlag && !transparentFlag; } /** * Render the light on this tile, using the ambient light of the weather and a factor how much the tile light * modifies the ambient light. * * This also resets the value of the temporary light to zero to ready it for the next calculation. */ public void renderLight() { if (removedTile) { LOGGER.debug("Render light of a removed tile."); return; } tracerColor.setColor(tmpLight); tmpLight.setColor(Color.BLACK); } public void applyAmbientLight(@Nonnull Color ambientLight) { targetCenterColor.setColor(tracerColor); targetCenterColor.add(ambientLight); targetCenterColor.clamp(); } /** * Show a graphical effect on the tile. * * @param effectId the ID of the effect */ public void showEffect(int effectId) { if (removedTile) { LOGGER.warn("Show a graphics effect on a removed tile."); return; } Effect effect = Effect.create(effectId); effect.show(tileCoordinate); } /** * Update a map tile using the update data the server send. * * @param update the update data the server send */ public boolean update(@Nonnull TileUpdate update) { if (removedTile) { LOGGER.error("Process update of a removed tile."); return false; } boolean changedSomething = false; if (tileId != update.getTileId()) { // update tile setTileId(update.getTileId()); changedSomething = true; } int newMovementCost = update.isBlocked() ? -1 : update.getMovementCost(); if (newMovementCost != movementCost) { setMovementCost(newMovementCost); changedSomething = true; } if (musicId != update.getTileMusic()) { musicId = update.getTileMusic(); changedSomething = true; } // update items if (updateItemList(update.getItemNumber(), update.getItemId(), update.getItemCount())) { changedSomething = true; } return changedSomething; } public void setMovementCost(int newMovementCost) { movementCost = newMovementCost; } /** * Set the ID of the tile and change the type of the tile this way. This function also sets up a new tile at this * position if there was no. Furthermore all calculations that are needed for a new tile are triggered by this * function. * * @param id the new ID if the tile */ public void setTileId(int id) { if (removedTile) { LOGGER.error("Change the ID of a removed tile."); return; } losDirty = true; resetLight(); // no replacement necessary if ((tileId == id) && (tile != null)) { tile.setScreenPos(tileCoordinate.toDisplayCoordinate(Layer.Tiles)); return; } tileId = id; // free old tile to factory if (tile != null) { tile.markAsRemoved(); tile = null; } // get a new tile to display if (id >= 0) { // create a tile, possibly with variants tile = new Tile(id, this); tile.setScreenPos(tileCoordinate.toDisplayCoordinate(Layer.Tiles)); tile.show(); } } @Nullable public Tile getTile() { return tile; } /** * Reset the light value back to 0. */ public void resetLight() { if (removedTile) { LOGGER.debug("Resetting the light of a removed tile."); } tmpLight.setColor(Color.BLACK); } /** * Update all items on the stack of this tile at once. * * @param number the new amount of items on this tile * @param itemId the list of item ids for the items on this tile * @param itemCount the list of count values for the items on this tile */ private boolean updateItemList(int number, @Nonnull List<ItemId> itemId, @Nonnull List<ItemCount> itemCount) { boolean changedSomething = false; Lock lock = items.getLock().writeLock(); lock.lock(); try { try { changedSomething = checkAndClampItems(number); for (int i = 0; i < number; i++) { if (setItem(i, itemId.get(i), itemCount.get(i))) { changedSomething = true; } } } finally { Lock readLock = items.getLock().readLock(); //noinspection LockAcquiredButNotSafelyReleased readLock.lock(); try { lock.unlock(); } finally { lock = readLock; } } // enable numbers for top item int pos = items.size() - 1; if (pos >= 0) { items.get(pos).enableNumbers(true); } } finally { lock.unlock(); } if (changedSomething) { itemChanged(); } return changedSomething; } /** * Delete any surplus items in an update. * * @param itemNumber the maximum amount of items that shall remain */ private boolean checkAndClampItems(int itemNumber) { items.getLock().writeLock().lock(); try { int amount = items.size() - itemNumber; if (amount > 0) { for (int i = 0; i < amount; i++) { // recycle the removed items Item item = items.get(itemNumber); item.markAsRemoved(); // keep deleting in the same place as the list becomes shorter items.remove(itemNumber); } return true; } return false; } finally { items.getLock().writeLock().unlock(); } } /** * Update the entire item list. This function is called by the net interface in case the server sends a full set of * new item data to the client. * * @param itemNumber Amount of items within the list of items * @param itemId List of the item IDs for all items that shall be created * @param itemCount List of count values for all items */ public void updateItems( int itemNumber, @Nonnull List<ItemId> itemId, @Nonnull List<ItemCount> itemCount) { if (removedTile) { LOGGER.error("Update items of a removed tile requested."); return; } updateItemList(itemNumber, itemId, itemCount); } public boolean isHidden() { return (group != null) && group.isHidden(); } public int getItemIndex(@Nonnull Item lookAtItem) { return items.indexOf(lookAtItem); } }