package games.strategy.triplea.ui; import static com.google.common.base.Preconditions.checkNotNull; import java.awt.BasicStroke; import java.awt.Color; import java.awt.Font; import java.awt.Graphics2D; import java.awt.Polygon; import java.awt.RenderingHints; import java.awt.Shape; import java.awt.geom.AffineTransform; import java.awt.geom.Line2D; import java.awt.geom.Point2D; import java.awt.image.BufferedImage; import java.util.Arrays; import java.util.List; import java.util.function.Function; import org.apache.commons.math3.analysis.interpolation.SplineInterpolator; import org.apache.commons.math3.analysis.polynomials.PolynomialSplineFunction; import games.strategy.engine.data.Route; import games.strategy.engine.data.Territory; import games.strategy.triplea.ui.logic.Line; import games.strategy.triplea.ui.logic.Point; import games.strategy.triplea.ui.logic.RouteCalculator; import games.strategy.triplea.ui.mapdata.MapData; /** * Draws a route on a map. */ public class MapRouteDrawer { private static final SplineInterpolator splineInterpolator = new SplineInterpolator(); /** * This value influences the "resolution" of the Path. * Too low values make the Path look edgy, too high values will cause lag and rendering errors * because the distance between the drawing segments is shorter than 2 pixels */ public static final double DETAIL_LEVEL = 1.0; private static final int arrowLength = 4; private final RouteCalculator routeCalculator; private final MapData mapData; private final MapPanel mapPanel; public MapRouteDrawer(final MapPanel mapPanel, final MapData mapData) { routeCalculator = new RouteCalculator(mapData.scrollWrapX(), mapData.scrollWrapY(), mapPanel.getImageWidth(), mapPanel.getImageHeight()); this.mapData = checkNotNull(mapData); this.mapPanel = checkNotNull(mapPanel); } /** * Draws the route to the screen. */ public void drawRoute(final Graphics2D graphics, final RouteDescription routeDescription, final String maxMovement) { final Route route = routeDescription.getRoute(); if (route == null) { return; } // set thickness and color of the future drawings graphics.setStroke(new BasicStroke(3.5f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); graphics.setPaint(Color.red); graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); final int numTerritories = route.getAllTerritories().size(); final int xOffset = mapPanel.getXOffset(); final int yOffset = mapPanel.getYOffset(); final Point[] points = routeCalculator.getTranslatedRoute(getRoutePoints(routeDescription)); final boolean tooFewTerritories = numTerritories <= 1; final boolean tooFewPoints = points.length <= 2; final double scale = mapPanel.getScale(); if (tooFewTerritories || tooFewPoints) { if (routeDescription.getEnd() != null) { // AI has no End Point drawDirectPath(graphics, new Point(routeDescription.getStart()), new Point(routeDescription.getEnd()), xOffset, yOffset, scale); } else { drawDirectPath(graphics, points[0], points[points.length - 1], xOffset, yOffset, scale); } if (tooFewPoints && !tooFewTerritories) { drawMoveLength(graphics, points, xOffset, yOffset, scale, numTerritories, maxMovement); } } else { drawCurvedPath(graphics, points, xOffset, yOffset, scale); drawMoveLength(graphics, points, xOffset, yOffset, scale, numTerritories, maxMovement); } drawJoints(graphics, points, xOffset, yOffset, scale); drawCustomCursor(graphics, routeDescription, xOffset, yOffset, scale); } /** * Draws Points on the Map. * * @param graphics The {@linkplain Graphics2D} Object being drawn on * @param points The {@linkplain Point} array aka the "Joints" to be drawn * @param xOffset The horizontal pixel-difference between the frame and the Map * @param yOffset The vertical pixel-difference between the frame and the Map * @param jointsize The diameter of the Points being drawn * @param scale The scale-factor of the Map */ private void drawJoints(final Graphics2D graphics, final Point[] points, final int xOffset, final int yOffset, final double scale) { final int jointsize = 10; // If the points array is bigger than 1 the last joint should not be drawn (draw an arrow instead) final Point[] newPoints = points.length > 1 ? Arrays.copyOf(points, points.length - 1) : points; for (Point[] joints : routeCalculator.getAllPoints(newPoints)) { for (final Point p : joints) { graphics.fillOval((int) (((p.getX() - xOffset) - (jointsize / 2) / scale) * scale), (int) (((p.getY() - yOffset) - (jointsize / 2) / scale) * scale), jointsize, jointsize); } } } /** * Draws a specified CursorImage if available. * * @param graphics The {@linkplain Graphics2D} Object being drawn on * @param routeDescription The RouteDescription object containing the CursorImage * @param xOffset The horizontal pixel-difference between the frame and the Map * @param yOffset The vertical pixel-difference between the frame and the Map * @param scale The scale-factor of the Map */ private void drawCustomCursor(final Graphics2D graphics, final RouteDescription routeDescription, final int xOffset, final int yOffset, final double scale) { final BufferedImage cursorImage = (BufferedImage) routeDescription.getCursorImage(); if (cursorImage != null) { for (Point[] endPoint : routeCalculator.getAllPoints(routeCalculator.getLastEndPoint())) { graphics.drawImage(cursorImage, (int) (((endPoint[0].getX() - xOffset) - (cursorImage.getWidth() / 2)) * scale), (int) (((endPoint[0].getY() - yOffset) - (cursorImage.getHeight() / 2)) * scale), null); } } } /** * Draws a straight Line from the start to the stop of the specified {@linkplain RouteDescription} * Also draws a small little point at the end of the Line. * * @param graphics The {@linkplain Graphics2D} Object being drawn on * @param start The start {@linkplain Point} of the Path * @param end The end {@linkplain Point} of the Path * @param xOffset The horizontal pixel-difference between the frame and the Map * @param yOffset The vertical pixel-difference between the frame and the Map * @param jointsize The diameter of the Points being drawn * @param scale The scale-factor of the Map */ private void drawDirectPath(final Graphics2D graphics, final Point start, final Point end, final int xOffset, final int yOffset, final double scale) { final Point[] points = routeCalculator.getTranslatedRoute(start, end); for (Point[] newPoints : routeCalculator.getAllPoints(points)) { drawLineWithTranslate(graphics, new Line2D.Float(newPoints[0].toPoint(), newPoints[1].toPoint()), xOffset, yOffset, scale); if (newPoints[0].distance(newPoints[1]) > arrowLength) { drawArrow(graphics, newPoints[0].toPoint(), newPoints[1].toPoint(), xOffset, yOffset, scale); } } } /** * Centripetal parameterization * * <p> * Check <a href="http://stackoverflow.com/a/37370620/5769952">http://stackoverflow.com/a/37370620/5769952</a> for * more information * </p> * * @param points - The Points which should be parameterized * @return A Parameter-Array called the "Index" */ protected double[] createParameterizedIndex(final Point[] points) { final double[] index = new double[points.length]; if (index.length > 0) { index[0] = 0; } for (int i = 1; i < points.length; i++) { index[i] = index[i - 1] + Math.sqrt(points[i - 1].distance(points[i])); } return index; } /** * Draws a line to the Screen regarding the Map-Offset and scale. * * @param graphics The {@linkplain Graphics2D} Object to be drawn on * @param line The Line to be drawn * @param xOffset The horizontal pixel-difference between the frame and the Map * @param yOffset The vertical pixel-difference between the frame and the Map * @param scale The scale-factor of the Map */ private void drawLineWithTranslate(final Graphics2D graphics, final Line2D line, final double xOffset, final double yOffset, final double scale) { graphics.draw( new Line2D.Double( new Point2D.Double((line.getP1().getX() - xOffset) * scale, (line.getP1().getY() - yOffset) * scale), new Point2D.Double((line.getP2().getX() - xOffset) * scale, (line.getP2().getY() - yOffset) * scale))); } /** * Creates a {@linkplain Point} Array out of a {@linkplain RouteDescription} and a {@linkplain MapData} object. * * @param routeDescription {@linkplain RouteDescription} containing the Route information * @param mapData {@linkplain MapData} Object containing Information about the Map Coordinates * @return The {@linkplain Point} array specified by the {@linkplain RouteDescription} and {@linkplain MapData} * objects */ protected Point[] getRoutePoints(final RouteDescription routeDescription) { final List<Territory> territories = routeDescription.getRoute().getAllTerritories(); final int numTerritories = territories.size(); final Point[] points = new Point[numTerritories]; for (int i = 0; i < numTerritories; i++) { points[i] = new Point(mapData.getCenter(territories.get(i))); } if (routeDescription.getStart() != null) { points[0] = new Point(routeDescription.getStart()); } if (routeDescription.getEnd() != null && numTerritories > 1) { points[numTerritories - 1] = new Point(routeDescription.getEnd()); } return points; } /** * Creates double arrays of y or x coordinates of the given {@linkplain Point} Array. * * @param points The {@linkplain Point} Array containing the Coordinates * @param extractor A function specifying which value to return * @return A double array with values specified by the given function */ protected double[] getValues(final Point[] points, final Function<Point, Double> extractor) { final double[] result = new double[points.length]; for (int i = 0; i < points.length; i++) { result[i] = extractor.apply(points[i]); } return result; } /** * Creates a double array containing y coordinates of a {@linkplain PolynomialSplineFunction} with the above specified * {@code DETAIL_LEVEL}. * * @param fuction The {@linkplain PolynomialSplineFunction} with the values * @param index the parameterized array to indicate the maximum Values * @return an array of double-precision y values of the specified function */ protected double[] getCoords(final PolynomialSplineFunction fuction, final double[] index) { final double defaultCoordSize = index[index.length - 1]; final double[] coords = new double[(int) Math.round(DETAIL_LEVEL * defaultCoordSize) + 1]; final double stepSize = fuction.getKnots()[fuction.getKnots().length - 1] / coords.length; double curValue = 0; for (int i = 0; i < coords.length; i++) { coords[i] = fuction.value(curValue); curValue += stepSize; } return coords; } /** * Draws how many moves are left. * * @param graphics The {@linkplain Graphics2D} Object to be drawn on * @param points The {@linkplain Point} array of the unit's tour * @param xOffset The horizontal pixel-difference between the frame and the Map * @param yOffset The vertical pixel-difference between the frame and the Map * @param scale The scale-factor of the Map * @param numTerritories how many Territories the unit traveled so far * @param maxMovement The String indicating how man */ private void drawMoveLength(final Graphics2D graphics, final Point[] points, final int xOffset, final int yOffset, final double scale, final int numTerritories, final String maxMovement) { final Point cursorPos = points[points.length - 1]; final String unitMovementLeft = maxMovement == null || maxMovement.trim().length() == 0 ? "" : " /" + maxMovement; final BufferedImage movementImage = new BufferedImage(50, 20, BufferedImage.TYPE_INT_ARGB); createMovementLeftImage(movementImage, String.valueOf(numTerritories - 1), unitMovementLeft); final int textXOffset = -movementImage.getWidth() / 2; final double yDir = cursorPos.getY() - points[numTerritories - 2].getY(); final int textYOffset = yDir > 0 ? movementImage.getHeight() : movementImage.getHeight() * -2; for (Point[] cursorPositions : routeCalculator.getAllPoints(cursorPos)) { graphics.drawImage(movementImage, (int) ((cursorPositions[0].getX() + textXOffset - xOffset) * scale), (int) ((cursorPositions[0].getY() + textYOffset - yOffset) * scale), null); } } /** * Draws a smooth curve through the given array of points * * <p> * This algorithm is called Spline-Interpolation * because the Apache-commons-math library we are using here does not accept * values but {@code f(x)=y} with x having to increase all the time * the idea behind this is to use a parameter array - the so called index * as x array and splitting the points into a x and y coordinates array. * </p> * * <p> * Finally those 2 interpolated arrays get unified into a single {@linkplain Point} array and drawn to the Map * </p> * * @param graphics The {@linkplain Graphics2D} Object to be drawn on * @param points The Knot Points for the Spline-Interpolator aka the joints * @param xOffset The horizontal pixel-difference between the frame and the Map * @param yOffset The vertical pixel-difference between the frame and the Map * @param scale The scale-factor of the Map */ private void drawCurvedPath(final Graphics2D graphics, final Point[] points, final int xOffset, final int yOffset, final double scale) { final double[] index = createParameterizedIndex(points); final PolynomialSplineFunction xcurve = splineInterpolator.interpolate(index, getValues(points, point -> point.getX())); final double[] xcoords = getCoords(xcurve, index); final PolynomialSplineFunction ycurve = splineInterpolator.interpolate(index, getValues(points, point -> point.getY())); final double[] ycoords = getCoords(ycurve, index); List<Line> lines = routeCalculator.getAllNormalizedLines(xcoords, ycoords); for (Line line : lines) { drawLineWithTranslate(graphics, line.toLine2D(), xOffset, yOffset, scale); } // draws the Line to the Cursor on every possible screen, so that the line ends at the cursor no matter what... List<Point[]> finishingPoints = routeCalculator.getAllPoints( new Point(xcoords[xcoords.length - 1], ycoords[ycoords.length - 1]), points[points.length - 1]); boolean hasArrowEnoughSpace = points[points.length - 2].distance(points[points.length - 1]) > arrowLength; for (Point[] finishingPointArray : finishingPoints) { drawLineWithTranslate(graphics, new Line(finishingPointArray[0], finishingPointArray[1]).toLine2D(), xOffset, yOffset, scale); if (hasArrowEnoughSpace) { drawArrow(graphics, finishingPointArray[0].toPoint(), finishingPointArray[1].toPoint(), xOffset, yOffset, scale); } } } /** * This draws how many moves are left on the given {@linkplain BufferedImage} * * @param image The Image to be drawn on * @param curMovement How many territories the unit traveled so far * @param maxMovement How many territories is allowed to travel. Is empty when the unit traveled too far */ private void createMovementLeftImage(final BufferedImage image, final String curMovement, final String maxMovement) { final Graphics2D textG2D = image.createGraphics(); textG2D.setColor(Color.YELLOW); textG2D.setFont(new Font("Dialog", Font.BOLD, 20)); final int textThicknessOffset = textG2D.getFontMetrics().stringWidth(curMovement) / 2; final boolean distanceTooBig = maxMovement.equals(""); textG2D.drawString(curMovement, distanceTooBig ? image.getWidth() / 2 - textThicknessOffset : 10, image.getHeight()); if (!distanceTooBig) { textG2D.setColor(new Color(33, 0, 127)); textG2D.setFont(new Font("Dialog", Font.BOLD, 16)); textG2D.drawString(maxMovement, 10, image.getHeight()); } } /** * Creates an Arrow-Shape. * * @param from The {@linkplain Point2D} specifying the direction of the Arrow * @param to The {@linkplain Point2D} where the arrow is placed * @return A transformed Arrow-Shape */ private static Shape createArrowTipShape(final Point2D from, final Point2D to) { final int arrowOffset = 1; final Polygon arrowPolygon = new Polygon(); arrowPolygon.addPoint(arrowOffset - arrowLength, arrowLength / 2); arrowPolygon.addPoint(arrowOffset, 0); arrowPolygon.addPoint(arrowOffset - arrowLength, arrowLength / -2); final AffineTransform transform = new AffineTransform(); transform.translate(to.getX(), to.getY()); transform.scale(arrowLength, arrowLength); final double rotate = Math.atan2(to.getY() - from.getY(), to.getX() - from.getX()); transform.rotate(rotate); return transform.createTransformedShape(arrowPolygon); } /** * Draws an Arrow on the {@linkplain Graphics2D} Object. * * @param graphics The {@linkplain Graphics2D} object to draw on * @param from The destination {@linkplain Point2D} form the Arrow * @param to The placement {@linkplain Point2D} for the Arrow * @param xOffset The horizontal pixel-difference between the frame and the Map * @param yOffset The vertical pixel-difference between the frame and the Map * @param scale The scale-factor of the Map */ private static void drawArrow(final Graphics2D graphics, final Point2D from, final Point2D to, final int xOffset, final int yOffset, final double scale) { final Point2D scaledStart = new Point2D.Double((from.getX() - xOffset) * scale, (from.getY() - yOffset) * scale); final Point2D scaledEnd = new Point2D.Double((to.getX() - xOffset) * scale, (to.getY() - yOffset) * scale); graphics.fill(createArrowTipShape(scaledStart, scaledEnd)); } }