/*
* Copyright (C) 2016 MegaMek team
*
* This file is part of MekHQ.
*
* MekHQ is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 of the License, or
* (at your option) any later version.
*
* MekHQ 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.
*
* You should have received a copy of the GNU General Public License
* along with MekHQ. If not, see <http://www.gnu.org/licenses/>.
*/
package mekhq.gui.view;
import java.awt.AWTEvent;
import java.awt.AWTEventMulticaster;
import java.awt.AlphaComposite;
import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Image;
import java.awt.MediaTracker;
import java.awt.RenderingHints;
import java.awt.Toolkit;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.MouseEvent;
import java.awt.geom.Path2D;
import java.awt.geom.Point2D;
import java.awt.image.BufferedImage;
import java.io.InputStream;
import java.util.EnumMap;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.IntStream;
import javax.xml.bind.JAXBContext;
import javax.xml.bind.JAXBException;
import javax.xml.bind.Unmarshaller;
import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
import javax.xml.bind.annotation.XmlAttribute;
import javax.xml.bind.annotation.XmlElement;
import javax.xml.bind.annotation.XmlElementWrapper;
import javax.xml.bind.annotation.XmlRootElement;
import javax.xml.bind.annotation.XmlValue;
import javax.xml.bind.annotation.adapters.XmlAdapter;
import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
import mekhq.MekHQ;
import mekhq.campaign.personnel.BodyLocation;
import mekhq.gui.utilities.MultiplyComposite;
/**
* A component allowing to display a "paper doll" image, with overlays
* for body locations.
*/
public class Paperdoll extends Component {
private static final long serialVersionUID = 2427542264332728643L;
public static final int DEFAULT_WIDTH = 256;
public static final int DEFAULT_HEIGHT = 768;
private transient ActionListener listener;
private Image base;
private Map<BodyLocation, Path2D> locShapes;
private Map<BodyLocation, Color> locColors;
private Map<BodyLocation, Map<String, Image>> locOverlays;
private Map<BodyLocation, String> locTags;
private Color highlightColor;
private transient BodyLocation hoverLoc;
private transient double scale;
// TODO: Make this work with any enum, not just BodyLocation
public Paperdoll(InputStream is) {
locShapes = new EnumMap<>(BodyLocation.class);
locColors = new EnumMap<>(BodyLocation.class);
locOverlays = new EnumMap<>(BodyLocation.class);
locTags = new EnumMap<>(BodyLocation.class);
try {
loadShapeData(is);
} catch(Exception ex) {
MekHQ.logError(ex);
}
highlightColor = null;
enableEvents(AWTEvent.MOUSE_EVENT_MASK | AWTEvent.MOUSE_MOTION_EVENT_MASK);
}
public void loadShapeData(InputStream is) throws JAXBException {
JAXBContext context = JAXBContext.newInstance(OverlayLocDataList.class, OverlayLocData.class);
Unmarshaller unmarshaller = context.createUnmarshaller();
OverlayLocDataList dataList = (OverlayLocDataList) unmarshaller.unmarshal(is);
if(null != dataList.locs) {
dataList.locs.forEach(data -> {
locShapes.put(data.loc, data.genPath());
if(null != data.overlayImages) {
data.overlayImages.forEach(imgSpec -> {
Map<String, Image> overlayMap = locOverlays.get(data.loc);
if(null == overlayMap) {
overlayMap = new HashMap<>();
locOverlays.put(data.loc, overlayMap);
}
Image img = Toolkit.getDefaultToolkit().createImage(imgSpec.image);
overlayMap.put(imgSpec.tag, img);
});
}
});
}
if((null != dataList.base) && !dataList.base.isEmpty()) {
base = Toolkit.getDefaultToolkit().createImage(dataList.base);
MediaTracker mt = new MediaTracker(this);
mt.addImage(base, 0);
try {
mt.waitForAll();
} catch(InterruptedException iex) {
MekHQ.logError(iex);
}
} else {
base = new BufferedImage(DEFAULT_WIDTH, DEFAULT_HEIGHT, BufferedImage.TYPE_INT_ARGB);
}
setSize(base.getWidth(null), base.getHeight(null));
}
public void setLocShape(BodyLocation loc, Path2D path) {
Objects.requireNonNull(loc);
if(null != path) {
locShapes.put(loc, (Path2D) path.clone());
} else {
locShapes.remove(loc);
}
invalidate();
}
public void setLocColor(BodyLocation loc, Color color) {
Objects.requireNonNull(loc);
Color oldColor = locColors.get(loc);
locColors.put(loc, color);
if(!Objects.equals(color, oldColor)) {
invalidate();
}
}
public void setLocTag(BodyLocation loc, String tag) {
Objects.requireNonNull(loc);
String oldTag = locTags.get(loc);
if(null == tag) {
locTags.remove(loc);
} else {
locTags.put(loc, tag);
}
if(!Objects.equals(tag, oldTag)) {
invalidate();
}
}
public void clearLocColors() {
locColors.clear();
}
public void clearLocTags() {
locTags.clear();
}
public Color getHighlightColor() {
return highlightColor;
}
public void setHighlightColor(Color highlightColor) {
if(!Objects.equals(this.highlightColor, highlightColor)) {
invalidate();
}
this.highlightColor = highlightColor;
}
@Override
public void paint(Graphics g) {
final Graphics2D g2 = (Graphics2D) g;
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
g2.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
final int imgWidth = base.getWidth(null);
final int imgHeight = base.getHeight(null);
scale = Math.min(getWidth() * 1.0 / imgWidth, getHeight() * 1.0 / imgHeight);
final int scaledWidth = (int) Math.round(imgWidth * scale);
final int scaledHeight = (int) Math.round(imgHeight * scale);
g2.drawImage(base, 0, 0, scaledWidth, scaledHeight, this);
// Check for image overlays first, and record what we have drawn
Set<BodyLocation> drawnOverlays = EnumSet.noneOf(BodyLocation.class);
locTags.entrySet().stream().filter(Objects::nonNull)
.filter(entry -> ((null != entry.getValue()) && locOverlays.containsKey(entry.getKey())
&& locOverlays.get(entry.getKey()).containsKey(entry.getValue())))
.forEach(entry -> {
final Image image = locOverlays.get(entry.getKey()).get(entry.getValue());
g2.drawImage(image, 0, 0, scaledWidth, scaledHeight, this);
drawnOverlays.add(entry.getKey());
});
g2.scale(scale, scale);
locColors.entrySet().stream().filter(Objects::nonNull)
.filter(entry -> ((null != entry.getValue()) && locShapes.containsKey(entry.getKey())
&& !drawnOverlays.contains(entry.getKey())))
.forEach(entry -> {
final Path2D overlay = locShapes.get(entry.getKey());
g2.setPaint(entry.getValue());
g2.setComposite(MultiplyComposite.INSTANCE);
g2.fill(overlay);
});
g2.setComposite(AlphaComposite.SrcOver);
if((null != highlightColor) && (null != hoverLoc) && locShapes.containsKey(hoverLoc)) {
g2.setPaint(highlightColor);
g2.setStroke(new BasicStroke(5f));
g2.draw(locShapes.get(hoverLoc));
}
}
public BodyLocation locationUnderPoint(double x, double y) {
final double scaledX = x / scale;
final double scaledY = y / scale;
return locShapes.entrySet().stream()
.filter(entry -> entry.getValue().contains(scaledX, scaledY)).findAny()
.map(entry -> entry.getKey()).orElse(BodyLocation.GENERIC);
}
@Override
public Dimension getPreferredSize() {
return new Dimension(base.getWidth(null), base.getHeight(null));
}
public void addActionListener(ActionListener al) {
listener = AWTEventMulticaster.add(listener, al);
}
public void removeActionListener(ActionListener al) {
listener = AWTEventMulticaster.remove(listener, al);
}
@Override
public void processEvent(AWTEvent e) {
if(e instanceof MouseEvent) {
final MouseEvent event = (MouseEvent) e;
if((event.getID() == MouseEvent.MOUSE_MOVED) || (event.getID() == MouseEvent.MOUSE_ENTERED)) {
BodyLocation oldHoverLoc = hoverLoc;
hoverLoc = locationUnderPoint(event.getX(), event.getY());
if(oldHoverLoc != hoverLoc) {
repaint();
}
}
if(event.getID() == MouseEvent.MOUSE_EXITED) {
hoverLoc = null;
repaint();
}
if(event.getButton() == MouseEvent.BUTTON1) {
if(event.getID() == MouseEvent.MOUSE_CLICKED) {
if((null != listener) && (null != hoverLoc)) {
ActionEvent myEvent = new ActionEvent(this, ActionEvent.ACTION_PERFORMED, hoverLoc.toString());
listener.actionPerformed(myEvent);
}
}
}
}
}
// XML serialization classes
@XmlRootElement(name="overlays")
@XmlAccessorType(XmlAccessType.FIELD)
private static class OverlayLocDataList {
public String base;
@XmlElement(name="loc")
public List<OverlayLocData> locs;
}
@XmlRootElement(name="loc")
@XmlAccessorType(XmlAccessType.FIELD)
public static class OverlayLocData {
@XmlAttribute(name="type")
public BodyLocation loc;
@XmlElement(name="p")
@XmlElementWrapper(name="path")
@XmlJavaTypeAdapter(XMLPoint2DAdapter.class)
public List<Point2D> path;
@XmlElement(name="image")
public List<OverlayLocImage> overlayImages;
public Path2D genPath() {
Path2D result = new Path2D.Float();
if((null != path) && !path.isEmpty()) {
result.moveTo(path.get(0).getX(), path.get(0).getY());
IntStream.range(1, path.size()).mapToObj(i -> path.get(i))
.forEachOrdered(p -> result.lineTo(p.getX(), p.getY()));
result.closePath();
}
return result;
}
}
public static class OverlayLocImage {
@XmlAttribute
public String tag;
@XmlValue
public String image;
}
private static class XMLPoint2DAdapter extends XmlAdapter<String, Point2D> {
@Override
public Point2D unmarshal(String v) throws Exception {
if((null == v) || v.isEmpty()) {
return null;
}
String data[] = v.split(",", 2);
if(data.length < 2) {
return null;
}
try {
return new Point2D.Float(Float.parseFloat(data[0]), Float.parseFloat(data[1]));
} catch(NumberFormatException nfex) {
// Oh well, we tried
}
return null;
}
@Override
public String marshal(Point2D v) throws Exception {
return (null != v) ? String.format(Locale.ROOT, "%.3f,%.3f", v.getX(), v.getY()) : null;
}
}
}