diff --git a/src/main/java/net/rptools/maptool/model/Grid.java b/src/main/java/net/rptools/maptool/model/Grid.java index 3e6de5ee8c..80bba9cb92 100644 --- a/src/main/java/net/rptools/maptool/model/Grid.java +++ b/src/main/java/net/rptools/maptool/model/Grid.java @@ -387,50 +387,57 @@ public void setSize(int size) { fireGridChanged(); } + // region Light shapes + /** - * Called by SightType and Light class to return a vision area based upon a specified distance + * Get the grid-relative angle of the token based on its facing. * - * @param shape CIRCLE, GRID, SQUARE, CONE or LINE - * @param token Used to position the shape and to provide footprint - * @param range As specified in the vision or light definition - * @param arcAngle Only used by cone - * @param offsetAngle Arc distance from facing, only used by cone - * @param scaleWithToken used to increase the area based on token footprint - * @return Area + *

This is used to rotate cones and beams according to the on-grid angle. The result is the + * number of clockwise degrees measured from the positive x-axis of the grid. + * + *

This method exists because {@link net.rptools.maptool.model.Token#getFacing()} is a measure + * of the on-screen angle of the token's facing, i.e., how many degrees from the positive x-axis + * of the screen. For most grids this is the same as measuring the number of degress from the + * positive x-axis of the grid, which is what is should be. + * + *

Things are different for isometric grids. Since they are rotated, the on-screen facing does + * not agree with the on-grid facing - there is a 45° offset. When building shapes, we need the + * on-grid facing, not the on-screen facing. This method allows isometric grids to override the + * default behaviour so that an on-grid facing is provided. + * + * @param token The token whose facing needs to be determined. + * @return The direction the token is facing, in clockwise degrees from the positive x-axis of the + * grid. */ - public @Nonnull Area getShapedArea( + protected int getTokenFacingAngleRelativeToGridAxis(Token token) { + return -token.getFacing(); + } + + /** + * Get the main area for a given light shape type. + * + *

This method expressly does not add in the footprint bit that cone lights are expected to + * have. This part cannot be freely transformed, so it is done separately in {@link + * #getFootprintShapedAreaForCone(java.awt.Rectangle)}. + * + * @param shape The shape. Can be any shape except {@link + * net.rptools.maptool.model.ShapeType#GRID}. + * @param tokenFacingAngle The angle on-screen that the token is facing. Used for cones and beams + * to provide the main axis of the shape. + * @param visionRange The range to which the token can see. Determines the size of the shape. + * @param width For beams, the width of the beam. Otherwise, ignored. + * @param arcAngle For cones, the internal angle of the point of the cone. Otherwise, ignored. + * @param offsetAngle For cones and beams, an offset to apply relative to the token facing. + * Otherwise, ignored. + * @return The area of the light. + */ + protected @Nonnull Area getShapedAreaWithoutFootprint( ShapeType shape, - Token token, - double range, + int tokenFacingAngle, + double visionRange, double width, double arcAngle, - int offsetAngle, - boolean scaleWithToken) { - if (shape == null) { - shape = ShapeType.CIRCLE; - } - int visionDistance = zone.getTokenVisionInPixels(); - double visionRange = (range == 0) ? visionDistance : range * getSize() / zone.getUnitsPerCell(); - /* Token facing as an angle. 0° points to the right and clockwise is positive. */ - int tokenFacingAngle = token.getFacingInDegrees() + 90; - Rectangle footprint = token.getFootprint(this).getBounds(this); - - if (scaleWithToken) { - double footprintWidth = footprint.getWidth() / 2; - - // Test for gridless maps - var cellShape = getCellShape(); - if (cellShape == null) { - double tokenBoundsWidth = token.getBounds(getZone()).getWidth() / 2; - visionRange += (footprintWidth > tokenBoundsWidth) ? tokenBoundsWidth : tokenBoundsWidth; - } else { - // For grids, this will be the same, but for Hex's we'll use the smaller side depending on - // which Hex type you choose - double footprintHeight = footprint.getHeight() / 2; - visionRange += Math.min(footprintWidth, footprintHeight); - } - } - + int offsetAngle) { Area visibleArea; switch (shape) { case CIRCLE -> { @@ -438,9 +445,6 @@ public void setSize(int size) { GraphicsUtil.createLineSegmentEllipse( -visionRange, -visionRange, visionRange, visionRange, CIRCLE_SEGMENTS); } - case GRID -> { - visibleArea = getGridArea(token, range, scaleWithToken, visionRange); - } case SQUARE -> { visibleArea = new Area( @@ -472,15 +476,14 @@ public void setSize(int size) { GeneralPath path = new GeneralPath(); path.append(cone.getPathIterator(null, 1), false); visibleArea = new Area(path); - - var footprintPart = new Rectangle(footprint); - footprintPart.x = -footprintPart.width / 2; - footprintPart.y = -footprintPart.height / 2; - visibleArea.add(new Area(footprintPart)); } case HEX -> { visibleArea = createHex(visionRange); } + case GRID -> { + log.error("Shape {} should not be handled here. Returning empty area.", shape); + visibleArea = new Area(); + } default -> { log.error("Unhandled shape {}; treating as a circle", shape); visibleArea = @@ -492,6 +495,75 @@ public void setSize(int size) { return visibleArea; } + protected @Nonnull Area getFootprintShapedAreaForCone(Rectangle footprint) { + var footprintPart = new Rectangle(footprint); + footprintPart.x = -footprintPart.width / 2; + footprintPart.y = -footprintPart.height / 2; + return new Area(footprintPart); + } + + /** + * Called by SightType and Light class to return a vision area based upon a specified distance + * + * @param shape The shape of the light. Can be any {@link net.rptools.maptool.model.ShapeType} + * @param token Used to position the shape and to provide footprint + * @param range How far the shape should extends from the origin. If {@code 0}, the zone's vision + * range is used. + * @param arcAngle Only used by cone + * @param offsetAngle Arc distance from facing, only used by cone + * @param scaleWithToken used to increase the area based on token footprint + * @return Area + */ + public @Nonnull Area getShapedArea( + ShapeType shape, + Token token, + double range, + double width, + double arcAngle, + int offsetAngle, + boolean scaleWithToken) { + if (range == 0) { + range = zone.getTokenVisionDistance(); + } + double visionRange = range * getSize() / zone.getUnitsPerCell(); + + Rectangle footprint = token.getFootprint(this).getBounds(this); + + if (scaleWithToken) { + double footprintWidth = footprint.getWidth() / 2; + + // Test for gridless maps + var cellShape = getCellShape(); + if (cellShape == null) { + double tokenBoundsWidth = token.getBounds(zone).getWidth() / 2; + visionRange += (footprintWidth > tokenBoundsWidth) ? tokenBoundsWidth : tokenBoundsWidth; + } else { + // For grids, this will be the same, but for Hex's we'll use the smaller side depending on + // which Hex type you choose + double footprintHeight = footprint.getHeight() / 2; + visionRange += Math.min(footprintWidth, footprintHeight); + } + } + + // Grid shape is unique in that it is deliberately "unnatural". So handle it separately. + if (shape == ShapeType.GRID) { + return getGridArea(token, range, scaleWithToken, visionRange); + } + + var facingAngle = getTokenFacingAngleRelativeToGridAxis(token); + var visibleArea = + getShapedAreaWithoutFootprint( + shape, facingAngle, visionRange, width, arcAngle, offsetAngle); + if (shape == ShapeType.CONE) { + // Cones are unique in that they add the token footprint to the shape. + visibleArea.add(getFootprintShapedAreaForCone(footprint)); + } + + return visibleArea; + } + + // endregion + /** * Return the cell distance between two cells. Does not take into account terrain or VBL. * Overridden by Hex & Gridless grids. @@ -777,7 +849,7 @@ protected Area getGridArea( if (range > 0) { final Stopwatch stopwatch = Stopwatch.createStarted(); - final int gridRadius = (int) (range / getZone().getUnitsPerCell()); + final int gridRadius = (int) (range / zone.getUnitsPerCell()); if (scaleWithToken) { visibleArea = getScaledGridArea(token, gridRadius); diff --git a/src/main/java/net/rptools/maptool/model/IsometricGrid.java b/src/main/java/net/rptools/maptool/model/IsometricGrid.java index 5242e5e0c1..0e31b960fd 100644 --- a/src/main/java/net/rptools/maptool/model/IsometricGrid.java +++ b/src/main/java/net/rptools/maptool/model/IsometricGrid.java @@ -37,7 +37,6 @@ import net.rptools.maptool.client.walker.astar.AStarSquareEuclideanWalker; import net.rptools.maptool.server.proto.GridDto; import net.rptools.maptool.server.proto.IsometricGridDto; -import net.rptools.maptool.util.GraphicsUtil; public class IsometricGrid extends Grid { private static List footprintList; @@ -268,96 +267,40 @@ public void uninstallMovementKeys(Map actionMap) { } @Override - public @Nonnull Area getShapedArea( + protected int getTokenFacingAngleRelativeToGridAxis(Token token) { + return -(token.getFacing() + 45); + } + + @Override + protected @Nonnull Area getShapedAreaWithoutFootprint( ShapeType shape, - Token token, - double range, + int tokenFacingAngle, + double visionRange, double width, double arcAngle, - int offsetAngle, - boolean scaleWithToken) { - if (shape == null) { - shape = ShapeType.CIRCLE; - } - int visionDistance = getZone().getTokenVisionInPixels(); - double visionRange = - (range == 0) ? visionDistance : range * getSize() / getZone().getUnitsPerCell(); - int tokenFacingAngle = token.getFacingInDegrees() + 90; - Rectangle footprint = token.getFootprint(this).getBounds(this); - - if (scaleWithToken) { - visionRange += footprint.getHeight() / 2; - } + int offsetAngle) { + var orthoShape = + super.getShapedAreaWithoutFootprint( + shape, tokenFacingAngle, visionRange, width, arcAngle, offsetAngle); - Area visibleArea = new Area(); - switch (shape) { - case CIRCLE -> { - visionRange = (float) Math.sin(Math.toRadians(45)) * visionRange; - visibleArea = - GraphicsUtil.createLineSegmentEllipse( - -visionRange * 2, -visionRange, visionRange * 2, visionRange, CIRCLE_SEGMENTS); - } - case SQUARE -> { - int[] x = {0, (int) visionRange * 2, 0, (int) -visionRange * 2}; - int[] y = {(int) -visionRange, 0, (int) visionRange, 0}; - visibleArea = new Area(new Polygon(x, y, 4)); - } - case BEAM -> { - var pixelWidth = Math.max(2, width * getSize() / getZone().getUnitsPerCell()); - Shape visibleShape = new Rectangle2D.Double(0, -pixelWidth / 2, visionRange, pixelWidth); - - // new angle, corrected for isometric view - double theta = Math.toRadians(offsetAngle - tokenFacingAngle); - Point2D angleVector = new Point2D.Double(Math.cos(theta), Math.sin(theta)); - AffineTransform at = new AffineTransform(); - at.rotate(Math.PI / 4); - at.scale(1.0, 0.5); - at.deltaTransform(angleVector, angleVector); - - theta = -Math.atan2(angleVector.getY(), angleVector.getX()); - - visibleArea = - new Area( - AffineTransform.getRotateInstance(theta + Math.toRadians(45)) - .createTransformedShape(visibleShape)); - } - case CONE -> { - // Rotate the vision range by 45 degrees for isometric view - visionRange = (float) Math.sin(Math.toRadians(45)) * visionRange; - // Get the cone, use degreesFromIso to convert the facing from isometric to plan - - Arc2D cone = - new Arc2D.Double( - -visionRange * 2, - -visionRange, - visionRange * 4, - visionRange * 2, - (offsetAngle - tokenFacingAngle) - (arcAngle / 2.0), - arcAngle, - Arc2D.PIE); - GeneralPath path = new GeneralPath(); - path.append(cone.getPathIterator(null, 1), false); // Flatten the cone to remove 'curves' - Area tempvisibleArea = new Area(path); - - // convert the cell footprint to an area - Area cellShape = createCellShape(footprint.height); - // convert the area to isometric view - AffineTransform mtx = new AffineTransform(); - mtx.translate(-footprint.width / 2, -footprint.height / 2); - cellShape.transform(mtx); - // join cell footprint and cone to create viewable area - visibleArea.add(cellShape); - visibleArea.add(tempvisibleArea); - } - default -> { - log.error("Unhandled shape {}; treating as a circle", shape); - visionRange = (float) Math.sin(Math.toRadians(45)) * visionRange; - visibleArea = - GraphicsUtil.createLineSegmentEllipse( - -visionRange * 2, -visionRange, visionRange * 2, visionRange, CIRCLE_SEGMENTS); - } - } - return visibleArea; + AffineTransform at = new AffineTransform(); + var sqrt2 = Math.sqrt(2); + at.scale(sqrt2, sqrt2 / 2); + at.rotate(Math.PI / 4); + orthoShape.transform(at); + + return orthoShape; + } + + @Override + protected @Nonnull Area getFootprintShapedAreaForCone(Rectangle footprint) { + // convert the cell footprint to an area + Area cellShape = createCellShape(footprint.height); + // convert the area to isometric view + AffineTransform mtx = new AffineTransform(); + mtx.translate(-footprint.width / 2, -footprint.height / 2); + cellShape.transform(mtx); + return cellShape; } @Override diff --git a/src/main/java/net/rptools/maptool/model/Zone.java b/src/main/java/net/rptools/maptool/model/Zone.java index d5acf2dea0..3bd4ac6714 100644 --- a/src/main/java/net/rptools/maptool/model/Zone.java +++ b/src/main/java/net/rptools/maptool/model/Zone.java @@ -527,17 +527,6 @@ public void setTokenSelection(TokenSelection tokenSelection) { this.tokenSelection = tokenSelection; } - /** - * @return the distance in map pixels at a 1:1 zoom - */ - public int getTokenVisionInPixels() { - if (tokenVisionDistance == 0) { - // TODO: This is here to provide transition between pre 1.3b19 an 1.3b19. Remove later - tokenVisionDistance = DEFAULT_TOKEN_VISION_DISTANCE; - } - return Double.valueOf(tokenVisionDistance * grid.getSize() / getUnitsPerCell()).intValue(); - } - public void setFogPaint(DrawablePaint paint) { fogPaint = paint; } @@ -2101,6 +2090,11 @@ private void collapseDrawableLayer(List layer) { // Backward compatibility @SuppressWarnings("ConstantConditions") protected Object readResolve() { + if (tokenVisionDistance == 0) { + // 1.3b19 + tokenVisionDistance = DEFAULT_TOKEN_VISION_DISTANCE; + } + if ("".equals(playerAlias) || name.equals(playerAlias)) { // Don't keep redundant player aliases around. The display name will default to the name if // no player alias is set.