package me.desht.scrollingmenusign.views;
import me.desht.dhutils.ConfigurationManager;
import me.desht.dhutils.LogUtils;
import me.desht.dhutils.MapUtil;
import me.desht.dhutils.PermissionUtils;
import me.desht.scrollingmenusign.*;
import me.desht.scrollingmenusign.enums.ViewJustification;
import me.desht.scrollingmenusign.views.action.ViewUpdateAction;
import org.bukkit.Bukkit;
import org.bukkit.ChatColor;
import org.bukkit.Material;
import org.bukkit.configuration.Configuration;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.map.MapCanvas;
import org.bukkit.map.MapPalette;
import org.bukkit.map.MapRenderer;
import org.bukkit.map.MapView;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.awt.image.ColorModel;
import java.awt.image.WritableRaster;
import java.io.File;
import java.io.IOException;
import java.math.BigInteger;
import java.net.MalformedURLException;
import java.net.URL;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Observable;
/**
* This view draws menus on maps. With thanks to dumptruckman for MapActionMenu, which provided inspiration for this.
*/
public class SMSMapView extends SMSScrollableView {
private static final String CACHED_FILE_FORMAT = "png";
// attributes
public static final String IMAGE_FILE = "imagefile";
public static final String BACKGROUND = "background";
public static final String FONT = "font";
public static final String FONT_SIZE = "fontsize";
private static final Color DEFAULT_COLOR = new Color(0, 0, 0);
private MapView mapView = null;
private final SMSMapRenderer mapRenderer;
private int x, y;
private int width, height;
private int lineSpacing;
private final List<MapRenderer> previousRenderers = new ArrayList<MapRenderer>();
private BufferedImage backgroundImage = null;
private static BufferedImage deniedImage = null;
/**
* Create a new map view on the given menu. The view name is chosen automatically.
*
* @param menu The menu to attach the new view to
*/
public SMSMapView(SMSMenu menu) {
this(null, menu);
}
/**
* Create a new map view on the given menu.
*
* @param name The new view's name.
* @param menu The menu to attach the new view to.
*/
public SMSMapView(String name, SMSMenu menu) {
super(name, menu);
Configuration config = ScrollingMenuSign.getInstance().getConfig();
registerAttribute(IMAGE_FILE, "", "Image to use as map background");
registerAttribute(FONT, config.getString("sms.maps.font"), "Java font for map text drawing");
registerAttribute(FONT_SIZE, config.getInt("sms.maps.fontsize"), "Font size for map text drawing");
registerAttribute(BACKGROUND, config.getInt("sms.maps.background"), "Background fill colour");
x = 4;
y = 0;
width = 120;
height = 128;
lineSpacing = 0;
mapRenderer = new SMSMapRenderer(this);
}
@Override
protected boolean isTypeUsable(Player player) {
SMSMapView mv = ScrollingMenuSign.getInstance().getViewManager().getHeldMapView(player);
if (mv != null && mv.getMapView().getId() == mapView.getId()) {
// player is holding this map
return super.isTypeUsable(player);
} else {
// not holding this map? must be in an item frame
return PermissionUtils.isAllowedTo(player, "scrollingmenusign.use.map.framed");
}
}
private BufferedImage getDeniedImage() {
if (deniedImage == null) {
URL u = getClass().getResource("/denied.png");
if (u != null) {
try {
deniedImage = ImageIO.read(u);
} catch (IOException e) {
LogUtils.warning("error loading access-denied image: " + e.getMessage());
}
} else {
LogUtils.warning("missing resource for access-denied image: " + u);
}
}
return deniedImage;
}
private void loadBackgroundImage() {
backgroundImage = null;
String file = getAttributeAsString(IMAGE_FILE, "");
if (file.isEmpty()) {
return;
}
// Load the file from the given URL, and write a cached copy (PNG, 128x128) to our local
// directory structure. The cached file can be used for subsequent loads to improve performance.
try {
URL url = ScrollingMenuSign.makeImageURL(file);
File cached = getCachedFile(url);
BufferedImage resizedImage;
if (cached != null && cached.canRead()) {
resizedImage = ImageIO.read(cached);
} else {
BufferedImage orig = ImageIO.read(url);
BufferedImage result = MapUtil.createMapBuffer();
Graphics2D graphics = result.createGraphics();
graphics.drawImage(orig, 0, 0, 128, 128, null);
graphics.dispose();
resizedImage = MapPalette.resizeImage(orig);
if (cached != null) {
ImageIO.write(resizedImage, CACHED_FILE_FORMAT, cached);
LogUtils.info("Cached image " + url + " as " + cached);
}
}
backgroundImage = resizedImage;
} catch (MalformedURLException e) {
LogUtils.warning("malformed image URL for map view " + getName() + ": " + e.getMessage());
} catch (IOException e) {
LogUtils.warning("cannot load image URL for map view " + getName() + ": " + e.getMessage());
}
}
private static File getCachedFile(URL url) {
byte[] bytes = url.toString().getBytes();
MessageDigest md;
try {
md = MessageDigest.getInstance("MD5");
byte[] d = md.digest(bytes);
BigInteger i = new BigInteger(d);
return new File(DirectoryStructure.getImgCacheFolder(), String.format("%1$032X", i) + "." + CACHED_FILE_FORMAT);
} catch (NoSuchAlgorithmException e) {
LogUtils.warning("Can't get MD5 MessageDigest algorithm, no image caching");
return null;
}
}
@Override
public Map<String, Object> freeze() {
Map<String, Object> map = super.freeze();
map.put("mapId", mapView == null ? -1 : mapView.getId());
return map;
}
protected void thaw(ConfigurationSection node) throws SMSException {
super.thaw(node);
short mapId = (short) node.getInt("mapId", -1);
if (mapId >= 0)
setMapId((short) node.getInt("mapId", 0));
}
/**
* Associate this view with a map ID. Removes (and saves) all renderers currently on
* the map, and adds our own SMSRenderer to the map.
*
* @param id the numeric map ID
*/
public void setMapId(short id) {
mapView = Bukkit.getServer().getMap(id);
if (mapView == null) {
// This could happen if the Minecraft map data has been lost. Perhaps the SMS view files have
// been migrated to a new server? Anyway, we can get a new Bukkit MapView for this SMS view.
mapView = Bukkit.getServer().createMap(Bukkit.getServer().getWorlds().get(0));
LogUtils.warning("View " + getName() + ": missing Bukkit MapView for map ID " + id + " - created new Bukkit MapView with ID " + mapView.getId());
}
for (MapRenderer r : mapView.getRenderers()) {
previousRenderers.add(r);
mapView.removeRenderer(r);
}
mapView.addRenderer(getMapRenderer());
loadBackgroundImage();
autosave();
}
/**
* Get the Bukkit @see org.bukkit.map.MapView associated with this map view object.
*
* @return The Bukkit MapView object
*/
public MapView getMapView() {
return mapView;
}
/**
* Get the custom map renderer for this map view object.
*
* @return The SMSMapRenderer object
*/
public SMSMapRenderer getMapRenderer() {
return mapRenderer;
}
/**
* Get the X co-ordinate to start drawing at - the left-hand bounds of the drawing space
*
* @return The X co-ordinate
*/
public int getX() {
return x;
}
/**
* Set the X co-ordinate to start drawing at - the left-hand bounds of the drawing space
*
* @param x The X co-ordinate
*/
public void setX(int x) {
this.x = x;
}
/**
* Get the Y co-ordinate to start drawing at - the upper bounds of the drawing space
*
* @return The Y co-ordinate
*/
public int getY() {
return y;
}
/**
* Set the Y co-ordinate to start drawing at - the upper bounds of the drawing space
*
* @param y The Y co-ordinate
*/
public void setY(int y) {
this.y = y;
}
/**
* Get the width of the drawing area on the map
*
* @return The width
*/
public int getWidth() {
return width;
}
/**
* Set the width of the drawing area on the map
*
* @param width The width
*/
public void setWidth(int width) {
this.width = width;
}
/**
* Get the height of the drawing area on the map
*
* @return The height
*/
public int getHeight() {
return height;
}
/**
* Set the height of the drawing area on the map
*
* @param height The height
*/
public void setHeight(int height) {
this.height = height;
}
/**
* Get the pixel spacing between each line of text in the menu
*
* @return The spacing
*/
public int getLineSpacing() {
return lineSpacing;
}
/**
* Set the pixel spacing between each line of text in the menu
*
* @param lineSpacing The spacing
*/
public void setLineSpacing(int lineSpacing) {
this.lineSpacing = lineSpacing;
}
public BufferedImage getImage() {
return backgroundImage;
}
/**
* Apply an item name & lore for this map view to the given item (which should be a map!)
*
* @param item the map item
*/
public void setMapItemName(ItemStack item) {
int nItems = getNativeMenu().getItemCount();
String loreStr = nItems + (nItems == 1 ? " menu item" : " menu items");
loreStr = ChatColor.GRAY.toString() + ChatColor.ITALIC.toString() + loreStr;
List<String> lore = new ArrayList<String>(1);
lore.add(loreStr);
ItemMeta im = item.getItemMeta();
im.setDisplayName(ChatColor.RESET + doVariableSubstitutions(null, getNativeMenu().getTitle()));
im.setLore(lore);
item.setItemMeta(im);
}
/**
* Remove any custom item name & lore from the given item.
*
* @param item the map item
*/
public void removeMapItemName(ItemStack item) {
if (item.getType() != Material.MAP || getMapView().getId() != item.getDurability()) {
LogUtils.warning("SMSMapView: Attempt to remove item name from non map-view item!");
return;
}
ItemMeta im = item.getItemMeta();
im.setDisplayName(null);
im.setLore(null);
item.setItemMeta(im);
}
/* (non-Javadoc)
* @see me.desht.scrollingmenusign.views.SMSScrollableView#update(java.util.Observable, java.lang.Object)
*/
@Override
public void update(Observable menu, Object arg1) {
super.update(menu, arg1);
ViewUpdateAction vu = ViewUpdateAction.getAction(arg1);
if (mapView != null) {
if (mapView.getRenderers().contains(getMapRenderer())) {
mapView.removeRenderer(getMapRenderer());
}
mapView.addRenderer(getMapRenderer());
setDirty(vu.getSender() instanceof Player ? (Player) vu.getSender() : null, true);
}
}
@Override
public void onDeleted(boolean permanent) {
super.onDeleted(permanent);
if (permanent && mapView != null) {
mapView.removeRenderer(getMapRenderer());
for (MapRenderer r : previousRenderers) {
mapView.addRenderer(r);
}
}
}
/* (non-Javadoc)
* @see java.lang.Object#toString()
*/
public String toString() {
return "map id: " + (mapView == null ? "NONE" : mapView.getId());
}
@Override
public String getType() {
return "map";
}
/* (non-Javadoc)
* @see me.desht.scrollingmenusign.views.SMSView#onConfigurationChanged(me.desht.dhutils.ConfigurationManager, java.lang.String, java.lang.Object, java.lang.Object)
*/
@Override
public void onConfigurationChanged(ConfigurationManager configurationManager, String attribute, Object oldVal, Object newVal) {
super.onConfigurationChanged(configurationManager, attribute, oldVal, newVal);
if (attribute.equals(IMAGE_FILE)) {
loadBackgroundImage();
setDirty(true);
}
}
@Override
protected int getHardMaxTitleLines() {
return 4;
}
@Override
protected int getLineLength() {
return 30; // estimate
}
/**
* This method does the actual creation of the map image, to be returned to the Bukkit
* map renderer for copying to the canvas.
*
* @return an Image
*/
public BufferedImage renderImage(Player player) {
if (mapView == null) {
return null;
}
BufferedImage result = backgroundImage == null ? MapUtil.createMapBuffer() : deepCopy(backgroundImage);
Graphics g = result.getGraphics();
g.setFont(new Font(getAttributeAsString(FONT), 0, (Integer) getAttribute(FONT_SIZE)));
if (backgroundImage == null) {
g.setColor(MapUtil.getMapColor((Integer) getAttribute(BACKGROUND)));
g.fillRect(0, 0, 128, 128);
}
g.setColor(DEFAULT_COLOR);
FontMetrics metrics = g.getFontMetrics();
SMSMenu menu = getActiveMenu(player);
Configuration config = ScrollingMenuSign.getInstance().getConfig();
if (!hasOwnerPermission(player) || !isTypeUsable(player)) {
BufferedImage di = getDeniedImage();
if (di != null) {
g.drawImage(di, 0, 0, null);
} else {
drawMessage(g, new String[]{"Access Denied"});
}
return result;
}
int lineHeight = metrics.getHeight() + getLineSpacing();
int yPos = getY() + lineHeight;
// draw the title line(s)
List<String> titleLines = splitTitle(player);
for (String line : titleLines) {
drawText(g, getTitleJustification(), yPos, line);
yPos += lineHeight;
}
Color c = g.getColor();
g.setColor(MapUtil.getChatColor(7));
yPos++;
int lineY = yPos + 1 - lineHeight;
g.drawLine(x, lineY, x + width, lineY);
g.setColor(c);
String prefixNotSel = ScrollingMenuSign.getInstance().getConfigCache().getPrefixNotSelected();
String prefixSel = ScrollingMenuSign.getInstance().getConfigCache().getPrefixSelected();
ViewJustification itemJust = getItemJustification();
int pageSize = (getHeight() - yPos) / (metrics.getHeight() + getLineSpacing());
int scrollPos = getScrollPos(player);
int menuSize = getActiveMenuItemCount(player);
// draw the text
switch (getScrollType()) {
case SCROLL:
for (int n = 0; n < pageSize && n < menuSize; n++, yPos += lineHeight) {
String prefix = n == 0 ? prefixSel : prefixNotSel;
String lineText = getActiveItemLabel(player, scrollPos);
drawText(g, itemJust, yPos, prefix + lineText);
if (++scrollPos > menuSize) {
scrollPos = 1;
}
}
break;
case PAGE:
int pageNum = (scrollPos - 1) / pageSize;
for (int j = 0, pos = (pageNum * pageSize) + 1; j < pageSize && pos <= menuSize; j++, pos++, yPos += lineHeight) {
String pre = pos == scrollPos ? prefixSel : prefixNotSel;
String lineText = getActiveItemLabel(player, pos);
drawText(g, itemJust, yPos, pre + lineText);
}
break;
}
// draw the tooltip, if needed
SMSMenuItem item = menu.getItemAt(getScrollPos(player));
if (item != null && item.hasPermission(player) && config.getBoolean("sms.maps.show_tooltips")) {
// String[] lore = item.getLore();
List<String> lore = doVariableSubstitutions(player, item.getLoreAsList());
if (!lore.isEmpty()) {
int offset = getScrollType() == ScrollType.SCROLL ? 3 : (scrollPos % pageSize) + 1;
int y1 = lineHeight * (titleLines.size() + offset);
int x1 = x + 10;
int y2 = y1 + lineHeight * lore.size() + 1;
int x2 = x + width;
g.setColor(MapUtil.getMapColor(10));
g.fillRect(x1, y1, x2 - x1, y2 - y1);
g.setColor(MapUtil.getMapColor(11));
g.draw3DRect(x1, y1, x2 - x1, y2 - y1, true);
yPos = y2 - (2 + lineHeight * (lore.size() - 1));
g.setClip(x1, y1, x2 - x1, y2 - y1);
for (String l : lore) {
g.setColor(MapUtil.getChatColor(0));
drawText(g, x1 + 2, yPos, l);
yPos += lineHeight;
}
}
}
g.dispose();
return result;
}
private void drawText(Graphics g, ViewJustification itemJust, int y, String text) {
FontMetrics metrics = g.getFontMetrics();
int textWidth = metrics.stringWidth(text.replaceAll("\u00a7.", ""));
drawText(g, getXOffset(itemJust, textWidth), y, text);
}
private void drawMessage(Graphics g, String[] text) {
FontMetrics metrics = g.getFontMetrics();
int h = metrics.getHeight() + getLineSpacing();
int y = getY() + (getHeight() - h * text.length) / 2;
for (String s : text) {
int x = getX() + (getWidth() - metrics.stringWidth(s)) / 2;
drawText(g, x, y, s);
y += h;
}
}
private static final byte BOLD = 0x01;
private static final byte ITALIC = 0x02;
private static final byte UNDERLINE = 0x04;
private static final byte STRIKE = 0x08;
private void drawText(Graphics g, int x, int y, String text) {
FontMetrics metrics = g.getFontMetrics();
byte flags = 0;
g.setColor(MapUtil.getChatColor(0));
StringBuilder sb = new StringBuilder(text.length());
for (int i = 0; i < text.length(); i++) {
Character c = text.charAt(i);
if (c == '\u00a7') {
// markup code: render what we have so far, then change the font/color
String s = sb.toString();
int width = metrics.stringWidth(s);
int height = metrics.getMaxAscent();
renderTextElement(g, s, x, y, width, height, flags);
x += width;
sb.delete(0, sb.length());
i++;
c = Character.toLowerCase(text.charAt(i));
if (c >= '0' && c <= '9' || c >= 'a' && c <= 'f') {
byte mcColor = Byte.parseByte(c.toString(), 16);
g.setColor(MapUtil.getChatColor(mcColor));
flags = 0x0;
} else if (c == 'l') {
flags |= BOLD;
} else if (c == 'm') {
flags |= STRIKE;
} else if (c == 'n') {
flags |= UNDERLINE;
} else if (c == 'o') {
flags |= ITALIC;
} else if (c == 'r') {
flags = 0;
g.setColor(DEFAULT_COLOR);
}
} else {
sb.append(c);
}
}
String s = sb.toString();
int width = metrics.stringWidth(s);
int height = metrics.getMaxAscent();
renderTextElement(g, s, x, y, width, height, flags);
g.setColor(DEFAULT_COLOR);
}
private void renderTextElement(Graphics g, String s, int x, int y, int width, int height, byte flags) {
Font f = g.getFont();
int style = 0;
if ((flags & BOLD) == BOLD) style |= Font.BOLD;
if ((flags & ITALIC) == ITALIC) style |= Font.ITALIC;
if (style != 0)
g.setFont(new Font(f.getFamily(), style, f.getSize()));
g.drawString(s, x, y);
if ((flags & UNDERLINE) == UNDERLINE) g.drawLine(x, y, x + width, y);
if ((flags & STRIKE) == STRIKE)
g.drawLine(x, y - height / 2, x + width, y - height / 2);
g.setFont(f);
}
private int getXOffset(ViewJustification just, int width) {
switch (just) {
case LEFT:
return getX();
case CENTER:
return getX() + (getWidth() - width) / 2;
case RIGHT:
return getX() + getWidth() - width;
default:
return 0;
}
}
private static BufferedImage deepCopy(BufferedImage bi) {
ColorModel cm = bi.getColorModel();
boolean isAlphaPremultiplied = cm.isAlphaPremultiplied();
WritableRaster raster = bi.copyData(null);
return new BufferedImage(cm, raster, isAlphaPremultiplied, null);
}
public class SMSMapRenderer extends MapRenderer {
private final SMSMapView view;
public SMSMapRenderer(SMSMapView view) {
super(true);
this.view = view;
}
public SMSMapView getView() {
return view;
}
@Override
public void render(MapView map, MapCanvas canvas, Player player) {
if (isDirty(player)) {
BufferedImage img = renderImage(player);
canvas.drawImage(0, 0, img);
setDirty(player, false);
player.sendMap(map);
}
}
}
}