+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package org.mapeditor.view;
+
+import java.awt.AlphaComposite;
+import java.awt.Color;
+import java.awt.Composite;
+import java.awt.Graphics2D;
+import java.awt.Image;
+import java.awt.Point;
+import java.awt.RenderingHints;
+import java.awt.geom.AffineTransform;
+import java.awt.Shape;
+import java.awt.geom.Rectangle2D;
+
+import org.mapeditor.core.MapLayer;
+import org.mapeditor.core.MapObject;
+import org.mapeditor.core.Tile;
+import org.mapeditor.core.TileLayer;
+import org.mapeditor.core.TileOffset;
+import org.mapeditor.io.TMXMapReader;
+
+/**
+ * Base class for renderers that apply shared layer visibility/opacity rules.
+ */
+public abstract class AbstractRenderer implements MapRenderer {
+
+ /**
+ * Paints layer content while handling visibility and opacity consistently.
+ *
+ * @param g graphics context
+ * @param layer the layer being painted
+ * @param painter callback that performs the actual layer drawing
+ */
+ protected final void paintLayer(Graphics2D g, MapLayer layer, Runnable painter) {
+ if (Boolean.FALSE.equals(layer.isVisible())) {
+ return;
+ }
+
+ // Multiply with any alpha already set on the graphics, so opacity
+ // applied by a parent group carries through to this layer.
+ final Composite oldComposite = g.getComposite();
+ float baseOpacity = 1.0f;
+ if (oldComposite instanceof AlphaComposite) {
+ baseOpacity = ((AlphaComposite) oldComposite).getAlpha();
+ }
+ final float opacity = baseOpacity * getOpacity(layer);
+ if (opacity <= 0.0f) {
+ return;
+ }
+
+ if (opacity < 1.0f) {
+ g.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, opacity));
+ }
+ try {
+ painter.run();
+ } finally {
+ g.setComposite(oldComposite);
+ }
+ }
+
+ protected final Point getTileDrawLocation(TileLayer layer, Tile tile, int baseX, int baseY) {
+ return getTileDrawLocation(tile, baseX + getLayerOffsetX(layer), baseY + getLayerOffsetY(layer));
+ }
+
+ protected final Point getTileDrawLocation(Tile tile, int baseX, int baseY) {
+ int drawX = baseX;
+ int drawY = baseY;
+ final TileOffset tileOffset = tile.getTileSet() != null ? tile.getTileSet().getTileoffset() : null;
+ if (tileOffset != null) {
+ drawX += tileOffset.getX();
+ drawY += tileOffset.getY();
+ }
+
+ return new Point(drawX, drawY);
+ }
+
+ protected final void drawTileWithFlags(
+ Graphics2D g,
+ Image image,
+ int drawX,
+ int drawY,
+ int flags,
+ boolean hexagonalCells) {
+ if (image == null) {
+ return;
+ }
+
+ final int width = image.getWidth(null);
+ final int height = image.getHeight(null);
+ if (width <= 0 || height <= 0) {
+ return;
+ }
+
+ boolean flippedHorizontally = (flags & (int) TMXMapReader.FLIPPED_HORIZONTALLY_FLAG) != 0;
+ boolean flippedVertically = (flags & (int) TMXMapReader.FLIPPED_VERTICALLY_FLAG) != 0;
+ final boolean flippedDiagonally = (flags & (int) TMXMapReader.FLIPPED_DIAGONALLY_FLAG) != 0;
+ final boolean rotatedHexagonal120 = (flags & (int) TMXMapReader.ROTATED_HEXAGONAL_120_FLAG) != 0;
+
+ double rotationDegrees = 0.0;
+ double centerX = drawX + width / 2.0;
+ double centerY = drawY + height / 2.0;
+
+ if (hexagonalCells) {
+ if (flippedDiagonally) {
+ rotationDegrees += 60.0;
+ }
+ if (rotatedHexagonal120) {
+ rotationDegrees += 120.0;
+ }
+ } else if (flippedDiagonally) {
+ rotationDegrees = 90.0;
+
+ final boolean originalFlipH = flippedHorizontally;
+ flippedHorizontally = flippedVertically;
+ flippedVertically = !originalFlipH;
+
+ final double halfDiff = (height - width) / 2.0;
+ centerX += halfDiff;
+ centerY += halfDiff;
+ }
+
+ if (!flippedHorizontally && !flippedVertically && rotationDegrees == 0.0) {
+ g.drawImage(image, drawX, drawY, null);
+ return;
+ }
+
+ final AffineTransform oldTransform = g.getTransform();
+ final AffineTransform transform = new AffineTransform(oldTransform);
+ transform.translate(centerX, centerY);
+ if (rotationDegrees != 0.0) {
+ transform.rotate(Math.toRadians(rotationDegrees));
+ }
+ transform.scale(flippedHorizontally ? -1.0 : 1.0, flippedVertically ? -1.0 : 1.0);
+ transform.translate(-width / 2.0, -height / 2.0);
+
+ g.setTransform(transform);
+ try {
+ g.drawImage(image, 0, 0, null);
+ } finally {
+ g.setTransform(oldTransform);
+ }
+ }
+
+ protected final void paintObjectBounds(Graphics2D g, MapObject object, double objectX, double objectY) {
+ final Double objectWidth = object.getWidth();
+ final Double objectHeight = object.getHeight();
+
+ if (objectWidth == null || objectWidth == 0 || objectHeight == null || objectHeight == 0) {
+ g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+ g.setColor(Color.black);
+ g.fillOval((int) objectX + 1, (int) objectY + 1, 10, 10);
+ g.setColor(Color.orange);
+ g.fillOval((int) objectX, (int) objectY, 10, 10);
+ g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_OFF);
+ return;
+ }
+
+ g.setColor(Color.black);
+ g.drawRect((int) objectX + 1, (int) objectY + 1,
+ objectWidth.intValue(), objectHeight.intValue());
+ g.setColor(Color.orange);
+ g.drawRect((int) objectX, (int) objectY,
+ objectWidth.intValue(), objectHeight.intValue());
+ }
+
+ protected final void paintObjectShape(Graphics2D g, MapObject object, double objectX, double objectY) {
+ final Shape shape = object.getShape();
+ if (shape == null) {
+ paintObjectBounds(g, object, objectX, objectY);
+ return;
+ }
+
+ final Rectangle2D bounds = shape.getBounds2D();
+ if (bounds.getWidth() == 0.0 && bounds.getHeight() == 0.0) {
+ paintObjectBounds(g, object, objectX, objectY);
+ return;
+ }
+
+ final AffineTransform oldTransform = g.getTransform();
+ g.rotate(Math.toRadians(object.getRotation()), objectX, objectY);
+ try {
+ final Shape shadow = AffineTransform.getTranslateInstance(1.0, 1.0).createTransformedShape(shape);
+ g.setColor(Color.black);
+ g.draw(shadow);
+ g.setColor(Color.orange);
+ g.draw(shape);
+ } finally {
+ g.setTransform(oldTransform);
+ }
+ }
+
+ private int getLayerOffsetX(TileLayer layer) {
+ return layer.getOffsetX() != null ? (int) Math.round(layer.getOffsetX()) : 0;
+ }
+
+ private int getLayerOffsetY(TileLayer layer) {
+ return layer.getOffsetY() != null ? (int) Math.round(layer.getOffsetY()) : 0;
+ }
+
+ private float getOpacity(MapLayer layer) {
+ final Float opacity = layer.getOpacity();
+ if (opacity == null) {
+ return 1.0f;
+ }
+ return Math.max(0.0f, Math.min(1.0f, opacity));
+ }
+}
diff --git a/util/java/libtiled-java/src/main/java/org/mapeditor/view/HexagonalRenderer.java b/util/java/libtiled-java/src/main/java/org/mapeditor/view/HexagonalRenderer.java
index c0676e3b0c..b60bf7d5a8 100644
--- a/util/java/libtiled-java/src/main/java/org/mapeditor/view/HexagonalRenderer.java
+++ b/util/java/libtiled-java/src/main/java/org/mapeditor/view/HexagonalRenderer.java
@@ -8,13 +8,13 @@
* %%
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
- *
+ *
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
- *
+ *
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
@@ -30,383 +30,304 @@
*/
package org.mapeditor.view;
-import java.awt.Color;
import java.awt.Dimension;
import java.awt.Graphics2D;
import java.awt.Point;
import java.awt.Rectangle;
-import java.awt.RenderingHints;
+import java.util.List;
import org.mapeditor.core.Map;
import org.mapeditor.core.ObjectGroup;
+import org.mapeditor.core.StaggerAxis;
+import org.mapeditor.core.StaggerIndex;
import org.mapeditor.core.Tile;
import org.mapeditor.core.TileLayer;
import org.mapeditor.core.MapObject;
-import org.mapeditor.core.Polygon;
/**
- * A View for displaying Hex based maps. There are four possible layouts for the
- * hexes. These are called tile alignment and are named 'top', 'bottom', 'left'
- * and 'right'. The name designates the border where the first row or column of
- * hexes is aligned with a flat side. I.e. 'left' and 'right' result in hexes
- * with the pointy sides up and down and the first row either aligned left or
- * right:
- *
- *
- * /\
- * | |
- * \/
- *
- *
- * And 'top' and 'bottom' result in hexes with the pointy sides to the
- * left and right and the first column either aligned top or bottom:
- *
- *
- * __
- * / \
- * \__/
- *
- *
- * Here is an example 2x2 map with top alignment:
- *
- *
- * ___
- * /0,0\___
- * \___/1,0\
- * /0,1\___/
- * \___/1,1\
- * \___/
- *
- *
- * The icon width and height refer to the total width and height of a hex (i.e
- * the size of the enclosing rectangle).
+ * A renderer for hexagonal maps, matching the C++ hexagonalrenderer.cpp logic.
*
* @version 1.4.2
*/
-public class HexagonalRenderer implements MapRenderer {
+public class HexagonalRenderer extends AbstractRenderer {
- /** Constant ALIGN_TOP=1 */
+ /** Constant ALIGN_TOP=1 @deprecated Use StaggerAxis/StaggerIndex instead. */
+ @Deprecated
public static final int ALIGN_TOP = 1;
- /** Constant ALIGN_BOTTOM=2 */
+ /** Constant ALIGN_BOTTOM=2 @deprecated Use StaggerAxis/StaggerIndex instead. */
+ @Deprecated
public static final int ALIGN_BOTTOM = 2;
- /** Constant ALIGN_RIGHT=3 */
+ /** Constant ALIGN_RIGHT=3 @deprecated Use StaggerAxis/StaggerIndex instead. */
+ @Deprecated
public static final int ALIGN_RIGHT = 3;
- /** Constant ALIGN_LEFT=4 */
+ /** Constant ALIGN_LEFT=4 @deprecated Use StaggerAxis/StaggerIndex instead. */
+ @Deprecated
public static final int ALIGN_LEFT = 4;
+ private static final class Offset {
+ final int dx, dy;
+ Offset(int dx, int dy) { this.dx = dx; this.dy = dy; }
+ }
+
+ private static final List OFFSETS_STAGGER_X = List.of(
+ new Offset(0, 0), new Offset(1, -1), new Offset(1, 0), new Offset(2, 0));
+ private static final List OFFSETS_STAGGER_Y = List.of(
+ new Offset(0, 0), new Offset(-1, 1), new Offset(0, 1), new Offset(0, 2));
+
private final Map map;
- private final int mapAlignment;
- /* hexEdgesToTheLeft:
- * This means a layout like this: __
- * / \
- * \__/
- * as opposed to this: /\
- * | |
- * \/
+ /*
+ * staggerX == true (staggeraxis="x"): columns overlap, flat-top hexes
+ * __
+ * / \
+ * \__/
+ *
+ * staggerX == false (staggeraxis="y"): rows overlap, pointy-top hexes
+ * /\
+ * | |
+ * \/
+ */
+ private final boolean staggerX;
+
+ /*
+ * staggerEven == false (staggerindex="odd"):
+ * <0> <2> <- even: not shifted
+ * <1> <3> <- odd: shifted
+ *
+ * staggerEven == true (staggerindex="even"):
+ * <0> <2> <- even: shifted
+ * <1> <3> <- odd: not shifted
*/
- private boolean hexEdgesToTheLeft;
- private boolean alignedToBottomOrRight;
+ private final boolean staggerEven;
+
+ private final int sideOffsetX;
+ private final int sideOffsetY;
+ private final int columnWidth;
+ private final int rowHeight;
/**
- * Constructor for IsometricRenderer.
+ * Constructor for HexagonalRenderer.
*
* @param map a {@link org.mapeditor.core.Map} object.
*/
public HexagonalRenderer(Map map) {
this.map = map;
-
- mapAlignment = ALIGN_LEFT;
- hexEdgesToTheLeft = false;
- if (mapAlignment == ALIGN_TOP
- || mapAlignment == ALIGN_BOTTOM) {
- hexEdgesToTheLeft = true;
- }
- alignedToBottomOrRight = false;
- if (mapAlignment == ALIGN_BOTTOM
- || mapAlignment == ALIGN_RIGHT) {
- alignedToBottomOrRight = true;
+ staggerX = map.getStaggerAxis() == StaggerAxis.X;
+ staggerEven = map.getStaggerIndex() == StaggerIndex.EVEN;
+
+ Integer hexSide = map.getHexSideLength();
+ int sideLengthX = 0, sideLengthY = 0;
+ if (hexSide != null) {
+ if (staggerX) sideLengthX = hexSide;
+ else sideLengthY = hexSide;
}
+ sideOffsetX = (map.getTileWidth() - sideLengthX) / 2;
+ sideOffsetY = (map.getTileHeight() - sideLengthY) / 2;
+ columnWidth = sideOffsetX + sideLengthX;
+ rowHeight = sideOffsetY + sideLengthY;
+ }
+
+ private boolean doStaggerX(int x) {
+ return staggerX && ((x & 1) == 0) == staggerEven;
+ }
+
+ private boolean doStaggerY(int y) {
+ return !staggerX && ((y & 1) == 0) == staggerEven;
}
/** {@inheritDoc} */
@Override
public Dimension getMapSize() {
- Dimension tsize = getEffectiveMapTileSize();
- int w;
- int h;
- int tq = getThreeQuarterHex(tsize);
- int oq = getOneQuarterHex(tsize);
-
- if (hexEdgesToTheLeft) {
- w = map.getWidth() * tq + oq;
- h = map.getHeight() * tsize.height + (int) (tsize.height / 2 + 0.49);
+ int w, h;
+ if (staggerX) {
+ w = map.getWidth() * columnWidth + sideOffsetX;
+ h = map.getHeight() * rowHeight * 2;
+ if (map.getWidth() > 1) {
+ h += rowHeight;
+ }
} else {
- w = map.getWidth() * tsize.width + (int) (tsize.width / 2 + 0.49);
- h = map.getHeight() * tq + oq;
+ w = map.getWidth() * columnWidth * 2;
+ h = map.getHeight() * rowHeight + sideOffsetY;
+ if (map.getHeight() > 1) {
+ w += columnWidth;
+ }
}
-
return new Dimension(w, h);
}
/** {@inheritDoc} */
@Override
public void paintTileLayer(Graphics2D g, TileLayer layer) {
- // Determine area to draw from clipping rectangle
- Dimension tsize = getEffectiveMapTileSize();
-
- Rectangle clipRect = g.getClipBounds();
-
- Point topLeft = screenToTileCoords(
- layer, (int) clipRect.getMinX(), (int) clipRect.getMinY());
- Point bottomRight = screenToTileCoords(
- layer, (int) clipRect.getMaxX(), (int) clipRect.getMaxY());
- int startX = (int) topLeft.getX();
- int startY = (int) topLeft.getY();
- int endX = (int) (bottomRight.getX());
- int endY = (int) (bottomRight.getY());
- if (startX < 0) {
- startX = 0;
- }
- if (startY < 0) {
- startY = 0;
- }
- if (endX >= map.getWidth()) {
- endX = map.getWidth() - 1;
- }
- if (endY >= map.getHeight()) {
- endY = map.getHeight() - 1;
- }
+ paintLayer(g, layer, () -> {
+ if (rowHeight <= 0 || columnWidth <= 0) {
+ return;
+ }
- Polygon gridPoly;
- double gx;
- double gy;
- for (int y = startY; y <= endY; y++) {
- for (int x = startX; x <= endX; x++) {
- Tile t = layer.getTileAt(x, y);
-
- if (t != null) {
- Point screenCoords = getTopLeftCornerOfTile(tsize, x, y);
- gx = screenCoords.getX();
- gy = screenCoords.getY();
- g.drawImage(t.getImage(), (int) gx, (int) gy, null);
+ Rectangle clipRect = g.getClipBounds();
+
+ Point topLeft = screenToTileCoords(
+ (int) clipRect.getMinX(), (int) clipRect.getMinY());
+ Point bottomRight = screenToTileCoords(
+ (int) clipRect.getMaxX(), (int) clipRect.getMaxY());
+
+ int startX = Math.max(0, topLeft.x - 1);
+ int startY = Math.max(0, topLeft.y - 1);
+ int endX = Math.min(bottomRight.x + 1, map.getWidth() - 1);
+ int endY = Math.min(bottomRight.y + 1, map.getHeight() - 1);
+
+ for (int y = startY; y <= endY; y++) {
+ for (int x = startX; x <= endX; x++) {
+ Tile t = layer.getTileAt(x, y);
+
+ if (t != null) {
+ java.awt.Image image = t.getImage();
+ if (image == null) {
+ continue;
+ }
+ Point screenCoords = getTopLeftCornerOfTile(x, y);
+ // Apply layer offset and tileset tileoffset, and
+ // bottom-align tiles taller than the cell.
+ Point drawLoc = getTileDrawLocation(layer, t,
+ screenCoords.x,
+ screenCoords.y + map.getTileHeight() - image.getHeight(null));
+ drawTileWithFlags(
+ g,
+ image,
+ drawLoc.x,
+ drawLoc.y,
+ layer.getFlagsAt(x, y),
+ true);
+ }
}
}
- }
+ });
}
/** {@inheritDoc} */
@Override
public void paintObjectGroup(Graphics2D g, ObjectGroup group) {
- // NOTE: Direct copy from OrthoMapView (candidate for generalization)
- for (MapObject mo : group) {
- double ox = mo.getX();
- double oy = mo.getY();
-
- if (mo.getWidth() == 0 || mo.getHeight() == 0) {
- g.setRenderingHint(
- RenderingHints.KEY_ANTIALIASING,
- RenderingHints.VALUE_ANTIALIAS_ON);
- g.setColor(Color.black);
- g.fillOval((int) ox + 1, (int) oy + 1,
- 10, 10);
- g.setColor(Color.orange);
- g.fillOval((int) ox, (int) oy,
- 10, 10);
- g.setRenderingHint(
- RenderingHints.KEY_ANTIALIASING,
- RenderingHints.VALUE_ANTIALIAS_OFF);
- } else {
- g.setColor(Color.black);
- g.drawRect((int) ox + 1, (int) oy + 1,
- mo.getWidth().intValue(),
- mo.getHeight().intValue());
- g.setColor(Color.orange);
- g.drawRect((int) ox, (int) oy,
- mo.getWidth().intValue(),
- mo.getHeight().intValue());
+ paintLayer(g, group, () -> {
+ for (MapObject mo : group) {
+ paintObjectShape(g, mo, mo.getX(), mo.getY());
}
- }
+ });
}
/**
- * @return The tile size in the view without border as Dimension.
+ * Get the point at the top left corner of the bounding rectangle of this
+ * hex. Matches the C++ tileToScreenCoords logic.
*/
- private Dimension getEffectiveMapTileSize() {
- return new Dimension((int) (map.getTileWidth() + 0.999),
- (int) (map.getTileHeight() + 0.999));
- }
+ private Point getTopLeftCornerOfTile(int x, int y) {
+ int pixelX, pixelY;
- /**
- * Together with getOneQuarterHex this gives the sizes of one and three
- * quarters in pixels in the interesting dimension. If the layout is such
- * that the hex edges point left and right the interesting dimension is the
- * width, otherwise it is the height. The sum of one and three quarters
- * equals always the total size of the hex in this dimension.
- *
- * @return Three quarter of the tile size width or height (see above) as
- * integer.
- */
- private int getThreeQuarterHex(Dimension tileDimension) {
- int tq;
- if (hexEdgesToTheLeft) {
- tq = (int) (tileDimension.width * 3.0 / 4.0 + 0.49);
+ if (staggerX) {
+ pixelY = y * rowHeight * 2;
+ if (doStaggerX(x)) {
+ pixelY += rowHeight;
+ }
+ pixelX = x * columnWidth;
} else {
- tq = (int) (tileDimension.height * 3.0 / 4.0 + 0.49);
+ pixelX = x * columnWidth * 2;
+ if (doStaggerY(y)) {
+ pixelX += columnWidth;
+ }
+ pixelY = y * rowHeight;
}
- return tq;
+ return new Point(pixelX, pixelY);
}
/**
- * Together with getThreeQuarterHex this gives the sizes of one and three
- * quarters in pixels in the interesting dimension. If the layout is such
- * that the hex edges point left and right the interesting dimension is the
- * width, otherwise it is the height. The sum of one and three quarters
- * equals always the total size of the hex in this dimension.
+ * Returns the centre of the hex in screen coordinates.
*
- * @return One quarter of the tile size width or height (see above) as
- * integer.
+ * @param x The x coordinate of the tile.
+ * @param y The y coordinate of the tile.
+ * @return The point at the centre of the Hex as Point.
*/
- private int getOneQuarterHex(Dimension tileDimension) {
- int oq;
- if (hexEdgesToTheLeft) {
- oq = tileDimension.width;
- } else {
- oq = tileDimension.height;
- }
-
- return oq - getThreeQuarterHex(tileDimension);
+ public Point tileToScreenCoords(int x, int y) {
+ Point p = getTopLeftCornerOfTile(x, y);
+ return new Point(
+ p.x + (columnWidth + sideOffsetX) / 2,
+ p.y + (rowHeight + sideOffsetY) / 2);
}
/**
- * Compute the resulting tile coords, i.e. map coordinates, from a point in
- * the viewport. This function works for some coords off the map, i.e. it
- * works for the tile coord -1 and for coords larger than the map size.
+ * Converts screen to tile coordinates using the C++ algorithm with four
+ * candidate hex centres.
*
- * @param layer a {@link org.mapeditor.core.TileLayer} object.
* @param screenX The x coordinate of a point in the viewport.
* @param screenY The y coordinate of a point in the viewport.
* @return The corresponding tile coords as Point.
*/
- public Point screenToTileCoords(TileLayer layer, int screenX, int screenY) {
- Dimension tileSize = getEffectiveMapTileSize();
- int tileWidth = tileSize.width;
- int tileHeight = tileSize.height;
- int hWidth = (int) (tileWidth / 2 + 0.49);
- int hHeight = (int) (tileHeight / 2 + 0.49);
- Point[] fourPoints = new Point[4];
- Point[] fourTiles = new Point[4];
-
- final int x = screenX;
- final int y = screenY;
-
- // determine the two columns of hexes we are between
- // we are between col and col+1.
- // col == -1 means we are in the strip to the left
- // of the centers of the hexes of column 0.
- int col;
- if (x < hWidth) {
- col = -1;
- } else {
- if (hexEdgesToTheLeft) {
- col = (int) ((x - hWidth)
- / (double) getThreeQuarterHex(tileSize) + 0.001);
- } else {
- col = (int) ((x - hWidth) / (double) tileWidth + 0.001);
- }
+ public Point screenToTileCoords(int screenX, int screenY) {
+ if (columnWidth <= 0 || rowHeight <= 0) {
+ return new Point(0, 0);
}
- // determine the two rows of hexes we are between
- int row;
- if (y < hHeight) {
- row = -1;
+ double x = screenX;
+ double y = screenY;
+
+ int tileW = columnWidth + sideOffsetX;
+ int tileH = rowHeight + sideOffsetY;
+
+ if (staggerX) {
+ x -= staggerEven ? tileW : sideOffsetX;
} else {
- if (hexEdgesToTheLeft) {
- row = (int) ((y - hHeight) / (double) tileHeight + 0.001);
- } else {
- row = (int) ((y - hHeight)
- / (double) getThreeQuarterHex(tileSize) + 0.001);
- }
+ y -= staggerEven ? tileH : sideOffsetY;
}
- // now take the four surrounding points and
- // find the one having the minimum distance to x,y
- fourTiles[0] = new Point(col, row);
- fourTiles[1] = new Point(col, row + 1);
- fourTiles[2] = new Point(col + 1, row);
- fourTiles[3] = new Point(col + 1, row + 1);
-
- fourPoints[0] = tileToScreenCoords(tileSize, col, row);
- fourPoints[1] = tileToScreenCoords(tileSize, col, row + 1);
- fourPoints[2] = tileToScreenCoords(tileSize, col + 1, row);
- fourPoints[3] = tileToScreenCoords(tileSize, col + 1, row + 1);
-
- // find point with min.distance
- double minDist = 2 * (map.getTileWidth() + map.getTileHeight());
- int minI = 5;
- for (int i = 0; i < fourPoints.length; i++) {
- if (fourPoints[i].distance(x, y) < minDist) {
- minDist = fourPoints[i].distance(x, y);
- minI = i;
- }
- }
+ // Start with the coordinates of a grid-aligned tile
+ int refX = (int) Math.floor(x / (columnWidth * 2));
+ int refY = (int) Math.floor(y / (rowHeight * 2));
- // get min point
- int tx = (int) (fourTiles[minI].getX());
- int ty = (int) (fourTiles[minI].getY());
+ // Relative x and y position on the base square of the grid-aligned tile
+ double relX = x - refX * (columnWidth * 2.0);
+ double relY = y - refY * (rowHeight * 2.0);
- return new Point(tx, ty);
- }
+ // Adjust the reference point to the correct tile coordinates
+ if (staggerX) {
+ refX = refX * 2 + (staggerEven ? 1 : 0);
+ } else {
+ refY = refY * 2 + (staggerEven ? 1 : 0);
+ }
- /**
- * Returns the location (center) on screen for the given tile. Works also
- * for hypothetical tiles off the map. The zoom is accounted for.
- *
- * @param tileSize a {@link java.awt.Dimension} object.
- * @param x The x coordinate of the tile.
- * @param y The y coordinate of the tile.
- * @return The point at the centre of the Hex as Point.
- */
- public Point tileToScreenCoords(Dimension tileSize, int x, int y) {
- Point p = getTopLeftCornerOfTile(tileSize, x, y);
- return new Point(
- (int) (p.getX()) + (int) (tileSize.width / 2 + 0.49),
- (int) (p.getY()) + (int) (tileSize.height / 2 + 0.49));
- }
+ // Determine the nearest hexagon tile by the distance to the center
+ double[] centersX = new double[4];
+ double[] centersY = new double[4];
- /**
- * Get the point at the top left corner of the bounding rectangle of this
- * hex.
- *
- * @param x The x coordinate of the tile.
- * @param y The y coordinate of the tile.
- *
- * @return The top left corner of the enclosing rectangle of the hex in
- * screen coordinates as Point.
- */
- private Point getTopLeftCornerOfTile(Dimension tileSize, int x, int y) {
- int w = tileSize.width;
- int h = tileSize.height;
- int xx;
- int yy;
-
- if (hexEdgesToTheLeft) {
- xx = x * getThreeQuarterHex(tileSize);
- yy = y * h;
+ if (staggerX) {
+ double left = columnWidth - sideOffsetX;
+ double centerX = left + columnWidth;
+ double centerY = tileH / 2.0;
+
+ centersX[0] = left; centersY[0] = centerY;
+ centersX[1] = centerX; centersY[1] = centerY - rowHeight;
+ centersX[2] = centerX; centersY[2] = centerY + rowHeight;
+ centersX[3] = centerX + columnWidth; centersY[3] = centerY;
} else {
- xx = x * w;
- yy = y * getThreeQuarterHex(tileSize);
+ double top = rowHeight - sideOffsetY;
+ double centerX = tileW / 2.0;
+ double centerY = top + rowHeight;
+
+ centersX[0] = centerX; centersY[0] = top;
+ centersX[1] = centerX - columnWidth; centersY[1] = centerY;
+ centersX[2] = centerX + columnWidth; centersY[2] = centerY;
+ centersX[3] = centerX; centersY[3] = centerY + rowHeight;
}
- if ((Math.abs(x % 2) == 1 && mapAlignment == ALIGN_TOP)
- || (x % 2 == 0 && mapAlignment == ALIGN_BOTTOM)) {
- yy += (int) (h / 2.0 + 0.49);
- }
- if ((Math.abs(y % 2) == 1 && mapAlignment == ALIGN_LEFT)
- || (y % 2 == 0 && mapAlignment == ALIGN_RIGHT)) {
- xx += (int) (w / 2.0 + 0.49);
+ int nearest = 0;
+ double minDist = Double.MAX_VALUE;
+ for (int i = 0; i < 4; i++) {
+ double dx = centersX[i] - relX;
+ double dy = centersY[i] - relY;
+ double dist = dx * dx + dy * dy;
+ if (dist < minDist) {
+ minDist = dist;
+ nearest = i;
+ }
}
- return new Point(xx, yy);
+
+ Offset offset = (staggerX ? OFFSETS_STAGGER_X : OFFSETS_STAGGER_Y).get(nearest);
+ return new Point(refX + offset.dx, refY + offset.dy);
}
}
diff --git a/util/java/libtiled-java/src/main/java/org/mapeditor/view/IsometricRenderer.java b/util/java/libtiled-java/src/main/java/org/mapeditor/view/IsometricRenderer.java
index 6c18c78d9f..a86606b549 100644
--- a/util/java/libtiled-java/src/main/java/org/mapeditor/view/IsometricRenderer.java
+++ b/util/java/libtiled-java/src/main/java/org/mapeditor/view/IsometricRenderer.java
@@ -46,7 +46,7 @@
*
* @version 1.4.2
*/
-public class IsometricRenderer implements MapRenderer {
+public class IsometricRenderer extends AbstractRenderer {
private final Map map;
@@ -71,77 +71,82 @@ public Dimension getMapSize() {
/** {@inheritDoc} */
@Override
public void paintTileLayer(Graphics2D g, TileLayer layer) {
- final Rectangle clip = g.getClipBounds();
- final int tileWidth = map.getTileWidth();
- final int tileHeight = map.getTileHeight();
-
- // Translate origin to top-center
- double tileRatio = (double) tileWidth / (double) tileHeight;
- clip.x -= map.getHeight() * (tileWidth / 2);
- int mx = clip.y + (int) (clip.x / tileRatio);
- int my = clip.y - (int) (clip.x / tileRatio);
-
- // Calculate map coords and divide by tile size (tiles assumed to
- // be square in normal projection)
- Point rowItr = new Point(
- (mx < 0 ? mx - tileHeight : mx) / tileHeight,
- (my < 0 ? my - tileHeight : my) / tileHeight);
- rowItr.x--;
-
- // Location on the screen of the top corner of a tile.
- int originX = (map.getHeight() * tileWidth) / 2;
- Point drawLoc = new Point(
- ((rowItr.x - rowItr.y) * tileWidth / 2) + originX,
- (rowItr.x + rowItr.y) * tileHeight / 2);
- drawLoc.x -= tileWidth / 2;
- drawLoc.y -= tileHeight / 2;
-
- // Add offset from tile layer property
- drawLoc.x += layer.getOffsetX() != null ? layer.getOffsetX() : 0;
- drawLoc.y += layer.getOffsetY() != null ? layer.getOffsetY() : 0;
-
- // Determine area to draw from clipping rectangle
- int tileStepY = tileHeight / 2 == 0 ? 1 : tileHeight / 2;
- int columns = clip.width / tileWidth + 3;
- int rows = clip.height / tileStepY + 4;
-
- // Draw this map layer
- for (int y = 0; y < rows; y++) {
- Point columnItr = new Point(rowItr);
-
- for (int x = 0; x < columns; x++) {
- final Tile tile = layer.getTileAt(columnItr.x, columnItr.y);
-
- if (tile != null) {
- final BufferedImage image = tile.getImage();
- if (image == null) {
- continue;
+ paintLayer(g, layer, () -> {
+ final Rectangle clip = g.getClipBounds();
+ final int tileWidth = map.getTileWidth();
+ final int tileHeight = map.getTileHeight();
+
+ // Translate origin to top-center
+ double tileRatio = (double) tileWidth / (double) tileHeight;
+ clip.x -= map.getHeight() * (tileWidth / 2);
+ int mx = clip.y + (int) (clip.x / tileRatio);
+ int my = clip.y - (int) (clip.x / tileRatio);
+
+ // Calculate map coords and divide by tile size (tiles assumed to
+ // be square in normal projection)
+ Point rowItr = new Point(
+ (mx < 0 ? mx - tileHeight : mx) / tileHeight,
+ (my < 0 ? my - tileHeight : my) / tileHeight);
+ rowItr.x--;
+
+ // Location on the screen of the top corner of a tile.
+ int originX = (map.getHeight() * tileWidth) / 2;
+ Point drawLoc = new Point(
+ ((rowItr.x - rowItr.y) * tileWidth / 2) + originX,
+ (rowItr.x + rowItr.y) * tileHeight / 2);
+ drawLoc.x -= tileWidth / 2;
+ drawLoc.y -= tileHeight / 2;
+
+ // The layer offset is applied per tile by getTileDrawLocation.
+
+ // Determine area to draw from clipping rectangle
+ int tileStepY = tileHeight / 2 == 0 ? 1 : tileHeight / 2;
+ int columns = clip.width / tileWidth + 3;
+ int rows = clip.height / tileStepY + 4;
+
+ // Draw this map layer
+ for (int y = 0; y < rows; y++) {
+ Point columnItr = new Point(rowItr);
+
+ for (int x = 0; x < columns; x++) {
+ final Tile tile = layer.getTileAt(columnItr.x, columnItr.y);
+
+ if (tile != null) {
+ final BufferedImage image = tile.getImage();
+ if (image == null) {
+ continue;
+ }
+
+ // Tile offset is per-draw, so don't mutate the running cursor.
+ Point tileDrawLoc = getTileDrawLocation(
+ layer, tile, drawLoc.x, drawLoc.y + tileHeight - image.getHeight(null));
+ drawTileWithFlags(
+ g,
+ image,
+ tileDrawLoc.x,
+ tileDrawLoc.y,
+ layer.getFlagsAt(columnItr.x, columnItr.y),
+ false);
}
- // Add offset from tileset property
- drawLoc.x += tile.getTileSet().getTileoffset() != null ? tile.getTileSet().getTileoffset().getX() : 0;
- drawLoc.y += tile.getTileSet().getTileoffset() != null ? tile.getTileSet().getTileoffset().getY() : 0;
-
- g.drawImage(image, drawLoc.x, drawLoc.y, null);
+ // Advance to the next tile
+ columnItr.x++;
+ columnItr.y--;
+ drawLoc.x += tileWidth;
}
- // Advance to the next tile
- columnItr.x++;
- columnItr.y--;
- drawLoc.x += tileWidth;
- }
-
- // Advance to the next row
- if ((y & 1) > 0) {
- rowItr.x++;
- drawLoc.x += tileWidth / 2;
- } else {
- rowItr.y++;
- drawLoc.x -= tileWidth / 2;
+ // Advance to the next row
+ if ((y & 1) > 0) {
+ rowItr.x++;
+ drawLoc.x += tileWidth / 2;
+ } else {
+ rowItr.y++;
+ drawLoc.x -= tileWidth / 2;
+ }
+ drawLoc.x -= columns * tileWidth;
+ drawLoc.y += tileStepY;
}
- drawLoc.x -= columns * tileWidth;
- drawLoc.y += tileStepY;
- }
+ });
}
/** {@inheritDoc} */
diff --git a/util/java/libtiled-java/src/main/java/org/mapeditor/view/ObliqueRenderer.java b/util/java/libtiled-java/src/main/java/org/mapeditor/view/ObliqueRenderer.java
new file mode 100644
index 0000000000..e726db22bb
--- /dev/null
+++ b/util/java/libtiled-java/src/main/java/org/mapeditor/view/ObliqueRenderer.java
@@ -0,0 +1,192 @@
+/*-
+ * #%L
+ * This file is part of libtiled-java.
+ * %%
+ * Copyright (C) 2004 - 2020 Thorbjørn Lindeijer
+ * Copyright (C) 2004 - 2020 Adam Turk
+ * Copyright (C) 2016 - 2020 Mike Thomas
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package org.mapeditor.view;
+
+import java.awt.Dimension;
+import java.awt.Graphics2D;
+import java.awt.Image;
+import java.awt.Point;
+import java.awt.Rectangle;
+import java.awt.geom.AffineTransform;
+import java.awt.geom.NoninvertibleTransformException;
+import java.awt.geom.Point2D;
+
+import org.mapeditor.core.Map;
+import org.mapeditor.core.ObjectGroup;
+import org.mapeditor.core.Tile;
+import org.mapeditor.core.TileLayer;
+
+/**
+ * An oblique map renderer. Extends {@link OrthogonalRenderer} by applying
+ * a skew transformation to the coordinate system.
+ *
+ * Oblique maps use a skewed grid where the X and/or Y axis is projected
+ * diagonally, controlled by the map's skewX and skewY properties.
+ *
+ * @version 1.4.2
+ */
+public class ObliqueRenderer extends OrthogonalRenderer {
+
+ private final Map map;
+
+ public ObliqueRenderer(Map map) {
+ super(map);
+ this.map = map;
+ }
+
+ @Override
+ public void paintTileLayer(Graphics2D g, TileLayer layer) {
+ paintLayer(g, layer, () -> {
+ final int tileWidth = map.getTileWidth();
+ final int tileHeight = map.getTileHeight();
+ if (tileWidth <= 0 || tileHeight <= 0) {
+ return;
+ }
+
+ final AffineTransform transform = getTransform();
+ final AffineTransform oldTransform = g.getTransform();
+
+ final Rectangle clip = g.getClipBounds();
+ Rectangle pixelClip = clip;
+ try {
+ AffineTransform inv = transform.createInverse();
+ Point2D[] corners = {
+ new Point2D.Double(clip.getMinX(), clip.getMinY()),
+ new Point2D.Double(clip.getMaxX(), clip.getMinY()),
+ new Point2D.Double(clip.getMaxX(), clip.getMaxY()),
+ new Point2D.Double(clip.getMinX(), clip.getMaxY())
+ };
+ double minX = Double.MAX_VALUE, minY = Double.MAX_VALUE;
+ double maxX = -Double.MAX_VALUE, maxY = -Double.MAX_VALUE;
+ for (Point2D corner : corners) {
+ Point2D p = inv.transform(corner, null);
+ minX = Math.min(minX, p.getX());
+ minY = Math.min(minY, p.getY());
+ maxX = Math.max(maxX, p.getX());
+ maxY = Math.max(maxY, p.getY());
+ }
+ pixelClip = new Rectangle(
+ (int) Math.floor(minX), (int) Math.floor(minY),
+ (int) Math.ceil(maxX - minX), (int) Math.ceil(maxY - minY));
+ } catch (NoninvertibleTransformException e) {
+ // Fall back to the current clip when the transform is singular.
+ }
+
+ final Rectangle bounds = layer.getBounds();
+ final int startX = Math.max(bounds.x,
+ Math.floorDiv(pixelClip.x, tileWidth));
+ final int startY = Math.max(bounds.y,
+ Math.floorDiv(pixelClip.y, tileHeight));
+ final int endX = Math.min(bounds.x + bounds.width,
+ (int) Math.ceil(pixelClip.getMaxX() / tileWidth));
+ final int endY = Math.min(bounds.y + bounds.height,
+ (int) Math.ceil((pixelClip.getMaxY() + tileHeight) / tileHeight));
+
+ g.transform(transform);
+ try {
+ for (int tx = startX; tx < endX; ++tx) {
+ for (int ty = startY; ty < endY; ++ty) {
+ final Tile tile = layer.getTileAt(tx, ty);
+ if (tile == null) {
+ continue;
+ }
+ final Image image = tile.getImage();
+ if (image == null) {
+ continue;
+ }
+
+ Point drawLoc = getTileDrawLocation(
+ layer, tile, tx * tileWidth, (ty + 1) * tileHeight - image.getHeight(null));
+
+ drawTileWithFlags(g, image, drawLoc.x, drawLoc.y, layer.getFlagsAt(tx, ty), false);
+ }
+ }
+ } finally {
+ g.setTransform(oldTransform);
+ }
+ });
+ }
+
+ /**
+ * Objects live in the same skewed coordinate system as the tiles.
+ */
+ @Override
+ public void paintObjectGroup(Graphics2D g, ObjectGroup group) {
+ final AffineTransform oldTransform = g.getTransform();
+ g.transform(getTransform());
+ try {
+ super.paintObjectGroup(g, group);
+ } finally {
+ g.setTransform(oldTransform);
+ }
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public Dimension getMapSize() {
+ Dimension size = super.getMapSize();
+ final int tileWidth = map.getTileWidth();
+ final int tileHeight = map.getTileHeight();
+ if (tileWidth <= 0 || tileHeight <= 0) {
+ return size;
+ }
+
+ final double skewX = Math.abs(map.getSkewx() != null ? map.getSkewx() : 0);
+ final double skewY = Math.abs(map.getSkewy() != null ? map.getSkewy() : 0);
+ return new Dimension(
+ size.width + (int) Math.ceil(skewX / tileHeight * size.height),
+ size.height + (int) Math.ceil(skewY / tileWidth * size.width));
+ }
+
+ /**
+ * Builds the skew (shear) transform based on the map's skewX and skewY values.
+ */
+ private AffineTransform getTransform() {
+ final double tileWidth = map.getTileWidth();
+ final double tileHeight = map.getTileHeight();
+ if (tileWidth == 0 || tileHeight == 0) {
+ return new AffineTransform();
+ }
+
+ final double skewX = map.getSkewx() != null ? map.getSkewx() : 0;
+ final double skewY = map.getSkewy() != null ? map.getSkewy() : 0;
+
+ final double shearX = skewX / tileHeight;
+ final double shearY = skewY / tileWidth;
+
+ AffineTransform transform = new AffineTransform();
+ // AffineTransform.shear(shx, shy) applies:
+ // [ 1 shx ] [ x ]
+ // [ shy 1 ] [ y ]
+ transform.shear(shearX, shearY);
+ return transform;
+ }
+}
diff --git a/util/java/libtiled-java/src/main/java/org/mapeditor/view/OrthogonalRenderer.java b/util/java/libtiled-java/src/main/java/org/mapeditor/view/OrthogonalRenderer.java
index 75b12d8536..9d43076026 100644
--- a/util/java/libtiled-java/src/main/java/org/mapeditor/view/OrthogonalRenderer.java
+++ b/util/java/libtiled-java/src/main/java/org/mapeditor/view/OrthogonalRenderer.java
@@ -36,10 +36,11 @@
import java.awt.Image;
import java.awt.Point;
import java.awt.Rectangle;
-import java.awt.RenderingHints;
import java.awt.geom.AffineTransform;
+import org.mapeditor.core.Group;
import org.mapeditor.core.Map;
+import org.mapeditor.core.MapLayer;
import org.mapeditor.core.MapObject;
import org.mapeditor.core.ObjectGroup;
import org.mapeditor.core.Tile;
@@ -51,7 +52,7 @@
*
* @version 1.4.2
*/
-public class OrthogonalRenderer implements MapRenderer {
+public class OrthogonalRenderer extends AbstractRenderer {
private final Map map;
@@ -67,115 +68,110 @@ public OrthogonalRenderer(Map map) {
/** {@inheritDoc} */
@Override
public Dimension getMapSize() {
+ if (map.getInfinite() != null && map.getInfinite() == 1) {
+ Rectangle extent = new Rectangle(0, 0, map.getWidth(), map.getHeight());
+ addLayerBounds(map.getLayers(), extent);
+ return new Dimension(
+ extent.width * map.getTileWidth(),
+ extent.height * map.getTileHeight());
+ }
return new Dimension(
map.getWidth() * map.getTileWidth(),
map.getHeight() * map.getTileHeight());
}
+ /**
+ * Grows the extent to cover the bounds of all layers, including layers
+ * nested inside groups.
+ */
+ private static void addLayerBounds(java.util.List layers, Rectangle extent) {
+ for (MapLayer layer : layers) {
+ if (layer instanceof Group) {
+ addLayerBounds(((Group) layer).getLayers(), extent);
+ } else {
+ extent.add(layer.getBounds());
+ }
+ }
+ }
+
/** {@inheritDoc} */
@Override
public void paintTileLayer(Graphics2D g, TileLayer layer) {
- final Rectangle clip = g.getClipBounds();
- final int tileWidth = map.getTileWidth();
- final int tileHeight = map.getTileHeight();
- final Rectangle bounds = layer.getBounds();
-
- g.translate(bounds.x * tileWidth, bounds.y * tileHeight);
- clip.translate(-bounds.x * tileWidth, -bounds.y * tileHeight);
-
- clip.height += map.getTileHeightMax();
-
- final int startX = Math.max(0, clip.x / tileWidth);
- final int startY = Math.max(0, clip.y / tileHeight);
- final int endX = Math.min(layer.getWidth(),
- (int) Math.ceil(clip.getMaxX() / tileWidth));
- final int endY = Math.min(layer.getHeight(),
- (int) Math.ceil(clip.getMaxY() / tileHeight));
-
- for (int x = startX; x < endX; ++x) {
- for (int y = startY; y < endY; ++y) {
- final Tile tile = layer.getTileAt(x, y);
- if (tile == null) {
- continue;
- }
- final Image image = tile.getImage();
- if (image == null) {
- continue;
+ paintLayer(g, layer, () -> {
+ final Rectangle clip = g.getClipBounds();
+ final int tileWidth = map.getTileWidth();
+ final int tileHeight = map.getTileHeight();
+ final Rectangle bounds = layer.getBounds();
+
+ // Compute visible tile range in tile-space coordinates
+ final int startX = Math.max(bounds.x,
+ Math.floorDiv(clip.x, tileWidth));
+ final int startY = Math.max(bounds.y,
+ Math.floorDiv(clip.y, tileHeight));
+ final int endX = Math.min(bounds.x + bounds.width,
+ (int) Math.ceil(clip.getMaxX() / tileWidth));
+ final int endY = Math.min(bounds.y + bounds.height,
+ (int) Math.ceil((clip.getMaxY() + map.getTileHeightMax()) / tileHeight));
+
+ for (int tx = startX; tx < endX; ++tx) {
+ for (int ty = startY; ty < endY; ++ty) {
+ final Tile tile = layer.getTileAt(tx, ty);
+ if (tile == null) {
+ continue;
+ }
+ final Image image = tile.getImage();
+ if (image == null) {
+ continue;
+ }
+
+ Point drawLoc = getTileDrawLocation(
+ layer, tile, tx * tileWidth, (ty + 1) * tileHeight - image.getHeight(null));
+
+ drawTileWithFlags(g, image, drawLoc.x, drawLoc.y, layer.getFlagsAt(tx, ty), false);
}
-
- Point drawLoc = new Point(x * tileWidth, (y + 1) * tileHeight - image.getHeight(null));
-
- // Add offset from tile layer property
- drawLoc.x += layer.getOffsetX() != null ? layer.getOffsetX() : 0;
- drawLoc.y += layer.getOffsetY() != null ? layer.getOffsetY() : 0;
-
- // Add offset from tileset property
- drawLoc.x += tile.getTileSet().getTileoffset() != null ? tile.getTileSet().getTileoffset().getX() : 0;
- drawLoc.y += tile.getTileSet().getTileoffset() != null ? tile.getTileSet().getTileoffset().getY() : 0;
-
- g.drawImage(image, drawLoc.x, drawLoc.y, null);
}
- }
-
- g.translate(-bounds.x * tileWidth, -bounds.y * tileHeight);
+ });
}
/** {@inheritDoc} */
@Override
public void paintObjectGroup(Graphics2D g, ObjectGroup group) {
- final Dimension tsize = new Dimension(map.getTileWidth(), map.getTileHeight());
- assert tsize.width != 0 && tsize.height != 0;
- final Rectangle bounds = map.getBounds();
-
- g.translate(
- bounds.x * tsize.width,
- bounds.y * tsize.height);
-
- for (MapObject mo : group) {
- final double ox = mo.getX();
- final double oy = mo.getY();
- final Double objectWidth = mo.getWidth();
- final Double objectHeight = mo.getHeight();
- final double rotation = mo.getRotation();
- final Tile tile = mo.getTile();
-
- if (tile != null) {
- Image objectImage = tile.getImage();
- AffineTransform old = g.getTransform();
- g.rotate(Math.toRadians(rotation));
- g.drawImage(objectImage, (int) ox, (int) oy, null);
- g.setTransform(old);
- } else if (objectWidth == null || objectWidth == 0
- || objectHeight == null || objectHeight == 0) {
- g.setRenderingHint(
- RenderingHints.KEY_ANTIALIASING,
- RenderingHints.VALUE_ANTIALIAS_ON);
- g.setColor(Color.black);
- g.fillOval((int) ox + 1, (int) oy + 1, 10, 10);
- g.setColor(Color.orange);
- g.fillOval((int) ox, (int) oy, 10, 10);
- g.setRenderingHint(
- RenderingHints.KEY_ANTIALIASING,
- RenderingHints.VALUE_ANTIALIAS_OFF);
- } else {
- g.setColor(Color.black);
- g.drawRect((int) ox + 1, (int) oy + 1,
- mo.getWidth().intValue(),
- mo.getHeight().intValue());
- g.setColor(Color.orange);
- g.drawRect((int) ox, (int) oy,
- mo.getWidth().intValue(),
- mo.getHeight().intValue());
+ paintLayer(g, group, () -> {
+ final Dimension tsize = new Dimension(map.getTileWidth(), map.getTileHeight());
+ assert tsize.width != 0 && tsize.height != 0;
+ final Rectangle bounds = map.getBounds();
+
+ g.translate(
+ bounds.x * tsize.width,
+ bounds.y * tsize.height);
+ try {
+ for (MapObject mo : group) {
+ final double ox = mo.getX();
+ final double oy = mo.getY();
+ final double rotation = mo.getRotation();
+ final Tile tile = mo.getTile();
+
+ if (tile != null) {
+ Image objectImage = tile.getImage();
+ AffineTransform old = g.getTransform();
+ g.rotate(Math.toRadians(rotation), ox, oy);
+ Point drawLoc = getTileDrawLocation(tile, (int) ox, (int) oy - objectImage.getHeight(null));
+ g.drawImage(objectImage, drawLoc.x, drawLoc.y, null);
+ g.setTransform(old);
+ } else {
+ paintObjectShape(g, mo, ox, oy);
+ }
+ final String s = mo.getName() != null ? mo.getName() : "(null)";
+ g.setColor(Color.black);
+ g.drawString(s, (int) (ox - 5) + 1, (int) (oy - 5) + 1);
+ g.setColor(Color.white);
+ g.drawString(s, (int) (ox - 5), (int) (oy - 5));
+ }
+ } finally {
+ g.translate(
+ -bounds.x * tsize.width,
+ -bounds.y * tsize.height);
}
- final String s = mo.getName() != null ? mo.getName() : "(null)";
- g.setColor(Color.black);
- g.drawString(s, (int) (ox - 5) + 1, (int) (oy - 5) + 1);
- g.setColor(Color.white);
- g.drawString(s, (int) (ox - 5), (int) (oy - 5));
- }
-
- g.translate(
- -bounds.x * tsize.width,
- -bounds.y * tsize.height);
+ });
}
}
diff --git a/util/java/libtiled-java/src/main/resources/bindings.xjb b/util/java/libtiled-java/src/main/resources/bindings.xjb
index f59a5c3027..a6b063c3aa 100644
--- a/util/java/libtiled-java/src/main/resources/bindings.xjb
+++ b/util/java/libtiled-java/src/main/resources/bindings.xjb
@@ -101,11 +101,14 @@
-
-
-
-
-
+
+
+
+
+
+
+
+
@@ -169,6 +172,9 @@
+
+
+
@@ -218,6 +224,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -276,6 +297,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/util/java/libtiled-java/src/main/resources/map.xsd b/util/java/libtiled-java/src/main/resources/map.xsd
index b62efc33a2..eee9b89a1d 100644
--- a/util/java/libtiled-java/src/main/resources/map.xsd
+++ b/util/java/libtiled-java/src/main/resources/map.xsd
@@ -162,6 +162,16 @@ POSSIBILITY OF SUCH DAMAGE.
+
+
+
+ Used to mark an object as a capsule shape.
+
+ @since 1.12
+
+
+
+
@@ -342,6 +352,26 @@ POSSIBILITY OF SUCH DAMAGE.
+
+
+
+ Whether the image drawn by this layer is repeated
+ along the X axis.
+
+ @since 1.8
+
+
+
+
+
+
+ Whether the image drawn by this layer is repeated
+ along the Y axis.
+
+ @since 1.8
+
+
+
@@ -424,7 +454,7 @@ POSSIBILITY OF SUCH DAMAGE.
-
+
Rendering offset for this layer in pixels. Defaults to 0.
@@ -433,7 +463,7 @@ POSSIBILITY OF SUCH DAMAGE.
-
+
Rendering offset for this layer in pixels. Defaults to 0.
@@ -449,6 +479,52 @@ POSSIBILITY OF SUCH DAMAGE.
+
+
+
+ A tint color that is multiplied with any tiles drawn by
+ this layer in `#AARRGGBB` or `#RRGGBB` format.
+
+ @since 1.0
+
+
+
+
+
+
+ Horizontal parallax scrolling factor. Defaults to 1.
+
+ @since 1.5
+
+
+
+
+
+
+ Vertical parallax scrolling factor. Defaults to 1.
+
+ @since 1.5
+
+
+
+
+
+
+ The class of the layer (optional).
+
+ @since 1.9
+
+
+
+
+
+
+ The blend mode used when rendering this layer. Defaults to "normal".
+
+ @since 1.11
+
+
+
@@ -478,7 +554,7 @@ POSSIBILITY OF SUCH DAMAGE.
-
+
The TMX format version. Was "1.0" so far, and will be
@@ -614,6 +690,61 @@ POSSIBILITY OF SUCH DAMAGE.
+
+
+
+ X coordinate of the parallax origin in pixels. Defaults to 0.
+
+ @since 1.8
+
+
+
+
+
+
+ Y coordinate of the parallax origin in pixels. Defaults to 0.
+
+ @since 1.8
+
+
+
+
+
+
+ The compression level to use for tile layer data.
+ Defaults to -1 (use algorithm default).
+
+ @since 1.3
+
+
+
+
+
+
+ The skew in the X direction for oblique maps. Defaults to 0.
+
+ @since 1.11
+
+
+
+
+
+
+ The skew in the Y direction for oblique maps. Defaults to 0.
+
+ @since 1.11
+
+
+
+
+
+
+ The class of the map (optional).
+
+ @since 1.9
+
+
+
@@ -661,6 +792,13 @@ POSSIBILITY OF SUCH DAMAGE.
+
+
+
+ @since 1.12
+
+
+
@@ -734,6 +872,16 @@ POSSIBILITY OF SUCH DAMAGE.
+
+
+
+ The opacity of the object as a value from 0 to 1. Defaults
+ to 1.
+
+ @since 1.2
+
+
+
@@ -853,6 +1001,18 @@ POSSIBILITY OF SUCH DAMAGE.
+
+
+
+
+ The member values of a class property.
+
+ @since 1.8
+
+
+
+
+
@@ -876,6 +1036,15 @@ POSSIBILITY OF SUCH DAMAGE.
+
+
+
+ The name of the custom property type, when applicable.
+
+ @since 1.8
+
+
+
@@ -958,7 +1127,7 @@ POSSIBILITY OF SUCH DAMAGE.
-
+
Whether word wrapping is enabled (1) or disabled
@@ -974,7 +1143,7 @@ POSSIBILITY OF SUCH DAMAGE.
-
+
Whether the font is bold (1) or not (0). Defaults to
@@ -982,7 +1151,7 @@ POSSIBILITY OF SUCH DAMAGE.
-
+
Whether the font is italic (1) or not (0). Defaults
@@ -990,7 +1159,7 @@ POSSIBILITY OF SUCH DAMAGE.
-
+
Whether a line should be drawn below the text (1) or
@@ -998,7 +1167,7 @@ POSSIBILITY OF SUCH DAMAGE.
-
+
Whether a line should be drawn through the text (1)
@@ -1006,7 +1175,7 @@ POSSIBILITY OF SUCH DAMAGE.
-
+
Whether kerning should be used while rendering the
@@ -1014,7 +1183,7 @@ POSSIBILITY OF SUCH DAMAGE.
-
+
Horizontal alignment of the text within the object
@@ -1022,11 +1191,11 @@ POSSIBILITY OF SUCH DAMAGE.
-
+
Vertical alignment of the text within the object
- (`left` (default), `center` or `right`)
+ (`top` (default), `center` or `bottom`)
@@ -1081,6 +1250,16 @@ POSSIBILITY OF SUCH DAMAGE.
+
+
+
+ The class of the tile (renamed from type in Tiled 1.9,
+ reverted in 1.10). (optional)
+
+ @since 1.9
+
+
+
@@ -1105,6 +1284,42 @@ POSSIBILITY OF SUCH DAMAGE.
+
+
+
+ The X coordinate of the sub-rectangle in the tile image.
+ Used for collection tilesets where each tile references
+ a sub-region of an image. (optional)
+
+
+
+
+
+
+ The Y coordinate of the sub-rectangle in the tile image.
+ Used for collection tilesets where each tile references
+ a sub-region of an image. (optional)
+
+
+
+
+
+
+ The width of the sub-rectangle in the tile image.
+ Used for collection tilesets where each tile references
+ a sub-region of an image. (optional)
+
+
+
+
+
+
+ The height of the sub-rectangle in the tile image.
+ Used for collection tilesets where each tile references
+ a sub-region of an image. (optional)
+
+
+
@@ -1199,6 +1414,13 @@ POSSIBILITY OF SUCH DAMAGE.
+
+
+
+ @since 1.5
+
+
+
@@ -1278,6 +1500,51 @@ POSSIBILITY OF SUCH DAMAGE.
+
+
+
+ Controls the alignment for tile objects.
+
+ @since 1.4
+
+
+
+
+
+
+ The size to use when rendering tiles from this tileset
+ on a tile layer. Can be "tile" (default) or "grid".
+
+ @since 1.9
+
+
+
+
+
+
+ The fill mode to use when rendering tiles from this
+ tileset. Can be "stretch" (default) or "preserve-aspect-fit".
+
+ @since 1.9
+
+
+
+
+
+
+ The class of this tileset (optional).
+
+ @since 1.9
+
+
+
+
+
+
+ The background color of the tileset (optional).
+
+
+
@@ -1356,6 +1623,100 @@ POSSIBILITY OF SUCH DAMAGE.
+
+
+
+ Stores transformations that can be applied to tiles in a
+ tileset.
+
+ @since 1.5
+
+
+
+
+
+
+ Whether tiles can be flipped horizontally.
+
+
+
+
+
+
+ Whether tiles can be flipped vertically.
+
+
+
+
+
+
+ Whether tiles can be rotated in 90 degree increments.
+
+
+
+
+
+
+ Whether untransformed tiles are preferred.
+
+
+
+
+
+
+
+
+ A color that can be used to define a corner and/or edge of
+ a Wang tile.
+
+ @since 1.5
+
+
+
+
+
+
+
+
+
+
+ The name of this color.
+
+
+
+
+
+
+ The class of this color (optional).
+
+ @since 1.9
+
+
+
+
+
+
+ The color in `#RRGGBB` format.
+
+
+
+
+
+
+ The tile ID of the tile representing this color.
+
+
+
+
+
+
+ The relative probability that this color is chosen
+ over others in case of multiple options.
+
+
+
+
+
@@ -1365,9 +1726,11 @@ POSSIBILITY OF SUCH DAMAGE.
+
+
@@ -1384,6 +1747,25 @@ POSSIBILITY OF SUCH DAMAGE.
+
+
+
+ The type of the Wang set. Can be "corner", "edge" or
+ "mixed".
+
+ @since 1.5
+
+
+
+
+
+
+ The class of this Wang set (optional).
+
+ @since 1.9
+
+
+
@@ -1435,6 +1817,13 @@ POSSIBILITY OF SUCH DAMAGE.
+
+
+
+ @since 1.3
+
+
+
@@ -1471,6 +1860,13 @@ POSSIBILITY OF SUCH DAMAGE.
+
+
+
+ @since 1.11
+
+
+
@@ -1534,6 +1930,25 @@ POSSIBILITY OF SUCH DAMAGE.
+
+
+
+ Object properties store a reference to an object by
+ its id.
+
+ @since 1.4
+
+
+
+
+
+
+ Class properties store a nested set of properties.
+
+ @since 1.8
+
+
+
diff --git a/util/java/libtiled-java/src/test/java/org/mapeditor/io/MapReaderTest.java b/util/java/libtiled-java/src/test/java/org/mapeditor/io/MapReaderTest.java
index d32003c41e..1d4119384b 100644
--- a/util/java/libtiled-java/src/test/java/org/mapeditor/io/MapReaderTest.java
+++ b/util/java/libtiled-java/src/test/java/org/mapeditor/io/MapReaderTest.java
@@ -40,8 +40,12 @@
import org.junit.Test;
import org.mapeditor.core.Map;
+import org.mapeditor.core.MapObject;
import org.mapeditor.core.ObjectGroup;
import org.mapeditor.core.Orientation;
+import org.mapeditor.core.Properties;
+import org.mapeditor.core.Property;
+import org.mapeditor.core.PropertyType;
import org.mapeditor.core.StaggerAxis;
import org.mapeditor.core.StaggerIndex;
import org.mapeditor.core.TileLayer;
@@ -207,6 +211,23 @@ public void testErrorReadingImageJar() throws Exception {
new TMXMapReader().readMap(getJarURL("desert_missing_image/desert.tmx"));
}
+ @Test
+ public void testUnsupportedImageFormat() throws Exception {
+ URL url = getUrlFromResources("unsupported_image/desert.tmx");
+ Map map = new TMXMapReader().readMap(url.getPath());
+ assertNotNull(map);
+ assertEquals(2, map.getWidth());
+ assertEquals(2, map.getHeight());
+ }
+
+ @Test
+ public void testUnsupportedImageFormatJar() throws Exception {
+ Map map = new TMXMapReader().readMap(getJarURL("unsupported_image/desert.tmx"));
+ assertNotNull(map);
+ assertEquals(2, map.getWidth());
+ assertEquals(2, map.getHeight());
+ }
+
@Test(expected = IOException.class)
public void testErrorReadingTileset() throws Exception {
URL url = getUrlFromResources("desert_missing_tileset/desert.tmx");
@@ -394,4 +415,304 @@ public void testReadmapWithSearchDirectory() throws Exception {
Map map = new TMXMapReader().readMap(in, parentDirectory);
assertEquals(1, map.getTileSets().size());
}
+
+ @Test
+ public void testReadingModernFeatures() throws Exception {
+ URL url = getUrlFromResources("modern_features/modern_features.tmx");
+ Map map = new TMXMapReader().readMap(url.getPath());
+
+ // Map-level attributes
+ assertEquals(Orientation.ORTHOGONAL, map.getOrientation());
+ assertEquals("1.10", map.getVersion());
+ assertEquals(3, map.getWidth());
+ assertEquals(3, map.getHeight());
+ assertEquals(32, map.getTileWidth());
+ assertEquals(32, map.getTileHeight());
+ assertEquals(6, map.getCompressionlevel().intValue());
+ assertEquals(100.0, map.getParallaxoriginx(), 0.001);
+ assertEquals(200.0, map.getParallaxoriginy(), 0.001);
+ assertEquals(4, map.getLayerCount());
+
+ // Map-level properties with types
+ Properties mapProps = map.getProperties();
+ assertNotNull(mapProps);
+ assertEquals("true", mapProps.getProperty("boolProp"));
+ assertEquals("42", mapProps.getProperty("intProp"));
+ assertEquals("3.14", mapProps.getProperty("floatProp"));
+ assertEquals("#ff00ff00", mapProps.getProperty("colorProp"));
+ assertEquals("test.png", mapProps.getProperty("fileProp"));
+ assertEquals("hello", mapProps.getProperty("stringProp"));
+ assertEquals(PropertyType.BOOL, findPropertyByName(mapProps, "boolProp").getType());
+ assertEquals(PropertyType.FLOAT, findPropertyByName(mapProps, "floatProp").getType());
+ assertNull(findPropertyByName(mapProps, "stringProp").getType());
+
+ // Class property with nested member values
+ Property classProp = findPropertyByName(mapProps, "classProp");
+ assertEquals(PropertyType.CLASS, classProp.getType());
+ assertEquals("PhysicsBody", classProp.getPropertyTypeName());
+ assertNull(classProp.getValue());
+ assertNotNull(classProp.getProperties());
+ assertEquals("2.5", classProp.getProperties().getProperty("mass"));
+ assertEquals("true", classProp.getProperties().getProperty("sticky"));
+
+ // Layer 0: tintcolor and parallax
+ TileLayer tintedLayer = (TileLayer) map.getLayer(0);
+ assertEquals("TintedLayer", tintedLayer.getName());
+ assertEquals("#dca0a0", tintedLayer.getTintcolor());
+ assertEquals(0.5, tintedLayer.getParallaxx(), 0.001);
+ assertEquals(0.75, tintedLayer.getParallaxy(), 0.001);
+
+ // Layer 1: no tintcolor, no parallax
+ TileLayer normalLayer = (TileLayer) map.getLayer(1);
+ assertEquals("NormalLayer", normalLayer.getName());
+ assertNull(normalLayer.getTintcolor());
+
+ // Layer 2: opacity + tintcolor
+ TileLayer halfOpacity = (TileLayer) map.getLayer(2);
+ assertEquals("HalfOpacity", halfOpacity.getName());
+ assertEquals(0.5f, halfOpacity.getOpacity(), 0.01);
+ assertEquals("#80ff0000", halfOpacity.getTintcolor());
+
+ // Layer 3: ObjectGroup with template objects
+ ObjectGroup templateOG = (ObjectGroup) map.getLayer(3);
+ assertEquals("TemplateObjects", templateOG.getName());
+ assertEquals(2, templateOG.getObjects().size());
+
+ // Object 1: template with no overrides
+ MapObject obj1 = templateOG.getObjects().get(0);
+ assertEquals("templates/rect_template.tx", obj1.getTemplate());
+ assertEquals("block", obj1.getName());
+ assertEquals("solid", obj1.getType());
+ assertEquals(100.0, obj1.getX(), 0.001);
+ assertEquals(200.0, obj1.getY(), 0.001);
+ assertEquals(64.0, obj1.getWidth(), 0.001);
+ assertEquals(64.0, obj1.getHeight(), 0.001);
+ assertEquals("true", obj1.getProperties().getProperty("collision"));
+
+ // Object 2: template with name override and property override
+ MapObject obj2 = templateOG.getObjects().get(1);
+ assertEquals("templates/rect_template.tx", obj2.getTemplate());
+ assertEquals("override_block", obj2.getName());
+ assertEquals("solid", obj2.getType());
+ assertEquals(300.0, obj2.getX(), 0.001);
+ assertEquals(400.0, obj2.getY(), 0.001);
+ assertEquals(64.0, obj2.getWidth(), 0.001);
+ assertEquals(64.0, obj2.getHeight(), 0.001);
+ // collision overridden from true to false
+ assertEquals("false", obj2.getProperties().getProperty("collision"));
+ // color is TMX-only property
+ assertEquals("red", obj2.getProperties().getProperty("color"));
+ }
+
+ @Test
+ public void testReadingInfiniteMap() throws Exception {
+ URL url = getUrlFromResources("infinite/infinite.tmx");
+ Map map = new TMXMapReader().readMap(url.getPath());
+
+ assertEquals(Orientation.ORTHOGONAL, map.getOrientation());
+ assertEquals("1.8", map.getVersion());
+ assertEquals(1, map.getInfinite().intValue());
+ assertEquals(1, map.getLayerCount());
+
+ // Layer should span from (-16,-16) to (16,16) = 32x32 tiles
+ TileLayer layer = (TileLayer) map.getLayer(0);
+ assertEquals("ChunkLayer", layer.getName());
+ assertEquals(32, layer.getWidth());
+ assertEquals(32, layer.getHeight());
+
+ // Verify attributes are preserved after TileLayer recreation for infinite maps
+ assertEquals(Double.valueOf(10), layer.getOffsetX());
+ assertEquals(Double.valueOf(20), layer.getOffsetY());
+ assertEquals("MyLayerClass", layer.getClassName());
+ }
+
+ @Test
+ public void testModernFeaturesRoundTrip() throws Exception {
+ URL url = getUrlFromResources("modern_features/modern_features.tmx");
+ TMXMapReader reader = new TMXMapReader();
+ Map original = reader.readMap(url.getPath());
+
+ // Write to byte array
+ java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
+ TMXMapWriter writer = new TMXMapWriter();
+ writer.writeMap(original, baos);
+
+ // Read back using the original file's directory for relative path resolution
+ java.io.ByteArrayInputStream bais = new java.io.ByteArrayInputStream(baos.toByteArray());
+ TMXMapReader reader2 = new TMXMapReader();
+ String searchDir = new File(url.getFile()).getParent();
+ Map reread = reader2.readMap(bais, searchDir);
+
+ // Verify key map attributes preserved
+ assertEquals(original.getWidth(), reread.getWidth());
+ assertEquals(original.getHeight(), reread.getHeight());
+ assertEquals(original.getLayerCount(), reread.getLayerCount());
+
+ // Class property survives the round trip
+ Property classProp = findPropertyByName(reread.getProperties(), "classProp");
+ assertEquals(PropertyType.CLASS, classProp.getType());
+ assertEquals("PhysicsBody", classProp.getPropertyTypeName());
+ assertEquals("2.5", classProp.getProperties().getProperty("mass"));
+ assertEquals("true", classProp.getProperties().getProperty("sticky"));
+
+ // Verify template objects in round-trip
+ ObjectGroup origOG = (ObjectGroup) original.getLayer(3);
+ ObjectGroup rereadOG = (ObjectGroup) reread.getLayer(3);
+ assertEquals(origOG.getObjects().size(), rereadOG.getObjects().size());
+
+ // First template object
+ MapObject origObj1 = origOG.getObjects().get(0);
+ MapObject rereadObj1 = rereadOG.getObjects().get(0);
+ assertEquals(origObj1.getName(), rereadObj1.getName());
+ assertEquals(origObj1.getType(), rereadObj1.getType());
+ assertEquals(origObj1.getWidth(), rereadObj1.getWidth());
+ assertEquals(origObj1.getHeight(), rereadObj1.getHeight());
+ assertEquals(origObj1.getTemplate(), rereadObj1.getTemplate());
+ assertEquals(origObj1.getProperties().getProperty("collision"),
+ rereadObj1.getProperties().getProperty("collision"));
+
+ // Second template object (with overrides)
+ MapObject origObj2 = origOG.getObjects().get(1);
+ MapObject rereadObj2 = rereadOG.getObjects().get(1);
+ assertEquals("override_block", rereadObj2.getName());
+ assertEquals("solid", rereadObj2.getType());
+ assertEquals(origObj2.getTemplate(), rereadObj2.getTemplate());
+ assertEquals("false", rereadObj2.getProperties().getProperty("collision"));
+ assertEquals("red", rereadObj2.getProperties().getProperty("color"));
+ }
+
+ @Test
+ public void testInfiniteMapRoundTrip() throws Exception {
+ URL url = getUrlFromResources("infinite/infinite.tmx");
+ TMXMapReader reader = new TMXMapReader();
+ Map original = reader.readMap(url.getPath());
+
+ // Write to byte array
+ java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
+ TMXMapWriter writer = new TMXMapWriter();
+ writer.writeMap(original, baos);
+
+ // Read back
+ java.io.ByteArrayInputStream bais = new java.io.ByteArrayInputStream(baos.toByteArray());
+ String searchDir = new File(url.getFile()).getParent();
+ Map reread = new TMXMapReader().readMap(bais, searchDir);
+
+ TileLayer origLayer = (TileLayer) original.getLayer(0);
+ TileLayer rereadLayer = (TileLayer) reread.getLayer(0);
+
+ assertEquals(origLayer.getName(), rereadLayer.getName());
+ assertEquals(origLayer.getOffsetX(), rereadLayer.getOffsetX());
+ assertEquals(origLayer.getOffsetY(), rereadLayer.getOffsetY());
+ assertEquals(origLayer.getClassName(), rereadLayer.getClassName());
+ }
+
+ @Test
+ public void testReadingSvgTileset() throws Exception {
+ URL url = getUrlFromResources("svg_tileset/svg_tileset.tmx");
+ Map map = new TMXMapReader().readMap(url.getPath());
+ checkSvgTileset(map);
+ }
+
+ @Test
+ public void testReadingSvgTilesetJar() throws Exception {
+ Map map = new TMXMapReader().readMap(getJarURL("svg_tileset/svg_tileset.tmx"));
+ checkSvgTileset(map);
+ }
+
+ private void checkSvgTileset(final Map map) {
+ assertNotNull(map);
+ assertEquals(2, map.getWidth());
+ assertEquals(2, map.getHeight());
+ assertEquals(32, map.getTileWidth());
+ assertEquals(32, map.getTileHeight());
+ assertEquals(1, map.getLayerCount());
+
+ TileSet tileset = map.getTileSets().get(0);
+ assertEquals("SvgTileset", tileset.getName());
+ assertEquals(2, tileset.size());
+
+ TileLayer layer = (TileLayer) map.getLayer(0);
+ assertNotNull(layer.getTileAt(0, 0));
+ assertNotNull(layer.getTileAt(1, 0));
+ }
+
+ @Test
+ public void testStickerKnightTemplates() throws Exception {
+ File sandboxFile = new File("../../../examples/sticker-knight/map/sandbox.tmx");
+ org.junit.Assume.assumeTrue("Skipping: sticker-knight not found", sandboxFile.exists());
+
+ Map map = new TMXMapReader().readMap(sandboxFile.getCanonicalPath());
+
+ // Find the "game" object group
+ ObjectGroup gameGroup = null;
+ for (int i = 0; i < map.getLayerCount(); i++) {
+ if (map.getLayer(i) instanceof ObjectGroup) {
+ ObjectGroup og = (ObjectGroup) map.getLayer(i);
+ if ("game".equals(og.getName())) {
+ gameGroup = og;
+ break;
+ }
+ }
+ }
+ assertNotNull("game object group should exist", gameGroup);
+
+ // Find specific template objects
+ MapObject hero = findObjectById(gameGroup, 58);
+ MapObject block = findObjectById(gameGroup, 111);
+ MapObject diamond = findObjectById(gameGroup, 190);
+
+ // Verify hero template resolution
+ assertNotNull("hero object should exist", hero);
+ assertEquals("templates/hero.tx", hero.getTemplate());
+ assertEquals("hero", hero.getName());
+ assertEquals("hero", hero.getType());
+ assertEquals(45.0, hero.getX(), 0.001);
+ assertEquals(979.5, hero.getY(), 0.001);
+ assertEquals(128.0, hero.getWidth(), 0.001);
+ assertEquals(160.0, hero.getHeight(), 0.001);
+ assertNotNull("hero should have a tile", hero.getTile());
+
+ // Verify block template resolution
+ assertNotNull("block object should exist", block);
+ assertEquals("templates/block.tx", block.getTemplate());
+ assertEquals("block", block.getName());
+ assertEquals(594.0, block.getX(), 0.001);
+ assertEquals(571.0, block.getY(), 0.001);
+ assertEquals(96.0, block.getWidth(), 0.001);
+ assertEquals(96.0, block.getHeight(), 0.001);
+ assertNotNull("block should have a tile", block.getTile());
+ // Check block properties inherited from template
+ assertNotNull(block.getProperties());
+ assertEquals("dynamic", block.getProperties().getProperty("bodyType"));
+ assertEquals("2", block.getProperties().getProperty("density"));
+ assertEquals("0.45", block.getProperties().getProperty("friction"));
+
+ // Verify diamond template resolution
+ assertNotNull("diamond object should exist", diamond);
+ assertEquals("templates/diamond.tx", diamond.getTemplate());
+ assertEquals("coin", diamond.getType());
+ assertEquals(238.0, diamond.getX(), 0.001);
+ assertEquals(947.5, diamond.getY(), 0.001);
+ assertEquals(64.0, diamond.getWidth(), 0.001);
+ assertEquals(64.0, diamond.getHeight(), 0.001);
+ assertNotNull("diamond should have a tile", diamond.getTile());
+ }
+
+ private static Property findPropertyByName(Properties props, String name) {
+ for (Property property : props.getProperties()) {
+ if (name.equals(property.getName())) {
+ return property;
+ }
+ }
+ throw new AssertionError("Missing property: " + name);
+ }
+
+ private static MapObject findObjectById(ObjectGroup group, int id) {
+ for (MapObject obj : group.getObjects()) {
+ if (obj.getId() == id) {
+ return obj;
+ }
+ }
+ return null;
+ }
}
diff --git a/util/java/libtiled-java/src/test/java/org/mapeditor/view/HexagonalRendererObjectShapeTest.java b/util/java/libtiled-java/src/test/java/org/mapeditor/view/HexagonalRendererObjectShapeTest.java
new file mode 100644
index 0000000000..a98cab0538
--- /dev/null
+++ b/util/java/libtiled-java/src/test/java/org/mapeditor/view/HexagonalRendererObjectShapeTest.java
@@ -0,0 +1,140 @@
+/*-
+ * #%L
+ * This file is part of libtiled-java.
+ * %%
+ * Copyright (C) 2026
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package org.mapeditor.view;
+
+import java.awt.BasicStroke;
+import java.awt.Graphics2D;
+import java.awt.Point;
+import java.awt.RenderingHints;
+import java.awt.Shape;
+import java.awt.geom.AffineTransform;
+import java.awt.geom.Path2D;
+import java.awt.geom.Rectangle2D;
+import java.awt.image.BufferedImage;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import org.junit.Test;
+
+import org.mapeditor.core.Map;
+import org.mapeditor.core.MapObject;
+import org.mapeditor.core.ObjectGroup;
+import org.mapeditor.core.Orientation;
+import org.mapeditor.core.Polygon;
+
+public class HexagonalRendererObjectShapeTest {
+
+ @Test
+ public void testPolygonIsNotDrawnAsBoundingRectangle() {
+ Map map = new Map(8, 8);
+ map.setTileWidth(32);
+ map.setTileHeight(32);
+ map.setOrientation(Orientation.HEXAGONAL);
+
+ ObjectGroup group = new ObjectGroup(map);
+ map.addLayer(group);
+
+ MapObject polygonObject = new MapObject();
+ polygonObject.setName("");
+ polygonObject.setX(60);
+ polygonObject.setY(50);
+
+ Path2D.Double shape = new Path2D.Double();
+ shape.moveTo(60, 50);
+ shape.lineTo(115, 63);
+ shape.lineTo(90, 130);
+ shape.lineTo(50, 95);
+ shape.closePath();
+ polygonObject.setShape(shape);
+
+ Rectangle2D bounds = shape.getBounds2D();
+ polygonObject.setWidth(bounds.getWidth());
+ polygonObject.setHeight(bounds.getHeight());
+
+ Polygon polygon = new Polygon();
+ polygon.setPoints("10,0 65,13 40,80 0,45");
+ polygonObject.setPolygon(polygon);
+ group.addObject(polygonObject);
+
+ BufferedImage image = new BufferedImage(
+ map.getWidth() * map.getTileWidth(),
+ map.getHeight() * map.getTileHeight(),
+ BufferedImage.TYPE_INT_ARGB);
+ Graphics2D g = image.createGraphics();
+ g.setClip(0, 0, image.getWidth(), image.getHeight());
+ g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_OFF);
+ try {
+ new HexagonalRenderer(map).paintObjectGroup(g, group);
+ } finally {
+ g.dispose();
+ }
+
+ Point rectOnlyPixel = findRectangleOnlyBorderPixel(polygonObject);
+ assertNotNull(rectOnlyPixel);
+ assertEquals(0, image.getRGB(rectOnlyPixel.x, rectOnlyPixel.y) >>> 24);
+ }
+
+ private static Point findRectangleOnlyBorderPixel(MapObject object) {
+ final Shape shape = object.getShape();
+ final Shape stroke = new BasicStroke(1f).createStrokedShape(shape);
+ final Shape shadowStroke = AffineTransform.getTranslateInstance(1.0, 1.0).createTransformedShape(stroke);
+ final Rectangle2D b = shape.getBounds2D();
+
+ final int left = (int) Math.floor(b.getMinX());
+ final int top = (int) Math.floor(b.getMinY());
+ final int right = (int) Math.ceil(b.getMaxX()) - 1;
+ final int bottom = (int) Math.ceil(b.getMaxY()) - 1;
+
+ for (int x = left; x <= right; x++) {
+ if (!containsAnyStroke(stroke, shadowStroke, x, top)) {
+ return new Point(x, top);
+ }
+ if (!containsAnyStroke(stroke, shadowStroke, x, bottom)) {
+ return new Point(x, bottom);
+ }
+ }
+
+ for (int y = top; y <= bottom; y++) {
+ if (!containsAnyStroke(stroke, shadowStroke, left, y)) {
+ return new Point(left, y);
+ }
+ if (!containsAnyStroke(stroke, shadowStroke, right, y)) {
+ return new Point(right, y);
+ }
+ }
+
+ return null;
+ }
+
+ private static boolean containsAnyStroke(Shape stroke, Shape shadowStroke, int x, int y) {
+ final double px = x + 0.5;
+ final double py = y + 0.5;
+ return stroke.contains(px, py) || shadowStroke.contains(px, py);
+ }
+}
diff --git a/util/java/libtiled-java/src/test/java/org/mapeditor/view/HexagonalRendererTest.java b/util/java/libtiled-java/src/test/java/org/mapeditor/view/HexagonalRendererTest.java
new file mode 100644
index 0000000000..57f6da32f8
--- /dev/null
+++ b/util/java/libtiled-java/src/test/java/org/mapeditor/view/HexagonalRendererTest.java
@@ -0,0 +1,226 @@
+/*-
+ * #%L
+ * This file is part of libtiled-java.
+ * %%
+ * Copyright (C) 2026
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package org.mapeditor.view;
+
+import java.awt.Dimension;
+import java.awt.Point;
+
+import static org.junit.Assert.assertEquals;
+import org.junit.Test;
+
+import org.mapeditor.core.Map;
+import org.mapeditor.core.Orientation;
+import org.mapeditor.core.StaggerAxis;
+import org.mapeditor.core.StaggerIndex;
+
+public class HexagonalRendererTest {
+
+ private static Map createHexMap(int w, int h, int tw, int th, int hexSide,
+ StaggerAxis axis, StaggerIndex index) {
+ Map map = new Map(w, h);
+ map.setTileWidth(tw);
+ map.setTileHeight(th);
+ map.setOrientation(Orientation.HEXAGONAL);
+ map.setHexSideLength(hexSide);
+ map.setStaggerAxis(axis);
+ map.setStaggerIndex(index);
+ return map;
+ }
+
+ private static void assertRoundTrip(HexagonalRenderer r, int mapW, int mapH) {
+ for (int ty = 0; ty < mapH; ty++) {
+ for (int tx = 0; tx < mapW; tx++) {
+ Point center = r.tileToScreenCoords(tx, ty);
+ Point result = r.screenToTileCoords(center.x, center.y);
+ assertEquals("(" + tx + "," + ty + ")", new Point(tx, ty), result);
+ }
+ }
+ }
+
+ // --- tileToScreenCoords ---
+
+ @Test
+ public void testTileToScreenCoords_staggerX_odd() {
+ // 60x60, hexSide=30 → sideOffsetX=15, columnWidth=45, rowHeight=30
+ HexagonalRenderer r = new HexagonalRenderer(
+ createHexMap(4, 4, 60, 60, 30, StaggerAxis.X, StaggerIndex.ODD));
+
+ assertEquals(new Point(30, 30), r.tileToScreenCoords(0, 0)); // even col: no shift
+ assertEquals(new Point(75, 60), r.tileToScreenCoords(1, 0)); // odd col: +rowHeight
+ assertEquals(new Point(120, 30), r.tileToScreenCoords(2, 0));
+ assertEquals(new Point(30, 90), r.tileToScreenCoords(0, 1));
+ }
+
+ @Test
+ public void testTileToScreenCoords_staggerX_even() {
+ HexagonalRenderer r = new HexagonalRenderer(
+ createHexMap(4, 4, 60, 60, 30, StaggerAxis.X, StaggerIndex.EVEN));
+
+ assertEquals(new Point(30, 60), r.tileToScreenCoords(0, 0)); // even col: +rowHeight
+ assertEquals(new Point(75, 30), r.tileToScreenCoords(1, 0)); // odd col: no shift
+ assertEquals(new Point(120, 60), r.tileToScreenCoords(2, 0));
+ }
+
+ @Test
+ public void testTileToScreenCoords_staggerY_odd() {
+ // 60x60, hexSide=30 → sideOffsetY=15, columnWidth=30, rowHeight=45
+ HexagonalRenderer r = new HexagonalRenderer(
+ createHexMap(4, 4, 60, 60, 30, StaggerAxis.Y, StaggerIndex.ODD));
+
+ assertEquals(new Point(30, 30), r.tileToScreenCoords(0, 0)); // even row: no shift
+ assertEquals(new Point(60, 75), r.tileToScreenCoords(0, 1)); // odd row: +columnWidth
+ assertEquals(new Point(30, 120), r.tileToScreenCoords(0, 2));
+ }
+
+ @Test
+ public void testTileToScreenCoords_staggerY_even() {
+ HexagonalRenderer r = new HexagonalRenderer(
+ createHexMap(4, 4, 60, 60, 30, StaggerAxis.Y, StaggerIndex.EVEN));
+
+ assertEquals(new Point(60, 30), r.tileToScreenCoords(0, 0)); // even row: +columnWidth
+ assertEquals(new Point(30, 75), r.tileToScreenCoords(0, 1)); // odd row: no shift
+ }
+
+ @Test
+ public void testTileToScreenCoords_asymmetricTile() {
+ // 74x54, hexSide=34 → sideOffsetX=20, columnWidth=54, rowHeight=27
+ HexagonalRenderer r = new HexagonalRenderer(
+ createHexMap(5, 5, 74, 54, 34, StaggerAxis.X, StaggerIndex.ODD));
+
+ assertEquals(new Point(37, 27), r.tileToScreenCoords(0, 0));
+ assertEquals(new Point(91, 54), r.tileToScreenCoords(1, 0));
+ assertEquals(new Point(37, 81), r.tileToScreenCoords(0, 1));
+ }
+
+ // --- getMapSize ---
+
+ @Test
+ public void testGetMapSize() {
+ // staggerX 4x4: w = 4*45+15 = 195, h = 4*60 + 30 = 270
+ assertEquals(new Dimension(195, 270), new HexagonalRenderer(
+ createHexMap(4, 4, 60, 60, 30, StaggerAxis.X, StaggerIndex.ODD)).getMapSize());
+
+ // staggerY 4x4: w = 4*60 + 30 = 270, h = 4*45+15 = 195
+ assertEquals(new Dimension(270, 195), new HexagonalRenderer(
+ createHexMap(4, 4, 60, 60, 30, StaggerAxis.Y, StaggerIndex.ODD)).getMapSize());
+
+ // single column staggerX: no extra rowHeight → h = 4*60 = 240
+ assertEquals(new Dimension(60, 240), new HexagonalRenderer(
+ createHexMap(1, 4, 60, 60, 30, StaggerAxis.X, StaggerIndex.ODD)).getMapSize());
+
+ // single row staggerY: no extra columnWidth → w = 4*60 = 240
+ assertEquals(new Dimension(240, 60), new HexagonalRenderer(
+ createHexMap(4, 1, 60, 60, 30, StaggerAxis.Y, StaggerIndex.ODD)).getMapSize());
+ }
+
+ // --- screenToTileCoords round-trip ---
+
+ @Test
+ public void testScreenToTileRoundTrip_allStaggerCombinations() {
+ StaggerAxis[] axes = { StaggerAxis.X, StaggerAxis.Y };
+ StaggerIndex[] indices = { StaggerIndex.ODD, StaggerIndex.EVEN };
+
+ for (StaggerAxis axis : axes) {
+ for (StaggerIndex index : indices) {
+ Map map = createHexMap(8, 8, 60, 60, 30, axis, index);
+ HexagonalRenderer r = new HexagonalRenderer(map);
+ assertRoundTrip(r, 8, 8);
+ }
+ }
+ }
+
+ @Test
+ public void testScreenToTileRoundTrip_asymmetricTile() {
+ Map map = createHexMap(6, 6, 74, 54, 34, StaggerAxis.X, StaggerIndex.ODD);
+ assertRoundTrip(new HexagonalRenderer(map), 6, 6);
+ }
+
+ // --- staggered map (hexSideLength=0) ---
+
+ @Test
+ public void testStaggeredMap_coordinatesMatchStaggeredRenderer() {
+ // Matches staggered.tmx: 9x9, 32x32, staggerY, odd, no hexSideLength
+ Map map = new Map(9, 9);
+ map.setTileWidth(32);
+ map.setTileHeight(32);
+ map.setOrientation(Orientation.STAGGERED);
+ map.setStaggerAxis(StaggerAxis.Y);
+ map.setStaggerIndex(StaggerIndex.ODD);
+
+ HexagonalRenderer r = new HexagonalRenderer(map);
+ // hexSideLength=0 → sideOffsetX=16, sideOffsetY=16, columnWidth=16, rowHeight=16
+
+ // (0,0): pixelX=0, pixelY=0, center (16,16)
+ assertEquals(new Point(16, 16), r.tileToScreenCoords(0, 0));
+ // (1,0): pixelX=32, pixelY=0, center (48,16)
+ assertEquals(new Point(48, 16), r.tileToScreenCoords(1, 0));
+ // (0,1): odd row shifted → pixelX=0+16=16, pixelY=16, center (32,32)
+ assertEquals(new Point(32, 32), r.tileToScreenCoords(0, 1));
+ // (0,2): even row → pixelX=0, pixelY=32, center (16,48)
+ assertEquals(new Point(16, 48), r.tileToScreenCoords(0, 2));
+
+ assertRoundTrip(r, 9, 9);
+ }
+
+ @Test
+ public void testStaggeredMap_asymmetricTile() {
+ // Matches isometric_staggered_grass_and_water.tmx: 64x32 tiles
+ Map map = new Map(25, 50);
+ map.setTileWidth(64);
+ map.setTileHeight(32);
+ map.setOrientation(Orientation.STAGGERED);
+ map.setStaggerAxis(StaggerAxis.Y);
+ map.setStaggerIndex(StaggerIndex.ODD);
+
+ HexagonalRenderer r = new HexagonalRenderer(map);
+ // hexSideLength=0 → sideOffsetX=32, sideOffsetY=16, columnWidth=32, rowHeight=16
+
+ // (0,0): pixelX=0, pixelY=0, center (32,16)
+ assertEquals(new Point(32, 16), r.tileToScreenCoords(0, 0));
+ // (0,1): odd row shifted → pixelX=0+32=32, pixelY=16, center (64,32)
+ assertEquals(new Point(64, 32), r.tileToScreenCoords(0, 1));
+
+ assertRoundTrip(r, 25, 50);
+ }
+
+ // --- null defaults ---
+
+ @Test
+ public void testDefaultsWhenStaggerPropertiesNull() {
+ Map map = new Map(4, 4);
+ map.setTileWidth(32);
+ map.setTileHeight(32);
+ map.setOrientation(Orientation.HEXAGONAL);
+
+ HexagonalRenderer r = new HexagonalRenderer(map);
+ // staggerX=false, staggerEven=false → staggerY odd equivalent
+ assertEquals(new Point(16, 16), r.tileToScreenCoords(0, 0));
+ assertEquals(new Point(32, 32), r.tileToScreenCoords(0, 1));
+ }
+}
diff --git a/util/java/libtiled-java/src/test/java/org/mapeditor/view/OrthogonalRendererFlipTest.java b/util/java/libtiled-java/src/test/java/org/mapeditor/view/OrthogonalRendererFlipTest.java
new file mode 100644
index 0000000000..d06a8b724a
--- /dev/null
+++ b/util/java/libtiled-java/src/test/java/org/mapeditor/view/OrthogonalRendererFlipTest.java
@@ -0,0 +1,112 @@
+/*-
+ * #%L
+ * This file is part of libtiled-java.
+ * %%
+ * Copyright (C) 2026
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package org.mapeditor.view;
+
+import java.awt.Graphics2D;
+import java.awt.image.BufferedImage;
+import java.net.URL;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertNotNull;
+import org.junit.Test;
+
+import org.mapeditor.core.Map;
+import org.mapeditor.core.Tile;
+import org.mapeditor.core.TileLayer;
+import org.mapeditor.io.TMXMapReader;
+
+public class OrthogonalRendererFlipTest {
+
+ @Test
+ public void testOrthogonalRendererAppliesTileFlipFlags() throws Exception {
+ URL url = this.getClass().getClassLoader().getResource("flipped/flipped.tmx");
+ assertNotNull(url);
+
+ Map map = new TMXMapReader().readMap(url);
+ TileLayer layer = (TileLayer) map.getLayer(0);
+ Tile baseTile = layer.getTileAt(3, 0);
+ assertNotNull(baseTile);
+
+ BufferedImage source = baseTile.getImage();
+ assertNotNull(source);
+
+ BufferedImage rendered = new BufferedImage(
+ map.getWidth() * map.getTileWidth(),
+ map.getHeight() * map.getTileHeight(),
+ BufferedImage.TYPE_INT_ARGB);
+
+ Graphics2D g = rendered.createGraphics();
+ g.setClip(0, 0, rendered.getWidth(), rendered.getHeight());
+ try {
+ new OrthogonalRenderer(map).paintTileLayer(g, layer);
+ } finally {
+ g.dispose();
+ }
+
+ final int tileWidth = map.getTileWidth();
+ final int tileHeight = map.getTileHeight();
+
+ assertTileEquals(expectedFlipped(source, true, false), rendered, 0 * tileWidth, 0, tileWidth, tileHeight);
+ assertTileEquals(expectedFlipped(source, false, true), rendered, 1 * tileWidth, 0, tileWidth, tileHeight);
+ assertTileEquals(expectedFlipped(source, true, true), rendered, 2 * tileWidth, 0, tileWidth, tileHeight);
+ assertTileEquals(expectedFlipped(source, false, false), rendered, 3 * tileWidth, 0, tileWidth, tileHeight);
+ }
+
+ private static BufferedImage expectedFlipped(BufferedImage source, boolean flipH, boolean flipV) {
+ int width = source.getWidth();
+ int height = source.getHeight();
+ BufferedImage expected = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
+
+ for (int y = 0; y < height; y++) {
+ for (int x = 0; x < width; x++) {
+ int sx = flipH ? (width - 1 - x) : x;
+ int sy = flipV ? (height - 1 - y) : y;
+ expected.setRGB(x, y, source.getRGB(sx, sy));
+ }
+ }
+
+ return expected;
+ }
+
+ private static void assertTileEquals(
+ BufferedImage expected,
+ BufferedImage rendered,
+ int startX,
+ int startY,
+ int width,
+ int height) {
+ int[] actualPixels = new int[width * height];
+ rendered.getRGB(startX, startY, width, height, actualPixels, 0, width);
+
+ int[] expectedPixels = new int[width * height];
+ expected.getRGB(0, 0, width, height, expectedPixels, 0, width);
+
+ assertArrayEquals(expectedPixels, actualPixels);
+ }
+}
diff --git a/util/java/libtiled-java/src/test/java/org/mapeditor/view/OrthogonalRendererObjectShapeTest.java b/util/java/libtiled-java/src/test/java/org/mapeditor/view/OrthogonalRendererObjectShapeTest.java
new file mode 100644
index 0000000000..c3b0151ef1
--- /dev/null
+++ b/util/java/libtiled-java/src/test/java/org/mapeditor/view/OrthogonalRendererObjectShapeTest.java
@@ -0,0 +1,190 @@
+/*-
+ * #%L
+ * This file is part of libtiled-java.
+ * %%
+ * Copyright (C) 2026
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package org.mapeditor.view;
+
+import java.awt.BasicStroke;
+import java.awt.Graphics2D;
+import java.awt.Point;
+import java.awt.RenderingHints;
+import java.awt.Shape;
+import java.awt.geom.AffineTransform;
+import java.awt.geom.Path2D;
+import java.awt.geom.Rectangle2D;
+import java.awt.image.BufferedImage;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import org.junit.Test;
+
+import org.mapeditor.core.Map;
+import org.mapeditor.core.MapObject;
+import org.mapeditor.core.ObjectGroup;
+import org.mapeditor.core.Orientation;
+import org.mapeditor.core.Polygon;
+import org.mapeditor.core.Polyline;
+
+public class OrthogonalRendererObjectShapeTest {
+
+ @Test
+ public void testPolygonAndPolylineAreNotDrawnAsBoundingRectangles() {
+ Map map = createMap(Orientation.ORTHOGONAL);
+ ObjectGroup group = new ObjectGroup(map);
+ map.addLayer(group);
+
+ MapObject polygonObject = createPolygonObject();
+ MapObject polylineObject = createPolylineObject();
+ group.addObject(polygonObject);
+ group.addObject(polylineObject);
+
+ BufferedImage rendered = renderObjects(map, group, new OrthogonalRenderer(map));
+
+ Point polygonRectOnly = findRectangleOnlyBorderPixel(polygonObject);
+ Point polylineRectOnly = findRectangleOnlyBorderPixel(polylineObject);
+ assertNotNull(polygonRectOnly);
+ assertNotNull(polylineRectOnly);
+
+ assertEquals(0, alphaAt(rendered, polygonRectOnly.x, polygonRectOnly.y));
+ assertEquals(0, alphaAt(rendered, polylineRectOnly.x, polylineRectOnly.y));
+ }
+
+ private static Map createMap(Orientation orientation) {
+ Map map = new Map(8, 8);
+ map.setTileWidth(32);
+ map.setTileHeight(32);
+ map.setOrientation(orientation);
+ return map;
+ }
+
+ private static MapObject createPolygonObject() {
+ MapObject object = new MapObject();
+ object.setName("");
+ object.setX(30);
+ object.setY(40);
+ object.setRotation(0);
+
+ Path2D.Double shape = new Path2D.Double();
+ shape.moveTo(40, 40);
+ shape.lineTo(95, 53);
+ shape.lineTo(70, 120);
+ shape.lineTo(30, 85);
+ shape.closePath();
+ object.setShape(shape);
+
+ Rectangle2D bounds = shape.getBounds2D();
+ object.setWidth(bounds.getWidth());
+ object.setHeight(bounds.getHeight());
+
+ Polygon polygon = new Polygon();
+ polygon.setPoints("10,0 65,13 40,80 0,45");
+ object.setPolygon(polygon);
+ return object;
+ }
+
+ private static MapObject createPolylineObject() {
+ MapObject object = new MapObject();
+ object.setName("");
+ object.setX(20);
+ object.setY(150);
+ object.setRotation(0);
+
+ Path2D.Double shape = new Path2D.Double();
+ shape.moveTo(20, 150);
+ shape.lineTo(70, 180);
+ shape.lineTo(110, 160);
+ shape.lineTo(150, 190);
+ object.setShape(shape);
+
+ Rectangle2D bounds = shape.getBounds2D();
+ object.setWidth(bounds.getWidth());
+ object.setHeight(bounds.getHeight());
+
+ Polyline polyline = new Polyline();
+ polyline.setPoints("0,0 50,30 90,10 130,40");
+ object.setPolyline(polyline);
+ return object;
+ }
+
+ private static BufferedImage renderObjects(Map map, ObjectGroup group, OrthogonalRenderer renderer) {
+ BufferedImage image = new BufferedImage(
+ map.getWidth() * map.getTileWidth(),
+ map.getHeight() * map.getTileHeight(),
+ BufferedImage.TYPE_INT_ARGB);
+ Graphics2D g = image.createGraphics();
+ g.setClip(0, 0, image.getWidth(), image.getHeight());
+ g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_OFF);
+ try {
+ renderer.paintObjectGroup(g, group);
+ } finally {
+ g.dispose();
+ }
+ return image;
+ }
+
+ private static Point findRectangleOnlyBorderPixel(MapObject object) {
+ final Shape shape = object.getShape();
+ final Shape stroke = new BasicStroke(1f).createStrokedShape(shape);
+ final Shape shadowStroke = AffineTransform.getTranslateInstance(1.0, 1.0).createTransformedShape(stroke);
+ final Rectangle2D b = shape.getBounds2D();
+
+ final int left = (int) Math.floor(b.getMinX());
+ final int top = (int) Math.floor(b.getMinY());
+ final int right = (int) Math.ceil(b.getMaxX()) - 1;
+ final int bottom = (int) Math.ceil(b.getMaxY()) - 1;
+
+ for (int x = left; x <= right; x++) {
+ if (!containsAnyStroke(stroke, shadowStroke, x, top)) {
+ return new Point(x, top);
+ }
+ if (!containsAnyStroke(stroke, shadowStroke, x, bottom)) {
+ return new Point(x, bottom);
+ }
+ }
+
+ for (int y = top; y <= bottom; y++) {
+ if (!containsAnyStroke(stroke, shadowStroke, left, y)) {
+ return new Point(left, y);
+ }
+ if (!containsAnyStroke(stroke, shadowStroke, right, y)) {
+ return new Point(right, y);
+ }
+ }
+
+ return null;
+ }
+
+ private static boolean containsAnyStroke(Shape stroke, Shape shadowStroke, int x, int y) {
+ final double px = x + 0.5;
+ final double py = y + 0.5;
+ return stroke.contains(px, py) || shadowStroke.contains(px, py);
+ }
+
+ private static int alphaAt(BufferedImage image, int x, int y) {
+ return image.getRGB(x, y) >>> 24;
+ }
+}
diff --git a/util/java/libtiled-java/src/test/resources/infinite/infinite.tmx b/util/java/libtiled-java/src/test/resources/infinite/infinite.tmx
new file mode 100644
index 0000000000..62707d724c
--- /dev/null
+++ b/util/java/libtiled-java/src/test/resources/infinite/infinite.tmx
@@ -0,0 +1,43 @@
+
+
diff --git a/util/java/libtiled-java/src/test/resources/modern_features/modern_features.tmx b/util/java/libtiled-java/src/test/resources/modern_features/modern_features.tmx
new file mode 100644
index 0000000000..ea8af0ca1a
--- /dev/null
+++ b/util/java/libtiled-java/src/test/resources/modern_features/modern_features.tmx
@@ -0,0 +1,47 @@
+
+
diff --git a/util/java/libtiled-java/src/test/resources/modern_features/templates/rect_template.tx b/util/java/libtiled-java/src/test/resources/modern_features/templates/rect_template.tx
new file mode 100644
index 0000000000..3811f79a37
--- /dev/null
+++ b/util/java/libtiled-java/src/test/resources/modern_features/templates/rect_template.tx
@@ -0,0 +1,8 @@
+
+
+
+
diff --git a/util/java/libtiled-java/src/test/resources/svg_tileset/svg_tileset.tmx b/util/java/libtiled-java/src/test/resources/svg_tileset/svg_tileset.tmx
new file mode 100644
index 0000000000..cf602ead02
--- /dev/null
+++ b/util/java/libtiled-java/src/test/resources/svg_tileset/svg_tileset.tmx
@@ -0,0 +1,10 @@
+
+
diff --git a/util/java/libtiled-java/src/test/resources/svg_tileset/svg_tileset.tsx b/util/java/libtiled-java/src/test/resources/svg_tileset/svg_tileset.tsx
new file mode 100644
index 0000000000..31fc59b0ae
--- /dev/null
+++ b/util/java/libtiled-java/src/test/resources/svg_tileset/svg_tileset.tsx
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/util/java/libtiled-java/src/test/resources/svg_tileset/tileset.svg b/util/java/libtiled-java/src/test/resources/svg_tileset/tileset.svg
new file mode 100644
index 0000000000..ba1cfe569f
--- /dev/null
+++ b/util/java/libtiled-java/src/test/resources/svg_tileset/tileset.svg
@@ -0,0 +1,32 @@
+
+
diff --git a/util/java/libtiled-java/src/test/resources/unsupported_image/desert.tmx b/util/java/libtiled-java/src/test/resources/unsupported_image/desert.tmx
new file mode 100644
index 0000000000..e1c3c442db
--- /dev/null
+++ b/util/java/libtiled-java/src/test/resources/unsupported_image/desert.tmx
@@ -0,0 +1,10 @@
+
+
diff --git a/util/java/libtiled-java/src/test/resources/unsupported_image/desert.tsx b/util/java/libtiled-java/src/test/resources/unsupported_image/desert.tsx
new file mode 100644
index 0000000000..a8c396dbb0
--- /dev/null
+++ b/util/java/libtiled-java/src/test/resources/unsupported_image/desert.tsx
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/util/java/libtiled-java/src/test/resources/unsupported_image/image.webp b/util/java/libtiled-java/src/test/resources/unsupported_image/image.webp
new file mode 100644
index 0000000000..273f8a1a34
--- /dev/null
+++ b/util/java/libtiled-java/src/test/resources/unsupported_image/image.webp
@@ -0,0 +1 @@
+NOT A REAL IMAGE
\ No newline at end of file
diff --git a/util/java/tmxviewer-java/src/main/java/TMXViewer.java b/util/java/tmxviewer-java/src/main/java/TMXViewer.java
index c514481659..c0046cb26d 100644
--- a/util/java/tmxviewer-java/src/main/java/TMXViewer.java
+++ b/util/java/tmxviewer-java/src/main/java/TMXViewer.java
@@ -27,10 +27,13 @@
* POSSIBILITY OF SUCH DAMAGE.
* #L%
*/
+import java.awt.AlphaComposite;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Graphics2D;
+import java.awt.Point;
+import java.awt.RenderingHints;
import java.awt.Rectangle;
import javax.swing.JFrame;
@@ -38,18 +41,25 @@
import javax.swing.JScrollPane;
import javax.swing.Scrollable;
import javax.swing.SwingConstants;
+import javax.swing.Timer;
import javax.swing.WindowConstants;
+import org.mapeditor.core.AnimatedTile;
+import org.mapeditor.core.Group;
import org.mapeditor.core.Map;
import org.mapeditor.core.ObjectGroup;
import org.mapeditor.core.MapLayer;
+import org.mapeditor.core.Tile;
import org.mapeditor.core.TileLayer;
+import org.mapeditor.core.TileSet;
import org.mapeditor.io.TMXMapReader;
import org.mapeditor.view.HexagonalRenderer;
import org.mapeditor.view.MapRenderer;
+import org.mapeditor.view.ObliqueRenderer;
import org.mapeditor.view.OrthogonalRenderer;
import org.mapeditor.view.IsometricRenderer;
+
/**
* An example showing how to use libtiled-java to do a simple TMX viewer.
*/
@@ -114,34 +124,140 @@ class MapView extends JPanel implements Scrollable
{
private final Map map;
private final MapRenderer renderer;
+ private final Timer animationTimer;
+ private final int originOffsetX;
+ private final int originOffsetY;
public MapView(Map map) {
this.map = map;
renderer = createRenderer(map);
+ // For infinite maps, layer bounds can be negative.
+ // Compute an origin offset so everything shifts into positive pixel space.
+ if (map.getInfinite() != null && map.getInfinite() == 1) {
+ Point min = minLayerOrigin(map.getLayers());
+ originOffsetX = -min.x * map.getTileWidth();
+ originOffsetY = -min.y * map.getTileHeight();
+ } else {
+ originOffsetX = 0;
+ originOffsetY = 0;
+ }
+
setPreferredSize(renderer.getMapSize());
setOpaque(true);
+
+ animationTimer = hasAnimatedTiles(map) ? new Timer(33, e -> repaint()) : null;
+ }
+
+ private static Point minLayerOrigin(java.util.List layers) {
+ Point min = new Point(0, 0);
+ for (MapLayer layer : layers) {
+ if (layer instanceof Group) {
+ Point child = minLayerOrigin(((Group) layer).getLayers());
+ min.x = Math.min(min.x, child.x);
+ min.y = Math.min(min.y, child.y);
+ } else {
+ Rectangle b = layer.getBounds();
+ min.x = Math.min(min.x, b.x);
+ min.y = Math.min(min.y, b.y);
+ }
+ }
+ return min;
+ }
+
+ private static boolean hasAnimatedTiles(Map map) {
+ for (TileSet tileSet : map.getTileSets()) {
+ for (Tile tile : tileSet) {
+ if (tile instanceof AnimatedTile) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public void addNotify() {
+ super.addNotify();
+ if (animationTimer != null) {
+ animationTimer.start();
+ }
+ }
+
+ @Override
+ public void removeNotify() {
+ if (animationTimer != null) {
+ animationTimer.stop();
+ }
+ super.removeNotify();
}
@Override
public void paintComponent(Graphics g) {
final Graphics2D g2d = (Graphics2D) g.create();
final Rectangle clip = g2d.getClipBounds();
+ g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR);
+ g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_OFF);
// Draw a gray background
g2d.setPaint(new Color(100, 100, 100));
g2d.fill(clip);
+ // Shift so negative tile coordinates appear in positive pixel space
+ g2d.translate(originOffsetX, originOffsetY);
+
// Draw each map layer
- for (MapLayer layer : map.getLayers()) {
- if (layer instanceof TileLayer) {
- renderer.paintTileLayer(g2d, (TileLayer) layer);
- } else if (layer instanceof ObjectGroup) {
- renderer.paintObjectGroup(g2d, (ObjectGroup) layer);
+ paintLayers(g2d, map.getLayers());
+ g2d.dispose();
+ }
+
+ private void paintLayers(Graphics2D g2d, java.util.List layers) {
+ for (MapLayer layer : layers) {
+ if (Boolean.FALSE.equals(layer.isVisible())) {
+ continue;
+ }
+ final Graphics2D layerGraphics = (Graphics2D) g2d.create();
+ try {
+ applyParallaxTranslation(layerGraphics, layer);
+
+ if (layer instanceof Group) {
+ // The renderer applies opacity for tile and object layers.
+ // Groups are recursed here, so multiply their opacity onto
+ // the graphics for the child layers to pick up.
+ applyGroupOpacity(layerGraphics, layer);
+ paintLayers(layerGraphics, ((Group) layer).getLayers());
+ } else if (layer instanceof TileLayer) {
+ renderer.paintTileLayer(layerGraphics, (TileLayer) layer);
+ } else if (layer instanceof ObjectGroup) {
+ renderer.paintObjectGroup(layerGraphics, (ObjectGroup) layer);
+ }
+ } finally {
+ layerGraphics.dispose();
}
}
}
+ private static void applyGroupOpacity(Graphics2D g2d, MapLayer group) {
+ float opacity = group.getOpacity() != null
+ ? Math.max(0.0f, Math.min(1.0f, group.getOpacity())) : 1.0f;
+ if (opacity < 1.0f) {
+ float base = g2d.getComposite() instanceof AlphaComposite
+ ? ((AlphaComposite) g2d.getComposite()).getAlpha() : 1.0f;
+ g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, base * opacity));
+ }
+ }
+
+ private void applyParallaxTranslation(Graphics2D g2d, MapLayer layer) {
+ final double parallaxOriginX = map.getParallaxoriginx() != null ? map.getParallaxoriginx() : 0.0;
+ final double parallaxOriginY = map.getParallaxoriginy() != null ? map.getParallaxoriginy() : 0.0;
+ final double parallaxX = layer.getParallaxx() != null ? layer.getParallaxx() : 1.0;
+ final double parallaxY = layer.getParallaxy() != null ? layer.getParallaxy() : 1.0;
+
+ final int translateX = (int) Math.round(parallaxOriginX * (1.0 - parallaxX));
+ final int translateY = (int) Math.round(parallaxOriginY * (1.0 - parallaxY));
+ g2d.translate(translateX, translateY);
+ }
+
private static MapRenderer createRenderer(Map map) {
switch (map.getOrientation()) {
case ORTHOGONAL:
@@ -150,9 +266,13 @@ private static MapRenderer createRenderer(Map map) {
case ISOMETRIC:
return new IsometricRenderer(map);
+ case STAGGERED:
case HEXAGONAL:
return new HexagonalRenderer(map);
+ case OBLIQUE:
+ return new ObliqueRenderer(map);
+
default:
return null;
}
@@ -193,4 +313,4 @@ public boolean getScrollableTracksViewportWidth() {
public boolean getScrollableTracksViewportHeight() {
return false;
}
-}
\ No newline at end of file
+}