/*
* Copyright (c) 2014 tabletoptool.com team.
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*
* Contributors:
* rptools.com team - initial implementation
* tabletoptool.com team - further development
*/
package com.t3.model.grid;
import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Graphics2D;
import java.awt.Rectangle;
import java.awt.RenderingHints;
import java.awt.Shape;
import java.awt.geom.AffineTransform;
import java.awt.geom.Area;
import java.awt.geom.GeneralPath;
import java.awt.image.BufferedImage;
import java.io.IOException;
import com.t3.client.AppState;
import com.t3.client.ui.zone.ZoneRenderer;
import com.t3.image.ImageUtil;
import com.t3.model.CellPoint;
import com.t3.model.Token;
import com.t3.model.TokenFootprint.OffsetTranslator;
import com.t3.model.ZonePoint;
import com.t3.swing.SwingUtil;
import com.t3.xstreamversioned.version.SerializationVersion;
/**
* An abstract hex grid class that uses generic Cartesian-coordinates for calculations to allow for various hex grid
* orientations.
*
* The v-axis points along the direction of edge to edge hexes
*/
@SerializationVersion(0)
public abstract class HexGrid extends Grid {
// A regular hexagon is one where all angles are 60 degrees.
// the ratio = minor_radius / edge_length
public static final double REGULAR_HEX_RATIO = Math.sqrt(3) / 2;
/** One DirectionCalculator object is shared by all instances of this hex grid class. */
private static final DirectionCalculator calculator = new DirectionCalculator();
static {
try {
pathHighlight = ImageUtil.getCompatibleImage("com/t3/client/image/hexBorder.png");
} catch (IOException ioe) {
ioe.printStackTrace();
}
}
private static final GridCapabilities GRID_CAPABILITIES = new GridCapabilities() {
@Override
public boolean isPathingSupported() {
return true;
}
@Override
public boolean isSnapToGridSupported() {
return true;
}
@Override
public boolean isPathLineSupported() {
return false;
}
@Override
public boolean isSecondDimensionAdjustmentSupported() {
return true;
}
@Override
public boolean isCoordinatesSupported() {
return false;
}
};
static class DirectionCalculator {
private static final int NW = 0;
private static final int N = 1;
private static final int NE = 2;
private static final int SE = 3;
private static final int S = 4;
private static final int SW = 5;
/**
* Given delta movement on the X and Y axes, determine which direction that would be for the current grid type.
* Note that horizontal and vertical hex grids will be different.
*
* @param dirx
* movement on the X axis
* @param diry
* movement on the Y axis
* @return direction being moved
*/
public int getDirection(int dirx, int diry) {
int direction = -1;
// @formatter:off
if (dirx > 0 && diry > 0) direction = NW;
if (dirx > 0 && diry < 0) direction = SW;
if (dirx < 0 && diry > 0) direction = NE;
if (dirx < 0 && diry < 0) direction = SE;
if (dirx == 0 && diry > 0) direction = N;
if (dirx == 0 && diry < 0) direction = S;
// @formatter:on
return direction;
}
/**
* Given a particular direction returns the opposite direction. Used for finding the "pie slice" on the 'other
* side' of the hex grid cell.
*
* @param dir
* one of the constants <code>DirectionCalculator.NW</code> .. <code>DirectionCalculator.SW</code>
* (0..5)
* @return
*/
public int oppositeDirection(int dir) {
return (dir + 3) % 6;
}
/**
* <div style="float: left">Image of a <i>vertical hex</i> grid:<br>
* <img src="doc-files/HexGridVertical.png" title="Vertical Hex"> </div> <div>
* <p>
* Returns a {@link Shape} that can be used to test for exposed fog areas in the direction specified by
* <code>dir</code>. Note that <code>dir</code> is the direction from which a token is entering a grid cell. So
* if the token is coming from the North, <code>dir</code> should be Direction.Calculator.N (i.e. "2"). The
* resulting Shape returned would be the isosceles triangle that represents one-sixth of the hex in an upward
* direction. The returned Shape has its origin at the center of the hex grid cell.
*
* @param dir
* direction that the movement is coming from
* @return a {@link Shape} representing one slice of the 6-slice pie </div>
*/
public Shape getFogAreaToCheck(int dir) {
// pieSlices = null; // debugging -- forces the following IF statement to always be true
if (pieSlices == null) {
double coords[][] = {
{ 0, 0, -114, 0, -57, -100 }, // NW
{ 0, 0, -57, -100, 57, -100 }, // N
{ 0, 0, 57, -100, 114, 0 }, // NE
{ 0, 0, 114, 0, 57, 100 }, // SE
{ 0, 0, 57, 100, -57, 100 }, // S
{ 0, 0, -57, 100, -114, 0 }, // SW
};
pieSlices = new Shape[6];
for (int i = 0; i < 6; i++) {
double row[] = coords[i];
GeneralPath slice = new GeneralPath();
slice.moveTo(row[0], row[1]);
slice.lineTo(row[2], row[3]);
slice.lineTo(row[4], row[5]);
pieSlices[i] = slice;
}
}
return pieSlices[dir];
}
private Shape[] pieSlices = null;
}
protected static BufferedImage pathHighlight;
/** minorRadius / edgeLength */
private double hexRatio = REGULAR_HEX_RATIO;
/** One-half the length of an edge. Set to sqrt(edgeLength^2 - minorRadius^2), i.e. one side of a right triangle. */
private double edgeProjection;
/** Distance from centerpoint to middle of a face. Set to gridSize/2. */
private double minorRadius;
/**
* Distance from centerpoint to vertex. Set to minorRadius/hexRatio (basically, uses 30� cosine to calculate
* sqrt(3)/2).
*/
private double edgeLength;
// Hex defining variables scaled for zoom
private double scaledEdgeProjection;
private double scaledMinorRadius;
private double scaledEdgeLength;
/** Cached value from the last request to scale the hex grid */
private double lastScale = -1;
/** Cached value of the hex shape using <code>lastScale</code> */
private transient GeneralPath scaledHex;
/**
* The offset required to translate from the center of a cell to the top right (x_min, y_min) of the cell's bounding
* rectangle.
*/
private Dimension cellOffset;
public HexGrid() {
super();
}
@Override
protected Area createCellShape(int size) {
// don't use size. it has already been used to set the minorRadius
// and will only introduce a rounding error.
return new Area(createShape(minorRadius, edgeProjection, edgeLength));
}
@Override
public Rectangle getBounds(CellPoint cp) {
// This is naive, but, give it a try
ZonePoint zp = convert(cp);
Shape shape = getCellShape();
int w = shape.getBounds().width;
int h = shape.getBounds().height;
zp.x -= w / 2 + getOffsetX();
zp.y -= h / 2 + getOffsetY();
// System.out.println(new Rectangle(zp.x, zp.y, w, h));
return new Rectangle(zp.x, zp.y, w, h);
}
/**
* @return Distance from the center to edge of a hex
*/
public double getVRadius() {
return minorRadius;
}
/**
* @return Distance from the center to vertex of a hex
*/
public double getURadius() {
return edgeLength / 2 + edgeProjection;
}
@Override
public Dimension getCellOffset() {
return cellOffset;
}
/**
* A generic form of getCellOffset() where V is the axis of edge to edge hexes.
*
* @return The offset required to translate from the center of a cell to the least edge (v_min)
*/
public double getCellOffsetV() {
return -getVRadius();
}
/**
* A generic form of getCellOffset() where U is the axis perpendicular to the line of edge to edge hexes.
*
* @return The offset required to translate from the center of a cell to the least vertex (u_min)
*/
public double getCellOffsetU() {
return -getURadius();
}
/**
* The offset required to translate from the center of a cell to the top right (x_min, y_min) of the cell's bounding
* rectangle.
*/
protected abstract Dimension setCellOffset();
@Override
public void setSize(int size) {
if (hexRatio == 0) {
hexRatio = REGULAR_HEX_RATIO;
}
// Using size as the edge-to-edge distance or
// minor diameter of the hex.
size = constrainSize(size);
minorRadius = (double) size / 2;
edgeLength = minorRadius / hexRatio;
// edgeProjection = Math.sqrt(edgeLength * edgeLength - minorRadius * minorRadius); // Pythagorus
edgeProjection = edgeLength / 2; // It's an isosceles triangle, after all!
scaledHex = null;
// Cell offset gives the offset to apply to the cell zone coords to draw images/tokens
cellOffset = setCellOffset();
super.setSize(size);
}
protected void createShape(double scale) {
if (lastScale == scale && scaledHex != null) {
return;
}
scaledMinorRadius = minorRadius * scale;
scaledEdgeLength = edgeLength * scale;
scaledEdgeProjection = edgeProjection * scale;
scaledHex = createHalfShape(scaledMinorRadius, scaledEdgeProjection, scaledEdgeLength);
lastScale = scale;
}
private GeneralPath createShape(double minorRadius, double edgeProjection, double edgeLength) {
GeneralPath hex = new GeneralPath();
hex.moveTo(0, (int) minorRadius);
hex.lineTo((int) edgeProjection, 0);
hex.lineTo((int) (edgeProjection + edgeLength), 0);
hex.lineTo((int) (edgeProjection + edgeLength + edgeProjection), (int) minorRadius);
hex.lineTo((int) (edgeProjection + edgeLength), (int) (minorRadius * 2));
hex.lineTo((int) (edgeProjection), (int) (minorRadius * 2));
orientHex(hex);
return hex;
}
private GeneralPath createHalfShape(double minorRadius, double edgeProjection, double edgeLength) {
GeneralPath hex = new GeneralPath();
hex.moveTo(0, (int) minorRadius);
hex.lineTo((int) edgeProjection, 0);
hex.lineTo((int) (edgeProjection + edgeLength), 0);
hex.lineTo((int) (edgeProjection + edgeLength + edgeProjection), (int) minorRadius);
orientHex(hex);
return hex;
}
/*
* (non-Javadoc)
*
* @see com.t3.model.Grid#validateMove(java.awt.Rectangle, int, int, java.awt.geom.Area)
*/
@Override
public boolean validateMove(Token token, Rectangle areaToCheck, int dirx, int diry, Area exposedFog) {
// For a hex grid, we calculate the center of the areaToCheck and use that to calculate the CellPoint.
ZonePoint actual = new ZonePoint(areaToCheck.x + areaToCheck.width / 2, areaToCheck.y + areaToCheck.height / 2);
// The first step is to check the center of the destination hex; if it's not in the exposed fog, there's no reason to check
// the rest of the pieces since we can just return false right away.
if (!token.isSnapToGrid()) {
// If we're not SnapToGrid, use the actual mouse coordinates
return exposedFog.contains(actual.x, actual.y);
}
// If we are SnapToGrid, round off the position and check that instead.
CellPoint cp = convertZP(actual.x, actual.y);
if (cp.x == 3 && cp.y == 0)
cp.y = 0; // hook for setting breakpoint in debugger while testing
ZonePoint snappedZP = convertCP(cp.x, cp.y);
if (!exposedFog.contains(snappedZP.x, snappedZP.y))
return false;
// The next step is to check the triangle that covers the hex face we are leaving from and teh one we
// are entering through to see if either contain any fog. If they do, the movement is disallowed.
int direction = calculator.getDirection(dirx, diry);
if (direction < DirectionCalculator.NW || direction > DirectionCalculator.SW) {
// we're not really moving so return 'true' -- it's a valid movement
// XXX When does this happen?
return true;
}
boolean result;
// can we move into the new cell?
result = checkOneSlice(snappedZP, direction, exposedFog);
// If this one is false, don't bother checking the other one...
if (!result)
return false;
snappedZP.translate(-dirx, -diry);
cp = convert(snappedZP); // takes grid orientation and cellOffset into account
snappedZP = convert(cp);
result = checkOneSlice(snappedZP, calculator.oppositeDirection(direction), exposedFog); // can we exit our own cell?
return result;
}
private boolean checkOneSlice(ZonePoint zp, int dir, Area exposedFog) {
Shape s = calculator.getFogAreaToCheck(dir);
// The resulting Shape is 4x larger than it should be. Use a transform to correct it.
AffineTransform af = new AffineTransform();
af.translate(zp.x, zp.y);
af.scale(minorRadius / 100, minorRadius / 100);
Area transformed = new Area(af.createTransformedShape(s));
// Create an Area based on the pie slice, then calculate the intersection with the exposed area. If the result
// is exactly the same as the original pie slice, then the entire slice must have been contained with the
// exposed area. That means it's fine for a token to move into the grid cell. Whew. ;-)
Area a = new Area(transformed);
a.intersect(exposedFog);
return a.equals(transformed);
}
/**
* Default orientation is for a vertical hex grid Override for other orientations
*
* @param hex
*/
protected void orientHex(GeneralPath hex) {
return;
}
@Override
public GridCapabilities getCapabilities() {
return GRID_CAPABILITIES;
}
@Override
public int getTokenSpace() {
return (int) (getVRadius() * 2);
}
protected abstract void setGridDrawTranslation(Graphics2D g, double u, double v);
protected abstract double getRendererSizeU(ZoneRenderer renderer);
protected abstract double getRendererSizeV(ZoneRenderer renderer);
protected abstract int getOffV(ZoneRenderer renderer);
protected abstract int getOffU(ZoneRenderer renderer);
@Override
public void draw(ZoneRenderer renderer, Graphics2D g, Rectangle bounds) {
createShape(renderer.getScale());
int offU = getOffU(renderer);
int offV = getOffV(renderer);
int count = 0;
Object oldAntiAlias = SwingUtil.useAntiAliasing(g);
g.setColor(new Color(getZone().getGridColor()));
g.setStroke(new BasicStroke(AppState.getGridSize()));
for (double v = offV % (scaledMinorRadius * 2) - (scaledMinorRadius * 2); v < getRendererSizeV(renderer); v += scaledMinorRadius) {
double offsetU = (int) (count % 2 == 0 ? 0 : -(scaledEdgeProjection + scaledEdgeLength));
count++;
double start = offU % (2 * scaledEdgeLength + 2 * scaledEdgeProjection) - (2 * scaledEdgeLength + 2 * scaledEdgeProjection);
double end = getRendererSizeU(renderer) + 2 * scaledEdgeLength + 2 * scaledEdgeProjection;
double incr = 2 * scaledEdgeLength + 2 * scaledEdgeProjection;
for (double u = start; u < end; u += incr) {
setGridDrawTranslation(g, u + offsetU, v);
g.draw(scaledHex);
setGridDrawTranslation(g, -(u + offsetU), -v);
}
}
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, oldAntiAlias);
}
/**
* Generic form of getOffsetX for ease of transforming to other grid orientations.
*
* @return The U component of the grid's offset.
*/
protected abstract int getOffsetU();
/**
* Generic form of getOffsetY for ease of transforming to other grid orientations.
*
* @return The V component of the grid's offset.
*/
protected abstract int getOffsetV();
/**
* A method used by HexGrid.convert(ZonePoint zp) to allow for alternate grid orientations
*
* @return Coordinates in Cell-space of the ZonePoint
*/
protected CellPoint convertZP(int zpU, int zpV) {
int xSect;
int ySect;
int offsetZpU = zpU - getOffsetU();
int offsetZpV = zpV - getOffsetV();
if (offsetZpU < 0) {
xSect = (int) (offsetZpU / (edgeProjection + edgeLength)) - 1;
} else {
xSect = (int) (offsetZpU / (edgeProjection + edgeLength));
}
if (offsetZpV < 0) {
if (Math.abs(xSect) % 2 == 1)
ySect = (int) ((offsetZpV - minorRadius) / (2 * minorRadius)) - 1;
else
ySect = (int) (offsetZpV / (2 * minorRadius)) - 1;
} else {
if (Math.abs(xSect) % 2 == 1)
ySect = (int) ((offsetZpV - minorRadius) / (2 * minorRadius));
else
ySect = (int) (offsetZpV / (2 * minorRadius));
}
int xPxl = Math.abs((int) (offsetZpU - xSect * (edgeProjection + edgeLength)));
int yPxl = Math.abs((int) (offsetZpV - ySect * (2 * minorRadius)));
int gridX = xSect;
int gridY = ySect;
double m = edgeProjection / minorRadius;
// System.out.format("gx:%d gy:%d px:%d py:%d m:%f\n", xSect, ySect, xPxl, yPxl, m);
// System.out.format("gx:%d gy:%d px:%d py:%d\n", xSect, ySect, zp.x, zp.y);
switch (Math.abs(xSect) % 2) {
case 0:
if (yPxl <= minorRadius) {
if (xPxl < edgeProjection - yPxl * m) {
gridX = xSect - 1;
gridY = ySect - 1;
}
} else {
if (xPxl < (yPxl - minorRadius) * m) {
gridX = xSect - 1;
//gridY = ySect;
}
}
break;
case 1:
if (yPxl >= minorRadius) {
if (xPxl < (edgeProjection - (yPxl - minorRadius) * m)) {
gridX = xSect - 1;
//gridY = ySect;
} else {
//gridX = xSect;
//gridY = ySect;
}
} else {
if (xPxl < (yPxl * m)) {
gridX = xSect - 1;
//gridY = ySect;
} else {
//gridX = xSect;
gridY = ySect - 1;
}
}
break;
}
// System.out.format("gx:%d gy:%d\n", gridX, gridY);
return new CellPoint(gridX, gridY);
}
/**
* A method used by HexGrid.convert(CellPoint cp) to allow for alternate grid orientations
*
* @return A ZonePoint positioned at the center of the Hex
*/
protected ZonePoint convertCP(int cpU, int cpV) {
int u, v;
u = (int) Math.round(cpU * (edgeProjection + edgeLength) + edgeLength) + getOffsetU();
v = (int) (cpV * 2 * minorRadius + (Math.abs(cpU) % 2 == 0 ? 1 : 2) * minorRadius + getOffsetV());
return new ZonePoint(u, v);
}
@Override
public void setSecondDimension(double length) {
if (length < minorRadius * 2) {
hexRatio = REGULAR_HEX_RATIO;
} else {
// some linear algebra and a quadratic equation results in:
double aspectRatio = length / (2 * minorRadius);
double a = 0.75;
double c = -(aspectRatio * aspectRatio + 1) * minorRadius * minorRadius;
double b = minorRadius * aspectRatio;
edgeLength = (-b + Math.sqrt(b * b - 4 * a * c)) / (2 * a);
hexRatio = minorRadius / edgeLength;
}
}
@Override
public double getSecondDimension() {
return getURadius() * 2;
}
protected abstract OffsetTranslator getOffsetTranslator();
}