Skip to content

Commit

Permalink
Decompose Grid.getShapedArea() so we can reuse logic between grids
Browse files Browse the repository at this point in the history
We now calculate these things separately:
1. Which way the token is facing on the grid.
2. The shape of the light itself.
3. The shape of the token's footprint contribution to the light (for cones).

With this we gain the following benefits:
1. `IsometricGrid` can reuse the main shape logic and just transform the result. No more duplicated logic.
2. The code should be more approachable as each method does One Thing™.

Lights on isometric grids act literally the same as for square grids, just rotated and foreshortened. This fixes some
related bugs, e.g., beams not getting visually shorted when vertical. It also means iso grids now have a full set of
accurate lights available, e.g., hexes no longer default to ellipses.
  • Loading branch information
kwvanderlinde committed Oct 5, 2024
1 parent 866eb94 commit ff3de9a
Show file tree
Hide file tree
Showing 3 changed files with 155 additions and 155 deletions.
177 changes: 120 additions & 57 deletions src/main/java/net/rptools/maptool/model/Grid.java
Original file line number Diff line number Diff line change
Expand Up @@ -387,60 +387,64 @@ 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
* <p>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.
*
* <p>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.
*
* <p>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.
*
* <p>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 -> {
visibleArea =
GraphicsUtil.createLineSegmentEllipse(
-visionRange, -visionRange, visionRange, visionRange, CIRCLE_SEGMENTS);
}
case GRID -> {
visibleArea = getGridArea(token, range, scaleWithToken, visionRange);
}
case SQUARE -> {
visibleArea =
new Area(
Expand Down Expand Up @@ -472,23 +476,13 @@ 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 -> {
double x = footprint.getCenterX();
double y = footprint.getCenterY();

double footprintWidth = footprint.getWidth();
double footprintHeight = footprint.getHeight();
double adjustment = Math.min(footprintWidth, footprintHeight);
x -= adjustment / 2;
y -= adjustment / 2;

visibleArea = createHex(x, y, visionRange, 0);
visibleArea = createHex(0, 0, visionRange, 0);
}
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);
Expand All @@ -501,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 &amp; Gridless grids.
Expand Down Expand Up @@ -792,7 +855,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);
Expand Down
117 changes: 30 additions & 87 deletions src/main/java/net/rptools/maptool/model/IsometricGrid.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<TokenFootprint> footprintList;
Expand Down Expand Up @@ -268,96 +267,40 @@ public void uninstallMovementKeys(Map<KeyStroke, Action> 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
Expand Down
16 changes: 5 additions & 11 deletions src/main/java/net/rptools/maptool/model/Zone.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -2101,6 +2090,11 @@ private void collapseDrawableLayer(List<DrawnElement> 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.
Expand Down

0 comments on commit ff3de9a

Please sign in to comment.