From 0d4eed5cbdd9d82ffd0090f85d0ad6918d51a7ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?TATSUNO=20=E2=80=9CTaz=E2=80=9D=20Yasuhiro?= Date: Wed, 2 Sep 2026 16:03:04 +0900 Subject: [PATCH] feat(libtiled-java): support modern TMX features (#4325) Updates libtiled-java, which had been stuck at around TMX 1.2, to the current TMX format: * Reader: object templates, infinite maps with chunked layers, class properties, Wang sets, editor settings, tileset transformations, embedded and cropped tile images, group layers, parallax, tint colors, zstd-compressed and CSV-encoded layer data and SVG tilesets. * Writer: always stamps the current format version and supports CSV encoding, zstd compression, chunked infinite layers and deterministic, deduplicated property output. * Robustness: XML escaping, XXE-hardened parsing, errors on truncated or invalid tile data, correct unsigned gid handling and locale-independent number formatting. * Renderers: new oblique renderer, rewritten hexagonal renderer, flip flag rendering, group layer opacity and corrected rotation pivot. * The zstd-jni and jsvg dependencies can be excluded by consumers that use neither zstd compression nor SVG tilesets. * CI: libtiled-java is now built and tested on GitHub Actions whenever files under util/java change. * Adds extensive tests, including pixel-exact flip rendering checks and template resolution against the sticker-knight example. Closes #2876 Closes #2745 --- .github/workflows/java.yml | 33 + util/java/libtiled-java/README.md | 28 + util/java/libtiled-java/pom.xml | 10 + .../java/org/mapeditor/core/AnimatedTile.java | 56 + .../src/main/java/org/mapeditor/core/Map.java | 37 + .../java/org/mapeditor/core/MapObject.java | 13 +- .../java/org/mapeditor/core/Properties.java | 34 +- .../main/java/org/mapeditor/core/Sprite.java | 2 + .../main/java/org/mapeditor/core/Tile.java | 22 + .../main/java/org/mapeditor/core/TileSet.java | 77 +- .../core/UnsupportedImageFormatException.java | 41 + .../java/org/mapeditor/io/TMXMapReader.java | 1015 ++++++++++++++--- .../java/org/mapeditor/io/TMXMapWriter.java | 927 ++++++++++++--- .../java/org/mapeditor/io/xml/XMLWriter.java | 34 +- .../java/org/mapeditor/util/ImageHelper.java | 98 +- .../org/mapeditor/view/AbstractRenderer.java | 236 ++++ .../org/mapeditor/view/HexagonalRenderer.java | 505 ++++---- .../org/mapeditor/view/IsometricRenderer.java | 139 +-- .../org/mapeditor/view/ObliqueRenderer.java | 192 ++++ .../mapeditor/view/OrthogonalRenderer.java | 188 ++- .../src/main/resources/bindings.xjb | 67 +- .../libtiled-java/src/main/resources/map.xsd | 439 ++++++- .../java/org/mapeditor/io/MapReaderTest.java | 321 ++++++ .../HexagonalRendererObjectShapeTest.java | 140 +++ .../mapeditor/view/HexagonalRendererTest.java | 226 ++++ .../view/OrthogonalRendererFlipTest.java | 112 ++ .../OrthogonalRendererObjectShapeTest.java | 190 +++ .../src/test/resources/infinite/infinite.tmx | 43 + .../modern_features/modern_features.tmx | 47 + .../templates/rect_template.tx | 8 + .../resources/svg_tileset/svg_tileset.tmx | 10 + .../resources/svg_tileset/svg_tileset.tsx | 4 + .../test/resources/svg_tileset/tileset.svg | 32 + .../resources/unsupported_image/desert.tmx | 10 + .../resources/unsupported_image/desert.tsx | 4 + .../resources/unsupported_image/image.webp | 1 + .../src/main/java/TMXViewer.java | 132 ++- 37 files changed, 4632 insertions(+), 841 deletions(-) create mode 100644 .github/workflows/java.yml create mode 100644 util/java/libtiled-java/src/main/java/org/mapeditor/core/UnsupportedImageFormatException.java create mode 100644 util/java/libtiled-java/src/main/java/org/mapeditor/view/AbstractRenderer.java create mode 100644 util/java/libtiled-java/src/main/java/org/mapeditor/view/ObliqueRenderer.java create mode 100644 util/java/libtiled-java/src/test/java/org/mapeditor/view/HexagonalRendererObjectShapeTest.java create mode 100644 util/java/libtiled-java/src/test/java/org/mapeditor/view/HexagonalRendererTest.java create mode 100644 util/java/libtiled-java/src/test/java/org/mapeditor/view/OrthogonalRendererFlipTest.java create mode 100644 util/java/libtiled-java/src/test/java/org/mapeditor/view/OrthogonalRendererObjectShapeTest.java create mode 100644 util/java/libtiled-java/src/test/resources/infinite/infinite.tmx create mode 100644 util/java/libtiled-java/src/test/resources/modern_features/modern_features.tmx create mode 100644 util/java/libtiled-java/src/test/resources/modern_features/templates/rect_template.tx create mode 100644 util/java/libtiled-java/src/test/resources/svg_tileset/svg_tileset.tmx create mode 100644 util/java/libtiled-java/src/test/resources/svg_tileset/svg_tileset.tsx create mode 100644 util/java/libtiled-java/src/test/resources/svg_tileset/tileset.svg create mode 100644 util/java/libtiled-java/src/test/resources/unsupported_image/desert.tmx create mode 100644 util/java/libtiled-java/src/test/resources/unsupported_image/desert.tsx create mode 100644 util/java/libtiled-java/src/test/resources/unsupported_image/image.webp diff --git a/.github/workflows/java.yml b/.github/workflows/java.yml new file mode 100644 index 0000000000..8a6ad5f6af --- /dev/null +++ b/.github/workflows/java.yml @@ -0,0 +1,33 @@ +name: Build libtiled-java + +on: + push: + branches: + - master + paths: + - 'util/java/**' + - '.github/workflows/java.yml' + pull_request: + paths: + - 'util/java/**' + - '.github/workflows/java.yml' + +jobs: + build: + + runs-on: ubuntu-24.04 + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Java + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 21 + cache: maven + + - name: Build and test + run: mvn --batch-mode clean install + working-directory: util/java diff --git a/util/java/libtiled-java/README.md b/util/java/libtiled-java/README.md index 6753c645af..8af6a98213 100644 --- a/util/java/libtiled-java/README.md +++ b/util/java/libtiled-java/README.md @@ -36,6 +36,34 @@ Add the following to your `build.sbt`: libraryDependencies += "org.mapeditor" % "libtiled" % "x.y.z" ``` +## Excluding unused dependencies + +libtiled depends on [zstd-jni](https://github.com/luben/zstd-jni) to read and +write zstd-compressed layer data and on +[jsvg](https://github.com/weisJ/jsvg) to render SVG tilesets. If your project +uses neither, you can exclude them to reduce its size: + +```xml + + org.mapeditor + libtiled + x.y.z + + + com.github.luben + zstd-jni + + + com.github.weisj + jsvg + + + +``` + +Loading a zstd-compressed map or an SVG tileset without the respective +dependency fails with an error naming the missing dependency. + ## Building To make libtiled.jar, install [Apache Maven](http://maven.apache.org/) and run the following command: diff --git a/util/java/libtiled-java/pom.xml b/util/java/libtiled-java/pom.xml index 3b0ebdf1e1..a5c86cbda2 100644 --- a/util/java/libtiled-java/pom.xml +++ b/util/java/libtiled-java/pom.xml @@ -27,6 +27,16 @@ + + com.github.luben + zstd-jni + 1.5.6-8 + + + com.github.weisj + jsvg + 2.0.0 + org.junit.vintage junit-vintage-engine diff --git a/util/java/libtiled-java/src/main/java/org/mapeditor/core/AnimatedTile.java b/util/java/libtiled-java/src/main/java/org/mapeditor/core/AnimatedTile.java index af1e24b3af..faf8d199c0 100644 --- a/util/java/libtiled-java/src/main/java/org/mapeditor/core/AnimatedTile.java +++ b/util/java/libtiled-java/src/main/java/org/mapeditor/core/AnimatedTile.java @@ -30,6 +30,9 @@ */ package org.mapeditor.core; +import java.awt.image.BufferedImage; +import java.util.List; + /** * Animated tiles take advantage of the Sprite class internally to handle * animation using an array of tiles. @@ -39,7 +42,10 @@ */ public class AnimatedTile extends Tile { + private static final int DEFAULT_FRAME_DURATION_MS = 100; + private Sprite sprite; + private final long animationStartTimeMs = System.currentTimeMillis(); /** * Constructor for AnimatedTile. @@ -111,4 +117,54 @@ public int countKeys() { public Sprite getSprite() { return sprite; } + + /** {@inheritDoc} */ + @Override + public BufferedImage getImage() { + final Animation animation = getAnimation(); + if (animation != null && animation.getFrame() != null && !animation.getFrame().isEmpty()) { + final TileSet tileSet = getTileSet(); + if (tileSet == null) { + return super.getImage(); + } + + final List frames = animation.getFrame(); + int totalDuration = 0; + for (Frame frame : frames) { + int duration = frame.getDuration() != null ? frame.getDuration() : DEFAULT_FRAME_DURATION_MS; + if (duration > 0) { + totalDuration += duration; + } + } + if (totalDuration <= 0) { + return super.getImage(); + } + + long elapsed = (System.currentTimeMillis() - animationStartTimeMs) % totalDuration; + if (elapsed < 0) { + // The system clock may move backwards. + elapsed += totalDuration; + } + long time = 0; + for (Frame frame : frames) { + int duration = frame.getDuration() != null ? frame.getDuration() : DEFAULT_FRAME_DURATION_MS; + if (duration <= 0) { + duration = DEFAULT_FRAME_DURATION_MS; + } + time += duration; + if (elapsed < time) { + Tile frameTile = tileSet.getTile(frame.getTileid()); + if (frameTile != null && frameTile != this) { + return frameTile.getImage(); + } + break; + } + } + } + + if (sprite != null && sprite.getCurrentKey() != null) { + return sprite.getCurrentFrame().getImage(); + } + return super.getImage(); + } } diff --git a/util/java/libtiled-java/src/main/java/org/mapeditor/core/Map.java b/util/java/libtiled-java/src/main/java/org/mapeditor/core/Map.java index 8271b2b936..a15f3297a5 100644 --- a/util/java/libtiled-java/src/main/java/org/mapeditor/core/Map.java +++ b/util/java/libtiled-java/src/main/java/org/mapeditor/core/Map.java @@ -49,6 +49,43 @@ public class Map extends MapData implements Iterable { private String filename; + private Integer editorChunkWidth; + private Integer editorChunkHeight; + private String exportTarget; + private String exportFormat; + + public Integer getEditorChunkWidth() { + return editorChunkWidth; + } + + public void setEditorChunkWidth(Integer editorChunkWidth) { + this.editorChunkWidth = editorChunkWidth; + } + + public Integer getEditorChunkHeight() { + return editorChunkHeight; + } + + public void setEditorChunkHeight(Integer editorChunkHeight) { + this.editorChunkHeight = editorChunkHeight; + } + + public String getExportTarget() { + return exportTarget; + } + + public void setExportTarget(String exportTarget) { + this.exportTarget = exportTarget; + } + + public String getExportFormat() { + return exportFormat; + } + + public void setExportFormat(String exportFormat) { + this.exportFormat = exportFormat; + } + /** * Constructor for Map. */ diff --git a/util/java/libtiled-java/src/main/java/org/mapeditor/core/MapObject.java b/util/java/libtiled-java/src/main/java/org/mapeditor/core/MapObject.java index f4a10dc5cd..3e423312cd 100644 --- a/util/java/libtiled-java/src/main/java/org/mapeditor/core/MapObject.java +++ b/util/java/libtiled-java/src/main/java/org/mapeditor/core/MapObject.java @@ -40,7 +40,8 @@ import java.io.File; import java.io.IOException; -import javax.imageio.ImageIO; +import org.mapeditor.util.ImageHelper; + import jakarta.xml.bind.annotation.XmlAccessType; import jakarta.xml.bind.annotation.XmlAccessorType; @@ -62,6 +63,7 @@ public class MapObject extends MapObjectData implements Cloneable { private boolean flipHorizontal; private boolean flipVertical; private boolean flipDiagonal; + private boolean rotatedHexagonal120; /** * Constructor for MapObject. @@ -72,8 +74,6 @@ public MapObject() { this.name = ""; this.type = ""; this.imageSource = ""; - this.flipHorizontal = false; - this.flipVertical = false; } /** @@ -182,9 +182,9 @@ public void setImageSource(String source) { imageSource = source; // Attempt to read the image - if (imageSource.length() > 0) { + if (!imageSource.isEmpty()) { try { - image = ImageIO.read(new File(imageSource)); + image = ImageHelper.readImage(new File(imageSource)); } catch (IOException e) { image = null; } @@ -222,6 +222,9 @@ public void setTile(Tile tile) { public boolean getFlipDiagonal() { return flipDiagonal; } public void setFlipDiagonal(boolean flip) { this.flipDiagonal = flip; } + public boolean getRotatedHexagonal120() { return rotatedHexagonal120; } + public void setRotatedHexagonal120(boolean rotated) { this.rotatedHexagonal120 = rotated; } + /** * Returns the image to be used when drawing this object. This image is * scaled to the size of the object. diff --git a/util/java/libtiled-java/src/main/java/org/mapeditor/core/Properties.java b/util/java/libtiled-java/src/main/java/org/mapeditor/core/Properties.java index 40dc0bf6eb..581df60c42 100644 --- a/util/java/libtiled-java/src/main/java/org/mapeditor/core/Properties.java +++ b/util/java/libtiled-java/src/main/java/org/mapeditor/core/Properties.java @@ -64,6 +64,36 @@ public void setProperty(String name, String value) { properties.add(property); } + /** + * setProperty with type. + * + * @param name a {@link java.lang.String} object. + * @param value a {@link java.lang.String} object. + * @param type a {@link org.mapeditor.core.PropertyType} object. + */ + public void setProperty(String name, String value, PropertyType type) { + setProperty(name, value, type, null); + } + + /** + * setProperty with type and custom property type name. + * + * @param name a {@link java.lang.String} object. + * @param value a {@link java.lang.String} object. + * @param type a {@link org.mapeditor.core.PropertyType} object. + * @param propertyTypeName the custom type name (optional). + */ + public void setProperty(String name, String value, PropertyType type, String propertyTypeName) { + Property property = new Property(); + property.setName(name); + property.setValue(value); + property.setType(type); + if (propertyTypeName != null) { + property.setPropertyTypeName(propertyTypeName); + } + properties.add(property); + } + /** * getProperty. * @@ -112,9 +142,7 @@ public boolean isEmpty() { * @return a {@link java.util.List} object. */ public List keySet() { - List keys = new ArrayList<>(); - properties.forEach(property -> keys.add(property.getName())); - return keys; + return properties.stream().map(Property::getName).collect(java.util.stream.Collectors.toList()); } /** diff --git a/util/java/libtiled-java/src/main/java/org/mapeditor/core/Sprite.java b/util/java/libtiled-java/src/main/java/org/mapeditor/core/Sprite.java index cd6d57711d..df8ff9b82a 100644 --- a/util/java/libtiled-java/src/main/java/org/mapeditor/core/Sprite.java +++ b/util/java/libtiled-java/src/main/java/org/mapeditor/core/Sprite.java @@ -160,6 +160,7 @@ public Sprite() { * @param frames an array of {@link org.mapeditor.core.Tile} objects. */ public Sprite(Tile[] frames) { + this(); setFrames(frames); } @@ -172,6 +173,7 @@ public Sprite(Tile[] frames) { * @param totalFrames a int. */ public Sprite(Image image, int fpl, int border, int totalFrames) { + this(); Tile[] frames = null; this.fpl = fpl; borderWidth = border; diff --git a/util/java/libtiled-java/src/main/java/org/mapeditor/core/Tile.java b/util/java/libtiled-java/src/main/java/org/mapeditor/core/Tile.java index 126cbcaf06..0e5f58e40f 100644 --- a/util/java/libtiled-java/src/main/java/org/mapeditor/core/Tile.java +++ b/util/java/libtiled-java/src/main/java/org/mapeditor/core/Tile.java @@ -176,6 +176,28 @@ public void setSource(String source) { this.source = source; } + /** + * Sets the collision object group for this tile. + * + * @param og the object group defining collision shapes + */ + public void setObjectGroup(ObjectGroup og) { + getObjectgroup().clear(); + getObjectgroup().add(og); + } + + /** + * Gets the collision object group for this tile, if any. + * + * @return the first ObjectGroup, or null + */ + public ObjectGroup getCollisionObjectGroup() { + if (objectgroup != null && !objectgroup.isEmpty()) { + return (ObjectGroup) objectgroup.get(0); + } + return null; + } + /** {@inheritDoc} */ @Override public Properties getProperties() { diff --git a/util/java/libtiled-java/src/main/java/org/mapeditor/core/TileSet.java b/util/java/libtiled-java/src/main/java/org/mapeditor/core/TileSet.java index 093fc1dc8a..48619bdf7e 100644 --- a/util/java/libtiled-java/src/main/java/org/mapeditor/core/TileSet.java +++ b/util/java/libtiled-java/src/main/java/org/mapeditor/core/TileSet.java @@ -32,6 +32,7 @@ package org.mapeditor.core; import java.awt.Color; +import java.awt.Graphics2D; import java.awt.Image; import java.awt.Toolkit; import java.awt.image.BufferedImage; @@ -44,13 +45,13 @@ import java.util.NoSuchElementException; import java.util.TreeMap; -import javax.imageio.ImageIO; import jakarta.xml.bind.Marshaller; import jakarta.xml.bind.Unmarshaller; import jakarta.xml.bind.annotation.XmlAccessType; import jakarta.xml.bind.annotation.XmlAccessorType; import jakarta.xml.bind.annotation.XmlRootElement; +import org.mapeditor.util.ImageHelper; import org.mapeditor.util.TileCutter; import org.mapeditor.util.TransparentImageFilter; import org.mapeditor.util.BasicTileCutter; @@ -110,27 +111,27 @@ public void importTileBitmap(String imgFilename, TileCutter cutter) throws IOExc * @throws java.io.IOException if any. */ public void importTileBitmap(final URL imgUrl, final TileCutter cutter) throws IOException { - Image image = ImageIO.read(imgUrl); + importTileBitmap(loadAndFilterImage(ImageHelper.readImage(imgUrl), imgUrl.toString()), cutter); + } + + private BufferedImage loadAndFilterImage(Image image, String source) throws IOException { if (image == null) { - throw new IOException("Failed to load " + imgUrl); + throw new UnsupportedImageFormatException("Failed to load " + source); } - - Toolkit tk = Toolkit.getDefaultToolkit(); - if (transparentColor != null) { - int rgb = transparentColor.getRGB(); - image = tk.createImage( + image = Toolkit.getDefaultToolkit().createImage( new FilteredImageSource(image.getSource(), - new TransparentImageFilter(rgb))); + new TransparentImageFilter(transparentColor.getRGB()))); } - BufferedImage buffered = new BufferedImage( - image.getWidth(null), - image.getHeight(null), - BufferedImage.TYPE_INT_ARGB); - buffered.getGraphics().drawImage(image, 0, 0, null); - - importTileBitmap(buffered, cutter); + image.getWidth(null), image.getHeight(null), BufferedImage.TYPE_INT_ARGB); + Graphics2D g = buffered.createGraphics(); + try { + g.drawImage(image, 0, 0, null); + } finally { + g.dispose(); + } + return buffered; } /** @@ -140,7 +141,7 @@ public void importTileBitmap(final URL imgUrl, final TileCutter cutter) throws I * @param tileBitmap the image to be used, must not be null * @param cutter the tile cutter, must not be null */ - private void importTileBitmap(BufferedImage tileBitmap, TileCutter cutter) { + public void importTileBitmap(BufferedImage tileBitmap, TileCutter cutter) { assert tileBitmap != null; assert cutter != null; @@ -173,31 +174,10 @@ private void importTileBitmap(BufferedImage tileBitmap, TileCutter cutter) { * @throws IOException * @see TileSet#importTileBitmap(BufferedImage,TileCutter) */ - private void refreshImportedTileBitmap() - throws IOException { - String imgFilename = tilebmpFile.getPath(); - - Image image = ImageIO.read(new File(imgFilename)); - if (image == null) { - throw new IOException("Failed to load " + tilebmpFile); - } - - Toolkit tk = Toolkit.getDefaultToolkit(); - - if (transparentColor != null) { - int rgb = transparentColor.getRGB(); - image = tk.createImage( - new FilteredImageSource(image.getSource(), - new TransparentImageFilter(rgb))); - } - - BufferedImage buffered = new BufferedImage( - image.getWidth(null), - image.getHeight(null), - BufferedImage.TYPE_INT_ARGB); - buffered.getGraphics().drawImage(image, 0, 0, null); - - refreshImportedTileBitmap(buffered); + private void refreshImportedTileBitmap() throws IOException { + refreshImportedTileBitmap(loadAndFilterImage( + ImageHelper.readImage(new File(tilebmpFile.getPath())), + tilebmpFile.toString())); } /** @@ -410,8 +390,7 @@ public Color getTransparentColor() { * @param marshaller the marshaller doing the marshalling. */ public void beforeMarshal(Marshaller marshaller) { - internalTiles = new ArrayList<>(); - tiles.entrySet().forEach(entry -> internalTiles.add(entry.getValue())); + internalTiles = new ArrayList<>(tiles.values()); } /** @@ -441,4 +420,14 @@ public String toString() { public boolean isSetFromImage() { return tileSetImage != null; } + + /** + * Returns the tileset image, when this tileset was created from a single + * image. + * + * @return the tileset image, or null for image-collection tilesets + */ + public Image getTileSetImage() { + return tileSetImage; + } } diff --git a/util/java/libtiled-java/src/main/java/org/mapeditor/core/UnsupportedImageFormatException.java b/util/java/libtiled-java/src/main/java/org/mapeditor/core/UnsupportedImageFormatException.java new file mode 100644 index 0000000000..dc8c27fe94 --- /dev/null +++ b/util/java/libtiled-java/src/main/java/org/mapeditor/core/UnsupportedImageFormatException.java @@ -0,0 +1,41 @@ +/*- + * #%L + * libtiled + * %% + * Copyright (C) 2004 - 2026 Thorbjørn Lindeijer + * %% + * 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.core; + +import java.io.IOException; + +/** + * Thrown when an image file exists but its format is not supported by ImageIO + * (e.g. WebP). + */ +public class UnsupportedImageFormatException extends IOException { + public UnsupportedImageFormatException(String message) { + super(message); + } +} diff --git a/util/java/libtiled-java/src/main/java/org/mapeditor/io/TMXMapReader.java b/util/java/libtiled-java/src/main/java/org/mapeditor/io/TMXMapReader.java index c86939bda7..4f2f760eb5 100644 --- a/util/java/libtiled-java/src/main/java/org/mapeditor/io/TMXMapReader.java +++ b/util/java/libtiled-java/src/main/java/org/mapeditor/io/TMXMapReader.java @@ -43,33 +43,57 @@ import java.net.MalformedURLException; import java.net.URISyntaxException; import java.net.URL; -import java.util.Map.Entry; +import java.util.List; +import java.util.HashSet; +import java.util.Set; import java.util.StringTokenizer; import java.util.TreeMap; import java.util.zip.GZIPInputStream; import java.util.zip.InflaterInputStream; - -import javax.imageio.ImageIO; import java.util.Base64; +import java.util.function.Consumer; +import java.util.function.Predicate; +import java.util.function.DoubleConsumer; + +import com.github.luben.zstd.ZstdInputStream; import jakarta.xml.bind.JAXBContext; import jakarta.xml.bind.JAXBException; import jakarta.xml.bind.Unmarshaller; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; import org.mapeditor.core.AnimatedTile; import org.mapeditor.core.Group; import org.mapeditor.core.ImageLayer; import org.mapeditor.core.Map; +import org.mapeditor.core.MapLayer; import org.mapeditor.core.MapObject; import org.mapeditor.core.ObjectGroup; import org.mapeditor.core.Point; +import org.mapeditor.core.Polygon; +import org.mapeditor.core.Polyline; +import org.mapeditor.core.Ellipse; +import org.mapeditor.core.Animation; +import org.mapeditor.core.Frame; +import org.mapeditor.core.Sprite; import org.mapeditor.core.Properties; +import org.mapeditor.core.Property; import org.mapeditor.core.Tile; import org.mapeditor.core.TileLayer; +import org.mapeditor.core.Grid; +import org.mapeditor.core.ImageData; +import org.mapeditor.core.Orientation; import org.mapeditor.core.TileOffset; import org.mapeditor.core.TileSet; +import org.mapeditor.core.Transformations; +import org.mapeditor.core.UnsupportedImageFormatException; +import org.mapeditor.core.WangColor; +import org.mapeditor.core.WangCornerColor; +import org.mapeditor.core.WangEdgeColor; +import org.mapeditor.core.WangSet; +import org.mapeditor.core.WangSets; import org.mapeditor.util.BasicTileCutter; import org.mapeditor.util.ImageHelper; import org.mapeditor.util.StreamHelper; @@ -118,7 +142,8 @@ public class TMXMapReader { public TMXMapReader() throws JAXBException { unmarshaller = JAXBContext.newInstance( Map.class, TileSet.class, Tile.class, - AnimatedTile.class, ObjectGroup.class, ImageLayer.class).createUnmarshaller(); + AnimatedTile.class, ObjectGroup.class, ImageLayer.class, + org.mapeditor.core.Text.class).createUnmarshaller(); } String getError() { @@ -133,11 +158,11 @@ private static URL makeUrl(final String filename) throws MalformedURLException { } } - private static String getAttributeValue(Node node, String attribname) { + private static String getAttributeValue(Node node, String attrName) { final NamedNodeMap attributes = node.getAttributes(); String value = null; if (attributes != null) { - Node attribute = attributes.getNamedItem(attribname); + Node attribute = attributes.getNamedItem(attrName); if (attribute != null) { value = attribute.getNodeValue(); } @@ -145,31 +170,125 @@ private static String getAttributeValue(Node node, String attribname) { return value; } - private static int getAttribute(Node node, String attribname, int def) { - final String attr = getAttributeValue(node, attribname); + private static int getAttribute(Node node, String attrName, int fallback) { + final String attr = getAttributeValue(node, attrName); if (attr != null) { return Integer.parseInt(attr); } else { - return def; + return fallback; } } - private static float getFloatAttribute(Node node, String attribname, float def) { - final String attr = getAttributeValue(node, attribname); + private static double getDoubleAttribute(Node node, String attrName, double fallback) { + final String attr = getAttributeValue(node, attrName); if (attr != null) { - return Float.parseFloat(attr); + return Double.parseDouble(attr); } else { - return def; + return fallback; } } - private static double getDoubleAttribute(Node node, String attribname, double def) { - final String attr = getAttributeValue(node, attribname); - if (attr != null) { - return Double.parseDouble(attr); - } else { - return def; + private static Integer getOptionalIntAttribute(Node node, String attrName) { + final String attr = getAttributeValue(node, attrName); + if (attr == null || attr.isEmpty()) { + return null; + } + return Integer.parseInt(attr); + } + + private static void setStrIfPresent(Node node, String attrName, Consumer setter) { + String value = getAttributeValue(node, attrName); + if (value != null) { + setter.accept(value); + } + } + + private static void setStrIf(Node node, String attrName, Predicate cond, Consumer setter) { + String value = getAttributeValue(node, attrName); + if (value != null && cond.test(value)) { + setter.accept(value); + } + } + + private static void setIntIfPresentAndNonZero(Node node, String attrName, Consumer setter) { + int value = getAttribute(node, attrName, 0); + if (value > 0) { + setter.accept(value); + } + } + + private static void setIntIfPresent(Node node, String attrName, Consumer setter) { + String value = getAttributeValue(node, attrName); + if (value != null) { + setter.accept(Integer.valueOf(value)); + } + } + + private static void setFloatIfPresent(Node node, String attrName, Consumer setter) { + String value = getAttributeValue(node, attrName); + if (value != null) { + setter.accept(Float.parseFloat(value)); + } + } + + private static void setDoubleIfPresent(Node node, String attrName, DoubleConsumer setter) { + String value = getAttributeValue(node, attrName); + if (value != null) { + setter.accept(Double.parseDouble(value)); + } + } + + private static void setBoolIfPresent(Node node, String attrName, Consumer setter) { + String value = getAttributeValue(node, attrName); + if (value != null) { + setter.accept("1".equals(value) || "true".equalsIgnoreCase(value)); + } + } + + private static WangColor toWangColor(String name, String color, Integer tile, Number probability) { + WangColor wc = new WangColor(); + wc.setName(name); + wc.setColor(color); + wc.setTile(tile); + if (probability != null) { + wc.setProbability(probability.doubleValue()); + } + return wc; + } + + private void applyLayerAttributes(TileLayer ml, Node t) { + ml.setName(getAttributeValue(t, "name")); + + setFloatIfPresent(t, "opacity", ml::setOpacity); + setDoubleIfPresent(t, "offsetx", ml::setOffsetX); + setDoubleIfPresent(t, "offsety", ml::setOffsetY); + setDoubleIfPresent(t, "parallaxx", ml::setParallaxx); + setDoubleIfPresent(t, "parallaxy", ml::setParallaxy); + setStrIfPresent(t, "tintcolor", ml::setTintcolor); + setStrIfPresent(t, "class", ml::setClassName); + setStrIf(t, "mode", s -> !s.isEmpty(), ml::setMode); + + readProperties(t.getChildNodes(), ml.getProperties()); + } + + private static final int MAX_GZIP_BUFFER_SIZE = 32 * 1024; + + private static InputStream createDecompressStream(ByteArrayInputStream bais, String comp, int bufferSize, String context) throws IOException { + if ("gzip".equalsIgnoreCase(comp)) { + return new GZIPInputStream(bais, Math.max(1, Math.min(bufferSize, MAX_GZIP_BUFFER_SIZE))); + } else if ("zlib".equalsIgnoreCase(comp)) { + return new InflaterInputStream(bais); + } else if ("zstd".equalsIgnoreCase(comp)) { + try { + return new ZstdInputStream(bais); + } catch (NoClassDefFoundError e) { + throw new IOException("Reading zstd-compressed data requires the" + + " com.github.luben:zstd-jni dependency", e); + } + } else if (comp != null && !comp.isEmpty()) { + throw new IOException("Unrecognized compression method \"" + comp + "\" for " + context); } + return bais; } private T unmarshalClass(Node node, Class type) throws JAXBException { @@ -196,7 +315,7 @@ private BufferedImage unmarshalImage(Node t, URL baseDir) throws IOException { throw new IOException(e); } } - img = ImageIO.read(url); + img = ImageHelper.readImage(url); } else { NodeList nl = t.getChildNodes(); @@ -218,13 +337,30 @@ private BufferedImage unmarshalImage(Node t, URL baseDir) throws IOException { return img; } + /** + * Creates a {@link DocumentBuilderFactory} hardened against XML External + * Entity (XXE) attacks. External entity resolution and external DTD + * loading are disabled, while DOCTYPE declarations remain allowed so + * legacy TMX/TSX files with a DTD reference still parse. + */ + private static DocumentBuilderFactory newSecureDocumentBuilderFactory() throws ParserConfigurationException { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); + factory.setXIncludeAware(false); + factory.setExpandEntityReferences(false); + return factory; + } + private TileSet unmarshalTilesetFile(InputStream in, URL file) throws Exception { TileSet set = null; Node tsNode; - DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + DocumentBuilderFactory factory = newSecureDocumentBuilderFactory(); try { DocumentBuilder builder = factory.newDocumentBuilder(); + builder.setEntityResolver(entityResolver); //builder.setErrorHandler(new XMLErrorHandler()); Document tsDoc = builder.parse(StreamHelper.buffered(in), "."); @@ -270,6 +406,13 @@ private TileSet unmarshalTileset(Node t, boolean isExternalTileset) throws Excep "ignoring source option for tileset %s%n", set.getName()); } + if (source != null && source.startsWith(":/")) { + System.out.printf("Warning: Qt resource path is not supported in Java - " + + "skipping tileset source '%s'%n", source); + source = null; + set.setSource(null); + } + if (source != null) { source = replacePathSeparator(source); URL url = URLHelper.resolve(xmlPath, source); @@ -300,11 +443,25 @@ private TileSet processTileset(Node t) throws Exception { final String name = getAttributeValue(t, "name"); set.setName(name); + setStrIfPresent(t, "objectalignment", set::setObjectalignment); + setStrIfPresent(t, "tilerendersize", set::setTilerendersize); + setStrIfPresent(t, "fillmode", set::setFillmode); + setStrIfPresent(t, "class", set::setClassName); + setStrIfPresent(t, "backgroundcolor", set::setBackgroundcolor); + setIntIfPresent(t, "tilecount", set::setTilecount); + setIntIfPresentAndNonZero(t, "columns", set::setColumns); + final int tileWidth = getAttribute(t, "tilewidth", map != null ? map.getTileWidth() : 0); final int tileHeight = getAttribute(t, "tileheight", map != null ? map.getTileHeight() : 0); final int tileSpacing = getAttribute(t, "spacing", 0); final int tileMargin = getAttribute(t, "margin", 0); + // Store the declared tile grid so image-collection tilesets keep it. + set.setTileWidth(tileWidth); + set.setTileHeight(tileHeight); + set.setTileSpacing(tileSpacing); + set.setTileMargin(tileMargin); + boolean hasTilesetImage = false; NodeList children = t.getChildNodes(); @@ -339,29 +496,141 @@ private TileSet processTileset(Node t) throws Exception { transStr = transStr.substring(1); } - int colorInt = Integer.parseInt(transStr, 16); - Color color = new Color(colorInt); - set.setTransparentColor(color); + try { + // Long.parseLong so 8-digit #AARRGGBB values fit + int colorInt = (int) Long.parseLong(transStr, 16); + Color color = new Color(colorInt, transStr.length() > 6); + set.setTransparentColor(color); + } catch (NumberFormatException e) { + System.out.printf( + "Warning: unrecognized transparent color '%s'%n", transStr); + } + } + + try { + set.importTileBitmap(sourcePath, new BasicTileCutter( + tileWidth, tileHeight, tileSpacing, tileMargin)); + } catch (UnsupportedImageFormatException e) { + System.out.printf("Warning: could not load tileset image '%s' - %s%n", + sourcePath, e.getMessage()); + hasTilesetImage = false; } - set.importTileBitmap(sourcePath, new BasicTileCutter( - tileWidth, tileHeight, tileSpacing, tileMargin)); + ImageData imgData = new ImageData(); + imgData.setSource(imgSource); + setIntIfPresent(child, "width", imgData::setWidth); + setIntIfPresent(child, "height", imgData::setHeight); + if (transStr != null) { + imgData.setTrans(transStr); + } + set.setImageData(imgData); + } else { + // Tileset image embedded as base64 + BufferedImage img = unmarshalImage(child, xmlPath); + if (img != null) { + set.importTileBitmap(img, new BasicTileCutter( + tileWidth, tileHeight, tileSpacing, tileMargin)); + hasTilesetImage = true; + + ImageData imgData = new ImageData(); + setStrIfPresent(child, "format", imgData::setFormat); + setIntIfPresent(child, "width", imgData::setWidth); + setIntIfPresent(child, "height", imgData::setHeight); + set.setImageData(imgData); + } } } else if (child.getNodeName().equalsIgnoreCase("tile")) { Tile tile = unmarshalTile(set, child, xmlPath); if (!hasTilesetImage || tile.getId() > set.getMaxTileId()) { set.addTile(tile); } else { + // Merge the parsed per-tile data onto the tile that was + // created when cutting the tileset image. Tile myTile = set.getTile(tile.getId()); myTile.setProperties(tile.getProperties()); + if (tile.getType() != null) { + myTile.setType(tile.getType()); + } + if (tile.getClassName() != null) { + myTile.setClassName(tile.getClassName()); + } + if (tile.getProbability() != null) { + myTile.setProbability(tile.getProbability()); + } + if (tile.getAnimation() != null) { + myTile.setAnimation(tile.getAnimation()); + } + if (tile.getCollisionObjectGroup() != null) { + myTile.setObjectGroup(tile.getCollisionObjectGroup()); + } + if (tile.getImageX() != null) { + myTile.setImageX(tile.getImageX()); + } + if (tile.getImageY() != null) { + myTile.setImageY(tile.getImageY()); + } + if (tile.getImageWidth() != null) { + myTile.setImageWidth(tile.getImageWidth()); + } + if (tile.getImageHeight() != null) { + myTile.setImageHeight(tile.getImageHeight()); + } //TODO: there is the possibility here of overlaying images, // which some people may want } } else if (child.getNodeName().equalsIgnoreCase("tileoffset")) { TileOffset tileoffset = new TileOffset(); - tileoffset.setX(Integer.valueOf(getAttributeValue(child, "x"))); - tileoffset.setY(Integer.valueOf(getAttributeValue(child, "y"))); + tileoffset.setX(getAttribute(child, "x", 0)); + tileoffset.setY(getAttribute(child, "y", 0)); set.setTileoffset(tileoffset); + } else if (child.getNodeName().equalsIgnoreCase("transformations")) { + Transformations trans = new Transformations(); + setBoolIfPresent(child, "hflip", trans::setHflip); + setBoolIfPresent(child, "vflip", trans::setVflip); + setBoolIfPresent(child, "rotate", trans::setRotate); + setBoolIfPresent(child, "preferuntransformed", trans::setPreferuntransformed); + set.setTransformations(trans); + } else if (child.getNodeName().equalsIgnoreCase("properties")) { + Properties tilesetProps = new Properties(); + readProperties(child.getChildNodes(), tilesetProps); + set.setProperties(tilesetProps); + } else if (child.getNodeName().equalsIgnoreCase("grid")) { + Grid grid = new Grid(); + String gridOrientation = getAttributeValue(child, "orientation"); + if (gridOrientation != null) { + grid.setOrientation(Orientation.fromValue(gridOrientation)); + } + setIntIfPresentAndNonZero(child, "width", grid::setWidth); + setIntIfPresentAndNonZero(child, "height", grid::setHeight); + set.setGrid(grid); + } else if (child.getNodeName().equalsIgnoreCase("wangsets")) { + WangSets wangSets = unmarshalClass(child, WangSets.class); + if (wangSets != null) { + for (WangSet ws : wangSets.getWangset()) { + // Convert old-style wangcornercolor/wangedgecolor to unified wangcolor + for (WangCornerColor wcc : ws.getWangcornercolor()) { + WangColor wangColor = toWangColor(wcc.getName(), wcc.getColor(), wcc.getTile(), wcc.getProbability()); + ws.getWangcolor().add(wangColor); + } + for (WangEdgeColor wec : ws.getWangedgecolor()) { + WangColor wangColor = toWangColor(wec.getName(), wec.getColor(), wec.getTile(), wec.getProbability()); + ws.getWangcolor().add(wangColor); + } + // Infer WangSet type from old-style colors if not set + if (ws.getType() == null || ws.getType().isEmpty()) { + boolean hasCorner = !ws.getWangcornercolor().isEmpty(); + boolean hasEdge = !ws.getWangedgecolor().isEmpty(); + if (hasCorner && !hasEdge) { + ws.setType("corner"); + } else if (hasEdge && !hasCorner) { + ws.setType("edge"); + } else if (hasCorner && hasEdge) { + ws.setType("mixed"); + } + } + } + set.setWangsets(wangSets); + } } } @@ -369,92 +638,354 @@ private TileSet processTileset(Node t) throws Exception { } private MapObject readMapObject(Node t) throws Exception { + // Step 1: Read template if present + final String templatePath = getAttributeValue(t, "template"); + MapObject templateObj = null; + if (templatePath != null && !templatePath.isEmpty()) { + templateObj = readTemplateFile(templatePath); + } + + // Step 2: Read TMX attributes final int id = getAttribute(t, "id", 0); final String name = getAttributeValue(t, "name"); - final String type = getAttributeValue(t, "type"); + String type = getAttributeValue(t, "class"); + if (type == null || type.isEmpty()) { + type = getAttributeValue(t, "type"); + } final String gid = getAttributeValue(t, "gid"); final double x = getDoubleAttribute(t, "x", 0); final double y = getDoubleAttribute(t, "y", 0); - final double width = getDoubleAttribute(t, "width", 0); - final double height = getDoubleAttribute(t, "height", 0); - final double rotation = getDoubleAttribute(t, "rotation", 0); + // Width/height/rotation: use TMX value if present, else template value + final String widthStr = getAttributeValue(t, "width"); + final String heightStr = getAttributeValue(t, "height"); + final String rotationStr = getAttributeValue(t, "rotation"); + + double width = widthStr != null && !widthStr.isEmpty() ? Double.parseDouble(widthStr) : + (templateObj != null && templateObj.getWidth() != null ? templateObj.getWidth() : 0); + double height = heightStr != null && !heightStr.isEmpty() ? Double.parseDouble(heightStr) : + (templateObj != null && templateObj.getHeight() != null ? templateObj.getHeight() : 0); + double rotation = rotationStr != null && !rotationStr.isEmpty() ? Double.parseDouble(rotationStr) : + (templateObj != null ? templateObj.getRotation() : 0); + + // Step 3: Create object with merged values MapObject obj = new MapObject(x, y, width, height, rotation); obj.setShape(obj.getBounds()); if (id != 0) { obj.setId(id); } - if (name != null) { - obj.setName(name); + + // TMX overrides template + final String templateName = name != null ? name : (templateObj != null ? templateObj.getName() : null); + if (templateName != null) { + obj.setName(templateName); } - if (type != null) { - obj.setType(type); + String templateType = type != null ? type : (templateObj != null ? templateObj.getType() : null); + if (templateType != null) { + obj.setType(templateType); + } + + // Store template path for round-trip + if (templatePath != null) { + obj.setTemplate(templatePath); } + + // Opacity: use the TMX value when present, else the template's + final String opacityStr = getAttributeValue(t, "opacity"); + if (opacityStr != null) { + obj.setOpacity(Double.parseDouble(opacityStr)); + } else if (templateObj != null && templateObj.getOpacity() != null) { + obj.setOpacity(templateObj.getOpacity()); + } + + // Visibility: use the TMX value when present, else the template's + final String visibleStr = getAttributeValue(t, "visible"); + if (visibleStr != null) { + obj.setVisible("1".equals(visibleStr) || "true".equalsIgnoreCase(visibleStr)); + } else if (templateObj != null && templateObj.isVisible() != null) { + obj.setVisible(templateObj.isVisible()); + } else { + obj.setVisible(true); + } + + // GID/tile: TMX gid overrides template tile if (gid != null) { long tileId = Long.parseLong(gid); if ((tileId & ALL_FLAGS) != 0) { // Read out the flags - long flippedHorizontally = tileId & FLIPPED_HORIZONTALLY_FLAG; - long flippedVertically = tileId & FLIPPED_VERTICALLY_FLAG; - long flippedDiagonally = tileId & FLIPPED_DIAGONALLY_FLAG; - - obj.setFlipHorizontal(flippedHorizontally != 0); - obj.setFlipVertical(flippedVertically != 0); - obj.setFlipDiagonal(flippedDiagonally != 0); + obj.setFlipHorizontal((tileId & FLIPPED_HORIZONTALLY_FLAG) != 0); + obj.setFlipVertical((tileId & FLIPPED_VERTICALLY_FLAG) != 0); + obj.setFlipDiagonal((tileId & FLIPPED_DIAGONALLY_FLAG) != 0); + obj.setRotatedHexagonal120((tileId & ROTATED_HEXAGONAL_120_FLAG) != 0); // Clear the flags - tileId &= ~(FLIPPED_HORIZONTALLY_FLAG - | FLIPPED_VERTICALLY_FLAG - | FLIPPED_DIAGONALLY_FLAG); + tileId &= ~ALL_FLAGS; } Tile tile = getTileForTileGID((int) tileId); obj.setTile(tile); + } else if (templateObj != null && templateObj.getTile() != null) { + Tile templateTile = templateObj.getTile(); + Tile mapTile = findTileInMapTilesets(templateTile, templateTile.getTileSet()); + obj.setTile(mapTile != null ? mapTile : templateTile); + obj.setFlipHorizontal(templateObj.getFlipHorizontal()); + obj.setFlipVertical(templateObj.getFlipVertical()); + obj.setFlipDiagonal(templateObj.getFlipDiagonal()); + obj.setRotatedHexagonal120(templateObj.getRotatedHexagonal120()); + } + + // Tile objects saved without an explicit size default to the tile size. + if (obj.getTile() != null) { + Tile objTile = obj.getTile(); + if (width == 0 && objTile.getWidth() > 0) { + obj.setWidth((double) objTile.getWidth()); + } + if (height == 0 && objTile.getHeight() > 0) { + obj.setHeight((double) objTile.getHeight()); + } } + // Read child elements from TMX NodeList children = t.getChildNodes(); - for (int i = 0; i < children.getLength(); i++) { - Node child = children.item(i); - if ("image".equalsIgnoreCase(child.getNodeName())) { - String source = getAttributeValue(child, "source"); - if (source != null) { - if (!new File(source).isAbsolute()) { - source = URLHelper.resolve(xmlPath, source).toString(); + boolean hasShapeChild = readShapeChildren(children, obj, x, y); + + // Also check for image element + if (!hasShapeChild) { + for (int i = 0; i < children.getLength(); i++) { + Node child = children.item(i); + if ("image".equalsIgnoreCase(child.getNodeName())) { + String source = getAttributeValue(child, "source"); + if (source != null) { + if (!new File(source).isAbsolute()) { + source = URLHelper.resolve(xmlPath, source).toString(); + } + obj.setImageSource(source); } - obj.setImageSource(source); + hasShapeChild = true; + break; } - break; - } else if ("ellipse".equalsIgnoreCase(child.getNodeName())) { + } + } + + // If no shape child in TMX, inherit from template + if (!hasShapeChild && templateObj != null) { + if (templateObj.getPoint() != null) { + obj.setPoint(templateObj.getPoint()); + } else if (templateObj.getText() != null) { + obj.setText(templateObj.getText()); + } else if (templateObj.getCapsule() != null) { + obj.setCapsule(templateObj.getCapsule()); + } else if (templateObj.getEllipse() != null) { + obj.setEllipse(templateObj.getEllipse()); obj.setShape(new Ellipse2D.Double(x, y, width, height)); - } else if ("polygon".equalsIgnoreCase(child.getNodeName()) || "polyline".equalsIgnoreCase(child.getNodeName())) { - Path2D.Double shape = new Path2D.Double(); - final String pointsAttribute = getAttributeValue(child, "points"); - StringTokenizer st = new StringTokenizer(pointsAttribute, ", "); - boolean firstPoint = true; - while (st.hasMoreElements()) { - double pointX = Double.parseDouble(st.nextToken()); - double pointY = Double.parseDouble(st.nextToken()); - if (firstPoint) { - shape.moveTo(x + pointX, y + pointY); - firstPoint = false; - } else { - shape.lineTo(x + pointX, y + pointY); + } else if (templateObj.getPolygon() != null) { + obj.setPolygon(templateObj.getPolygon()); + Path2D.Double shape = buildPointsShape(templateObj.getPolygon().getPoints(), x, y, true); + obj.setShape(shape); + obj.setWidth(shape.getBounds2D().getWidth()); + obj.setHeight(shape.getBounds2D().getHeight()); + } else if (templateObj.getPolyline() != null) { + obj.setPolyline(templateObj.getPolyline()); + Path2D.Double shape = buildPointsShape(templateObj.getPolyline().getPoints(), x, y, false); + obj.setShape(shape); + obj.setWidth(shape.getBounds2D().getWidth()); + obj.setHeight(shape.getBounds2D().getHeight()); + } + } + + // Properties: merge template as base, TMX overrides + Properties tmxProps = new Properties(); + readProperties(children, tmxProps); + + if (templateObj != null && templateObj.getProperties() != null && !templateObj.getProperties().isEmpty()) { + Properties props = new Properties(); + Set tmxKeys = new HashSet<>(tmxProps.keySet()); + for (Property p : templateObj.getProperties().getProperties()) { + if (!tmxKeys.contains(p.getName())) { + props.setProperty(p.getName(), p.getValue(), p.getType(), p.getPropertyTypeName()); + } + } + for (Property p : tmxProps.getProperties()) { + props.setProperty(p.getName(), p.getValue(), p.getType(), p.getPropertyTypeName()); + } + obj.setProperties(props); + } else { + obj.setProperties(tmxProps); + } + + return obj; + } + + private MapObject readTemplateFile(String templatePath) throws Exception { + templatePath = replacePathSeparator(templatePath); + URL templateUrl = URLHelper.resolve(xmlPath, templatePath); + + DocumentBuilderFactory factory = newSecureDocumentBuilderFactory(); + try (InputStream in = StreamHelper.openStream(templateUrl)) { + DocumentBuilder builder = factory.newDocumentBuilder(); + builder.setEntityResolver(entityResolver); + Document doc = builder.parse(StreamHelper.buffered(in), "."); + Node templateNode = doc.getDocumentElement(); + + URL xmlPathSave = xmlPath; + xmlPath = URLHelper.getParent(templateUrl); + try { + TileSet templateTileset = null; + int templateFirstGid = 1; + MapObject templateObject = null; + + NodeList children = templateNode.getChildNodes(); + for (int i = 0; i < children.getLength(); i++) { + Node child = children.item(i); + if ("tileset".equalsIgnoreCase(child.getNodeName())) { + templateFirstGid = getAttribute(child, "firstgid", 1); + templateTileset = unmarshalTileset(child); + } else if ("object".equalsIgnoreCase(child.getNodeName())) { + templateObject = readTemplateObject(child, templateTileset, templateFirstGid); } } - shape.closePath(); - obj.setShape(shape); - obj.setBounds((Rectangle2D.Double) shape.getBounds2D()); - } else if ("point".equalsIgnoreCase(child.getNodeName())) { - obj.setPoint(new Point()); + + return templateObject; + } finally { + xmlPath = xmlPathSave; + } + } + } + + private MapObject readTemplateObject(Node t, TileSet templateTileset, int firstGid) throws Exception { + final String name = getAttributeValue(t, "name"); + String type = getAttributeValue(t, "class"); + if (type == null || type.isEmpty()) { + type = getAttributeValue(t, "type"); + } + final String gidStr = getAttributeValue(t, "gid"); + final double x = getDoubleAttribute(t, "x", 0); + final double y = getDoubleAttribute(t, "y", 0); + final double width = getDoubleAttribute(t, "width", 0); + final double height = getDoubleAttribute(t, "height", 0); + final double rotation = getDoubleAttribute(t, "rotation", 0); + + MapObject obj = new MapObject(x, y, width, height, rotation); + obj.setShape(obj.getBounds()); + if (name != null) obj.setName(name); + if (type != null) obj.setType(type); + + final int visible = getAttribute(t, "visible", 1); + obj.setVisible(visible == 1); + + if (gidStr != null && templateTileset != null) { + long tileId = Long.parseLong(gidStr); + if ((tileId & ALL_FLAGS) != 0) { + obj.setFlipHorizontal((tileId & FLIPPED_HORIZONTALLY_FLAG) != 0); + obj.setFlipVertical((tileId & FLIPPED_VERTICALLY_FLAG) != 0); + obj.setFlipDiagonal((tileId & FLIPPED_DIAGONALLY_FLAG) != 0); + obj.setRotatedHexagonal120((tileId & ROTATED_HEXAGONAL_120_FLAG) != 0); + tileId &= ~ALL_FLAGS; } + int localId = (int) tileId - firstGid; + Tile tile = templateTileset.getTile(localId); + obj.setTile(tile); } + readShapeChildren(t.getChildNodes(), obj, x, y); + Properties props = new Properties(); - readProperties(children, props); + readProperties(t.getChildNodes(), props); obj.setProperties(props); return obj; } + private boolean readShapeChildren(NodeList children, MapObject obj, double x, double y) throws Exception { + boolean found = false; + double width = obj.getWidth() != null ? obj.getWidth() : 0; + double height = obj.getHeight() != null ? obj.getHeight() : 0; + for (int i = 0; i < children.getLength(); i++) { + Node child = children.item(i); + String name = child.getNodeName(); + if ("ellipse".equalsIgnoreCase(name)) { + obj.setShape(new Ellipse2D.Double(x, y, width, height)); + obj.setEllipse(new Ellipse()); + found = true; + } else if ("polygon".equalsIgnoreCase(name) || "polyline".equalsIgnoreCase(name)) { + readPolylineOrPolygon(child, obj, x, y); + found = true; + } else if ("point".equalsIgnoreCase(name)) { + obj.setPoint(new Point()); + found = true; + } else if ("text".equalsIgnoreCase(name)) { + try { + obj.setText(unmarshalClass(child, org.mapeditor.core.Text.class)); + } catch (JAXBException e) { + // ignore parse errors for text elements + } + found = true; + } else if ("capsule".equalsIgnoreCase(name)) { + obj.setCapsule(new org.mapeditor.core.Capsule()); + found = true; + } + } + return found; + } + + private void readPolylineOrPolygon(Node node, MapObject object, double x, double y) { + boolean isPolygon = "polygon".equalsIgnoreCase(node.getNodeName()); + final String pointsAttribute = getAttributeValue(node, "points"); + Path2D.Double shape = buildPointsShape(pointsAttribute, x, y, isPolygon); + if (isPolygon) { + Polygon pg = new Polygon(); + pg.setPoints(pointsAttribute); + object.setPolygon(pg); + } else { + Polyline pl = new Polyline(); + pl.setPoints(pointsAttribute); + object.setPolyline(pl); + } + object.setShape(shape); + // Keep the object's position; only derive the size from the shape so + // negative point coordinates don't shift x/y on every round trip. + object.setWidth(shape.getBounds2D().getWidth()); + object.setHeight(shape.getBounds2D().getHeight()); + } + + private static Path2D.Double buildPointsShape(String pointsAttribute, double x, double y, boolean close) { + Path2D.Double shape = new Path2D.Double(); + StringTokenizer st = new StringTokenizer(pointsAttribute, ", "); + boolean firstPoint = true; + while (st.hasMoreElements()) { + double pointX = Double.parseDouble(st.nextToken()); + double pointY = Double.parseDouble(st.nextToken()); + if (firstPoint) { + shape.moveTo(x + pointX, y + pointY); + firstPoint = false; + } else { + shape.lineTo(x + pointX, y + pointY); + } + } + if (close) { + shape.closePath(); + } + return shape; + } + + private Tile findTileInMapTilesets(Tile templateTile, TileSet templateTileSet) { + if (map == null || tilesetPerFirstGid == null || templateTileSet == null) return null; + for (var entry : tilesetPerFirstGid.entrySet()) { + TileSet mapTileSet = entry.getValue(); + if (tilesetSourcesMatch(templateTileSet, mapTileSet)) { + return mapTileSet.getTile(templateTile.getId()); + } + } + return null; + } + + private boolean tilesetSourcesMatch(TileSet a, TileSet b) { + if (a == null || b == null) return false; + String sourceA = a.getSource(); + String sourceB = b.getSource(); + if (sourceA == null || sourceB == null) return false; + return sourceA.equals(sourceB); + } + /** * Reads properties from amongst the given children. When a "properties" * element is encountered, it recursively calls itself with the children of @@ -473,7 +1004,20 @@ private static void readProperties(NodeList children, Properties props) { if ("property".equalsIgnoreCase(child.getNodeName())) { final String key = getAttributeValue(child, "name"); String value = getAttributeValue(child, "value"); - if (value == null) { + + // A class property carries its member values in a nested + // element instead of a value attribute. + Properties nested = null; + for (Node grand = child.getFirstChild(); grand != null; + grand = grand.getNextSibling()) { + if ("properties".equalsIgnoreCase(grand.getNodeName())) { + nested = new Properties(); + readProperties(grand.getChildNodes(), nested); + break; + } + } + + if (value == null && nested == null) { Node grandChild = child.getFirstChild(); if (grandChild != null) { value = grandChild.getNodeValue(); @@ -482,8 +1026,24 @@ private static void readProperties(NodeList children, Properties props) { } } } - if (value != null) { - props.setProperty(key, value); + if (value != null || nested != null) { + final String typeStr = getAttributeValue(child, "type"); + final String propertyTypeName = getAttributeValue(child, "propertytype"); + if (typeStr != null && !typeStr.isEmpty()) { + try { + org.mapeditor.core.PropertyType type = + org.mapeditor.core.PropertyType.fromValue(typeStr); + props.setProperty(key, value, type, propertyTypeName); + } catch (IllegalArgumentException e) { + props.setProperty(key, value); + } + } else { + props.setProperty(key, value); + } + if (nested != null) { + List list = props.getProperties(); + list.get(list.size() - 1).setProperties(nested); + } } } else if ("properties".equals(child.getNodeName())) { readProperties(child.getChildNodes(), props); @@ -517,13 +1077,63 @@ private Tile unmarshalTile(TileSet set, Node t, URL baseDir) throws Exception { tile.setTileSet(set); + // class/type fallback: Tiled 1.9 renamed "type" to "class", 1.10 reverted + String tileType = getAttributeValue(t, "class"); + if (tileType == null || tileType.isEmpty()) { + tileType = getAttributeValue(t, "type"); + } + if (tileType != null && !tileType.isEmpty()) { + tile.setType(tileType); + } + for (int i = 0; i < children.getLength(); i++) { Node child = children.item(i); if ("image".equalsIgnoreCase(child.getNodeName())) { BufferedImage img = unmarshalImage(child, baseDir); + if (img != null) { + final Integer cropX = getOptionalIntAttribute(t, "x"); + final Integer cropY = getOptionalIntAttribute(t, "y"); + final Integer cropWidth = getOptionalIntAttribute(t, "width"); + final Integer cropHeight = getOptionalIntAttribute(t, "height"); + if (cropX != null || cropY != null || cropWidth != null || cropHeight != null) { + tile.setImageX(cropX); + tile.setImageY(cropY); + tile.setImageWidth(cropWidth); + tile.setImageHeight(cropHeight); + final int x = cropX != null ? cropX : 0; + final int y = cropY != null ? cropY : 0; + final int w = cropWidth != null ? cropWidth : img.getWidth() - x; + final int h = cropHeight != null ? cropHeight : img.getHeight() - y; + if (x >= 0 && y >= 0 && w > 0 && h > 0 + && x + w <= img.getWidth() && y + h <= img.getHeight()) { + img = img.getSubimage(x, y, w, h); + } + } + } tile.setImage(img); } else if ("animation".equalsIgnoreCase(child.getNodeName())) { - // TODO: fill this in once TMXMapWriter is complete + if (tile instanceof AnimatedTile) { + Animation anim = tile.getAnimation(); + if (anim != null && anim.getFrame() != null && !anim.getFrame().isEmpty()) { + List frames = anim.getFrame(); + Tile[] frameTiles = new Tile[frames.size()]; + for (int j = 0; j < frames.size(); j++) { + Frame f = frames.get(j); + int tileId = f.getTileid(); + Tile frameTile = set.getTile(tileId); + if (frameTile == null) { + frameTile = new Tile(); + } + frameTiles[j] = frameTile; + } + ((AnimatedTile) tile).setSprite(new Sprite(frameTiles)); + } + } + } else if ("objectgroup".equalsIgnoreCase(child.getNodeName())) { + ObjectGroup collisionGroup = unmarshalObjectGroup(child); + if (collisionGroup != null) { + tile.setObjectGroup(collisionGroup); + } } } @@ -554,31 +1164,31 @@ private Group unmarshalGroup(Node t) throws Exception { g.setLocked(1); } + final String groupClass = getAttributeValue(t, "class"); + if (groupClass != null) { + g.setClassName(groupClass); + } + g.getLayers().clear(); // Load the layers and objectgroups for (Node sibs = t.getFirstChild(); sibs != null; sibs = sibs.getNextSibling()) { + MapLayer child = null; if ("group".equals(sibs.getNodeName())) { - Group group = unmarshalGroup(sibs); - if (group != null) { - g.getLayers().add(group); - } + child = unmarshalGroup(sibs); } else if ("layer".equals(sibs.getNodeName())) { - TileLayer layer = readLayer(sibs); - if (layer != null) { - g.getLayers().add(layer); - } + child = readLayer(sibs); } else if ("objectgroup".equals(sibs.getNodeName())) { - ObjectGroup group = unmarshalObjectGroup(sibs); - if (group != null) { - g.getLayers().add(group); - } + child = unmarshalObjectGroup(sibs); } else if ("imagelayer".equals(sibs.getNodeName())) { - ImageLayer imageLayer = unmarshalImageLayer(sibs); - if (imageLayer != null) { - g.getLayers().add(imageLayer); - } + child = unmarshalImageLayer(sibs); + } + if (child != null) { + // Group children are not added through Map.addLayer, so set + // the map here for anything that needs the map context. + child.setMap(map); + g.getLayers().add(child); } } @@ -604,6 +1214,11 @@ private ObjectGroup unmarshalObjectGroup(Node t) throws Exception { og.setLocked(1); } + final String ogClass = getAttributeValue(t, "class"); + if (ogClass != null) { + og.setClassName(ogClass); + } + // Manually parse the objects in object group og.getObjects().clear(); @@ -641,23 +1256,13 @@ private TileLayer readLayer(Node t) throws Exception { final int layerId = getAttribute(t, "id", 0); final int layerWidth = getAttribute(t, "width", map.getWidth()); final int layerHeight = getAttribute(t, "height", map.getHeight()); + int offsetX = getAttribute(t, "x", 0); + int offsetY = getAttribute(t, "y", 0); + final int visible = getAttribute(t, "visible", 1); TileLayer ml = new TileLayer(layerWidth, layerHeight); - ml.setId(layerId); - - final int offsetX = getAttribute(t, "x", 0); - final int offsetY = getAttribute(t, "y", 0); - final int visible = getAttribute(t, "visible", 1); - String opacity = getAttributeValue(t, "opacity"); - - ml.setName(getAttributeValue(t, "name")); - - if (opacity != null) { - ml.setOpacity(Float.parseFloat(opacity)); - } - - readProperties(t.getChildNodes(), ml.getProperties()); + applyLayerAttributes(ml, t); for (Node child = t.getFirstChild(); child != null; child = child.getNextSibling()) { @@ -666,34 +1271,71 @@ private TileLayer readLayer(Node t) throws Exception { String encoding = getAttributeValue(child, "encoding"); String comp = getAttributeValue(child, "compression"); - if ("base64".equalsIgnoreCase(encoding)) { + // Check for chunk children (infinite maps) + boolean hasChunks = false; + for (Node chunkCheck = child.getFirstChild(); chunkCheck != null; + chunkCheck = chunkCheck.getNextSibling()) { + if ("chunk".equalsIgnoreCase(chunkCheck.getNodeName())) { + hasChunks = true; + break; + } + } + + if (hasChunks) { + // Infinite map: compute bounding box from all chunks + int minX = Integer.MAX_VALUE, minY = Integer.MAX_VALUE; + int maxX = Integer.MIN_VALUE, maxY = Integer.MIN_VALUE; + for (Node chunkNode = child.getFirstChild(); chunkNode != null; + chunkNode = chunkNode.getNextSibling()) { + if ("chunk".equalsIgnoreCase(chunkNode.getNodeName())) { + int cx = getAttribute(chunkNode, "x", 0); + int cy = getAttribute(chunkNode, "y", 0); + int cw = getAttribute(chunkNode, "width", 0); + int ch = getAttribute(chunkNode, "height", 0); + minX = Math.min(minX, cx); + minY = Math.min(minY, cy); + maxX = Math.max(maxX, cx + cw); + maxY = Math.max(maxY, cy + ch); + } + } + int totalWidth = maxX - minX; + int totalHeight = maxY - minY; + ml = new TileLayer(new java.awt.Rectangle(minX, minY, totalWidth, totalHeight)); + ml.setId(layerId); + applyLayerAttributes(ml, t); + + // The final setOffset call would otherwise reset the + // chunk-derived origin, making negative chunk coordinates + // inaccessible. + offsetX += minX; + offsetY += minY; + + // Read each chunk + for (Node chunkNode = child.getFirstChild(); chunkNode != null; + chunkNode = chunkNode.getNextSibling()) { + if (!"chunk".equalsIgnoreCase(chunkNode.getNodeName())) { + continue; + } + int cx = getAttribute(chunkNode, "x", 0); + int cy = getAttribute(chunkNode, "y", 0); + int cw = getAttribute(chunkNode, "width", 0); + int ch = getAttribute(chunkNode, "height", 0); + + readChunkData(ml, chunkNode, encoding, comp, cx, cy, cw, ch); + } + } else if ("base64".equalsIgnoreCase(encoding)) { Node cdata = child.getFirstChild(); if (cdata != null) { String enc = cdata.getNodeValue().trim(); byte[] dec = Base64.getDecoder().decode(enc); ByteArrayInputStream bais = new ByteArrayInputStream(dec); - InputStream is; - - if ("gzip".equalsIgnoreCase(comp)) { - final int len = layerWidth * layerHeight * 4; - is = new GZIPInputStream(bais, len); - } else if ("zlib".equalsIgnoreCase(comp)) { - is = new InflaterInputStream(bais); - } else if (comp != null && !comp.isEmpty()) { - throw new IOException("Unrecognized compression method \"" + comp + "\" for map layer " + ml.getName()); - } else { - is = bais; - } - - for (int y = 0; y < ml.getHeight(); y++) { - for (int x = 0; x < ml.getWidth(); x++) { - int tileId = 0; - tileId |= is.read(); - tileId |= is.read() << Byte.SIZE; - tileId |= is.read() << Byte.SIZE * 2; - tileId |= is.read() << Byte.SIZE * 3; - - setTileAtFromTileId(ml, y, x, tileId); + try (InputStream is = createDecompressStream(bais, comp, + layerWidth * layerHeight * 4, "map layer " + ml.getName())) { + for (int y = 0; y < ml.getHeight(); y++) { + for (int x = 0; x < ml.getWidth(); x++) { + int tileId = readTileId(is, "map layer " + ml.getName()); + setTileAtFromTileId(ml, y, x, tileId); + } } } } @@ -726,7 +1368,10 @@ private TileLayer readLayer(Node t) throws Exception { dataChild != null; dataChild = dataChild.getNextSibling()) { if ("tile".equalsIgnoreCase(dataChild.getNodeName())) { - int tileId = getAttribute(dataChild, "gid", -1); + // Parse as long so unsigned gids with flip flags set fit. + // A missing gid means an empty cell, like in C++. + String gidAttr = getAttributeValue(dataChild, "gid"); + int tileId = gidAttr != null ? (int) Long.parseLong(gidAttr) : 0; setTileAtFromTileId(ml, y, x, tileId); x++; @@ -778,6 +1423,66 @@ private TileLayer readLayer(Node t) throws Exception { + /** + * Reads tile data from a chunk node and places tiles in the layer at the + * correct position. + */ + private void readChunkData(TileLayer ml, Node chunkNode, String encoding, + String comp, int cx, int cy, int cw, int ch) throws IOException { + if ("base64".equalsIgnoreCase(encoding)) { + Node cdata = chunkNode.getFirstChild(); + if (cdata != null) { + String enc = cdata.getNodeValue().trim(); + byte[] dec = Base64.getDecoder().decode(enc); + ByteArrayInputStream bais = new ByteArrayInputStream(dec); + try (InputStream is = createDecompressStream(bais, comp, cw * ch * 4, "chunk")) { + for (int y = 0; y < ch; y++) { + for (int x = 0; x < cw; x++) { + int tileId = readTileId(is, "chunk"); + setTileAtFromTileId(ml, cy + y, cx + x, tileId); + } + } + } + } + } else if ("csv".equalsIgnoreCase(encoding)) { + String csvText = chunkNode.getTextContent(); + String[] csvTileIds = csvText.trim().split("[\\s]*,[\\s]*"); + + if (csvTileIds.length != cw * ch) { + throw new IOException("Number of tiles does not match the chunk's width and height"); + } + + for (int y = 0; y < ch; y++) { + for (int x = 0; x < cw; x++) { + String gid = csvTileIds[x + y * cw]; + long tileId = Long.parseLong(gid); + setTileAtFromTileId(ml, cy + y, cx + x, (int) tileId); + } + } + } else { + int x = 0, y = 0; + for (Node dataChild = chunkNode.getFirstChild(); dataChild != null; + dataChild = dataChild.getNextSibling()) { + if ("tile".equalsIgnoreCase(dataChild.getNodeName())) { + // Parse as long so unsigned gids with flip flags set fit. + // A missing gid means an empty cell, like in C++. + String gidAttr = getAttributeValue(dataChild, "gid"); + int tileId = gidAttr != null ? (int) Long.parseLong(gidAttr) : 0; + setTileAtFromTileId(ml, cy + y, cx + x, tileId); + + x++; + if (x == cw) { + x = 0; + y++; + } + if (y == ch) { + break; + } + } + } + } + } + /** * Helper method to set the tile based on its global id. * @@ -786,6 +1491,23 @@ private TileLayer readLayer(Node t) throws Exception { * @param x x-coordinate * @param tileGid global id of the tile as read from the file */ + /** + * Reads one little-endian 32-bit gid, failing loudly on truncated data. + */ + private static int readTileId(InputStream is, String context) throws IOException { + int b0 = is.read(); + int b1 = is.read(); + int b2 = is.read(); + int b3 = is.read(); + if ((b0 | b1 | b2 | b3) < 0) { + throw new IOException("Premature end of tile data in " + context); + } + return b0 + | b1 << Byte.SIZE + | b2 << Byte.SIZE * 2 + | b3 << Byte.SIZE * 3; + } + private void setTileAtFromTileId(TileLayer ml, int y, int x, int tileGid) { Tile tile = this.getTileForTileGID( (tileGid & (int)~ALL_FLAGS)); @@ -803,7 +1525,7 @@ private void setTileAtFromTileId(TileLayer ml, int y, int x, int tileGid) { */ private Tile getTileForTileGID(int tileId) { Tile tile = null; - java.util.Map.Entry ts = findTileSetForTileGID(tileId); + var ts = findTileSetForTileGID(tileId); if (ts != null) { tile = ts.getValue().getTile(tileId - ts.getKey()); } @@ -844,13 +1566,23 @@ private void buildMap(Document doc) throws Exception { // Load the layers and groups for (Node sibs = mapNode.getFirstChild(); sibs != null; sibs = sibs.getNextSibling()) { - if ("group".equals(sibs.getNodeName())) { + if ("editorsettings".equals(sibs.getNodeName())) { + for (Node esChild = sibs.getFirstChild(); esChild != null; + esChild = esChild.getNextSibling()) { + if ("chunksize".equals(esChild.getNodeName())) { + setIntIfPresentAndNonZero(esChild, "width", map::setEditorChunkWidth); + setIntIfPresentAndNonZero(esChild, "height", map::setEditorChunkHeight); + } else if ("export".equals(esChild.getNodeName())) { + setStrIfPresent(esChild, "target", map::setExportTarget); + setStrIfPresent(esChild, "format", map::setExportFormat); + } + } + } else if ("group".equals(sibs.getNodeName())) { Group group = unmarshalGroup(sibs); if (group != null) { map.addLayer(group); } - } - if ("layer".equals(sibs.getNodeName())) { + } else if ("layer".equals(sibs.getNodeName())) { TileLayer layer = readLayer(sibs); if (layer != null) { map.addLayer(layer); @@ -871,12 +1603,11 @@ private void buildMap(Document doc) throws Exception { } private Map unmarshal(InputStream in) throws Exception { - DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + DocumentBuilderFactory factory = newSecureDocumentBuilderFactory(); Document doc; try { factory.setIgnoringComments(true); factory.setIgnoringElementContentWhitespace(true); - factory.setExpandEntityReferences(false); DocumentBuilder builder = factory.newDocumentBuilder(); builder.setEntityResolver(entityResolver); InputSource insrc = new InputSource(StreamHelper.buffered(in)); @@ -1047,8 +1778,10 @@ public static boolean checkRoot(String filename) { * @return the tileset containing the tile with the given global tile id, or * null when no such tileset exists */ - private Entry findTileSetForTileGID(int gid) { - return tilesetPerFirstGid.floorEntry(gid); + private java.util.Map.Entry findTileSetForTileGID(int gid) { + // The gid table only exists while reading a map. When reading a + // standalone tileset there is nothing to resolve against. + return tilesetPerFirstGid != null ? tilesetPerFirstGid.floorEntry(gid) : null; } /** diff --git a/util/java/libtiled-java/src/main/java/org/mapeditor/io/TMXMapWriter.java b/util/java/libtiled-java/src/main/java/org/mapeditor/io/TMXMapWriter.java index 9d72e594d1..1cea006386 100644 --- a/util/java/libtiled-java/src/main/java/org/mapeditor/io/TMXMapWriter.java +++ b/util/java/libtiled-java/src/main/java/org/mapeditor/io/TMXMapWriter.java @@ -31,7 +31,10 @@ package org.mapeditor.io; import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.Image; import java.awt.Rectangle; +import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileOutputStream; @@ -42,16 +45,14 @@ import java.nio.charset.Charset; import java.util.ArrayList; import java.util.HashMap; -import java.util.Iterator; import java.util.List; -import java.util.Set; -import java.util.TreeSet; import java.util.zip.DeflaterOutputStream; import java.util.zip.GZIPOutputStream; -import java.util.Base64; +import com.github.luben.zstd.ZstdOutputStream; import org.mapeditor.core.AnimatedTile; +import org.mapeditor.core.ImageLayer; import org.mapeditor.core.MapLayer; import org.mapeditor.core.Map; import org.mapeditor.core.MapObject; @@ -59,11 +60,19 @@ import org.mapeditor.core.Group; import org.mapeditor.core.Orientation; import org.mapeditor.core.Properties; +import org.mapeditor.core.Property; +import org.mapeditor.core.Animation; +import org.mapeditor.core.Frame; import org.mapeditor.core.Sprite; import org.mapeditor.core.Tile; import org.mapeditor.core.TileLayer; import org.mapeditor.core.TileSet; +import org.mapeditor.core.WangColor; +import org.mapeditor.core.WangSet; +import org.mapeditor.core.WangSets; +import org.mapeditor.core.WangTile; import org.mapeditor.io.xml.XMLWriter; +import org.mapeditor.util.ImageHelper; /** * A writer for Tiled's TMX map format. @@ -74,6 +83,9 @@ public class TMXMapWriter { private static final int LAST_BYTE = 0x000000FF; + // The TMX format version written to output files. + private static final String CURRENT_VERSION = "1.11"; + private static final boolean ENCODE_LAYER_DATA = true; private static final boolean COMPRESS_LAYER_DATA = ENCODE_LAYER_DATA; @@ -84,11 +96,49 @@ public static class Settings { @Deprecated public static final String LAYER_COMPRESSION_METHOD_GZIP = "gzip"; public static final String LAYER_COMPRESSION_METHOD_ZLIB = "zlib"; + public static final String LAYER_COMPRESSION_METHOD_ZSTD = "zstd"; + + public static final String LAYER_ENCODING_BASE64 = "base64"; + public static final String LAYER_ENCODING_CSV = "csv"; public String layerCompressionMethod = LAYER_COMPRESSION_METHOD_ZLIB; + public String layerEncoding = LAYER_ENCODING_BASE64; } public Settings settings = new Settings(); + private static boolean hasAnimation(Tile tile) { + if (tile instanceof AnimatedTile) return true; + Animation anim = tile.getAnimation(); + return anim != null && anim.getFrame() != null && !anim.getFrame().isEmpty(); + } + + private static boolean isNonEmpty(String s) { + return s != null && !s.isEmpty(); + } + + private static long buildFlipFlags(MapObject obj) { + long flags = 0; + if (obj.getFlipHorizontal()) flags |= TMXMapReader.FLIPPED_HORIZONTALLY_FLAG; + if (obj.getFlipVertical()) flags |= TMXMapReader.FLIPPED_VERTICALLY_FLAG; + if (obj.getFlipDiagonal()) flags |= TMXMapReader.FLIPPED_DIAGONALLY_FLAG; + if (obj.getRotatedHexagonal120()) flags |= TMXMapReader.ROTATED_HEXAGONAL_120_FLAG; + return flags; + } + + private static boolean tileNeedsWrite(Tile tile, boolean checkSource) { + return !tile.getProperties().isEmpty() + || isNonEmpty(tile.getType()) + || isNonEmpty(tile.getClassName()) + || (checkSource && tile.getSource() != null) + || (tile.getProbability() != null && tile.getProbability() != 1.0) + || tile.getCollisionObjectGroup() != null + || tile.getImageX() != null + || tile.getImageY() != null + || tile.getImageWidth() != null + || tile.getImageHeight() != null + || hasAnimation(tile); + } + /** * Saves a map to an XML file. * @@ -176,9 +226,10 @@ private void writeMap(Map map, XMLWriter w, String wp) throws IOException { // w.writeDocType("map", null, "http://mapeditor.org/dtd/1.0/map.dtd"); w.startElement("map"); - w.writeAttribute("version", "1.2"); + // Saving upgrades the map to the current format version. + w.writeAttribute("version", CURRENT_VERSION); - if (!map.getTiledversion().isEmpty()) { + if (isNonEmpty(map.getTiledversion())) { w.writeAttribute("tiledversion", map.getTiledversion()); } @@ -189,19 +240,55 @@ private void writeMap(Map map, XMLWriter w, String wp) throws IOException { w.writeAttribute("height", map.getHeight()); w.writeAttribute("tilewidth", map.getTileWidth()); w.writeAttribute("tileheight", map.getTileHeight()); - w.writeAttribute("infinite", map.getInfinite()); + w.writeAttribute("infinite", map.getInfinite() != null ? map.getInfinite() : 0); - w.writeAttribute("nextlayerid", map.getNextlayerid()); - w.writeAttribute("nextobjectid", map.getNextobjectid()); + if (isNonEmpty(map.getBackgroundcolor())) { + w.writeAttribute("backgroundcolor", map.getBackgroundcolor()); + } + + if (map.getCompressionlevel() != null && map.getCompressionlevel() >= 0) { + w.writeAttribute("compressionlevel", map.getCompressionlevel()); + } + + if (map.getParallaxoriginx() != null && map.getParallaxoriginx() != 0.0) { + w.writeAttribute("parallaxoriginx", map.getParallaxoriginx()); + } - switch (orientation) { - case HEXAGONAL: - w.writeAttribute("hexsidelength", map.getHexSideLength()); - case STAGGERED: + if (map.getParallaxoriginy() != null && map.getParallaxoriginy() != 0.0) { + w.writeAttribute("parallaxoriginy", map.getParallaxoriginy()); + } + + if (isNonEmpty(map.getClassName())) { + w.writeAttribute("class", map.getClassName()); + } + + if (map.getNextlayerid() != null) { + w.writeAttribute("nextlayerid", map.getNextlayerid()); + } + if (map.getNextobjectid() != null) { + w.writeAttribute("nextobjectid", map.getNextobjectid()); + } + + if (orientation == Orientation.HEXAGONAL && map.getHexSideLength() != null) { + w.writeAttribute("hexsidelength", map.getHexSideLength()); + } + if (orientation == Orientation.HEXAGONAL || orientation == Orientation.STAGGERED) { + if (map.getStaggerAxis() != null) { w.writeAttribute("staggeraxis", map.getStaggerAxis().value()); + } + if (map.getStaggerIndex() != null) { w.writeAttribute("staggerindex", map.getStaggerIndex().value()); + } } + if (map.getSkewx() != null && map.getSkewx() != 0) { + w.writeAttribute("skewx", map.getSkewx()); + } + if (map.getSkewy() != null && map.getSkewy() != 0) { + w.writeAttribute("skewy", map.getSkewy()); + } + + writeEditorSettings(map, w); writeProperties(map.getProperties(), w); firstGidPerTileset = new HashMap<>(); @@ -212,15 +299,7 @@ private void writeMap(Map map, XMLWriter w, String wp) throws IOException { firstgid += tileset.getMaxTileId() + 1; } - for (MapLayer layer : map.getLayers()) { - if (layer instanceof TileLayer) { - writeMapLayer((TileLayer) layer, w, wp); - } else if (layer instanceof ObjectGroup) { - writeObjectGroup((ObjectGroup) layer, w, wp); - } else if (layer instanceof Group) { - writeGroup((Group) layer, w, wp); - } - } + writeLayers(map.getLayers(), w, wp); firstGidPerTileset = null; w.endElement(); @@ -231,15 +310,48 @@ private void writeGroup(Group group, XMLWriter w, String wp) throws IOException writeLayerAttributes(group, w); writeProperties(group.getProperties(), w); + writeLayers(group.getLayers(), w, wp); + w.endElement(); + } - for (MapLayer layer : group.getLayers()) { + private void writeLayers(List layers, XMLWriter w, String wp) throws IOException { + for (MapLayer layer : layers) { if (layer instanceof TileLayer) { writeMapLayer((TileLayer) layer, w, wp); } else if (layer instanceof ObjectGroup) { writeObjectGroup((ObjectGroup) layer, w, wp); + } else if (layer instanceof ImageLayer) { + writeImageLayer((ImageLayer) layer, w, wp); } else if (layer instanceof Group) { writeGroup((Group) layer, w, wp); - } // TODO: Image Layer writing + } + } + } + + private void writeEditorSettings(Map map, XMLWriter w) throws IOException { + boolean hasChunkSize = (map.getEditorChunkWidth() != null && map.getEditorChunkHeight() != null); + boolean hasExport = isNonEmpty(map.getExportTarget()); + + if (!hasChunkSize && !hasExport) { + return; + } + + w.startElement("editorsettings"); + + if (hasChunkSize) { + w.startElement("chunksize"); + w.writeAttribute("width", map.getEditorChunkWidth()); + w.writeAttribute("height", map.getEditorChunkHeight()); + w.endElement(); + } + + if (hasExport) { + w.startElement("export"); + w.writeAttribute("target", map.getExportTarget()); + if (isNonEmpty(map.getExportFormat())) { + w.writeAttribute("format", map.getExportFormat()); + } + w.endElement(); } w.endElement(); @@ -248,23 +360,35 @@ private void writeGroup(Group group, XMLWriter w, String wp) throws IOException private void writeProperties(Properties props, XMLWriter w) throws IOException { if (props != null && !props.isEmpty()) { - final Set propertyKeys = new TreeSet<>(); - propertyKeys.addAll(props.keySet()); w.startElement("properties"); - for (Object propertyKey : propertyKeys) { - final String key = (String) propertyKey; - final String property = props.getProperty(key); + // Sort by name and keep the first occurrence, so the output is + // deterministic and duplicates don't multiply on round trips. + java.util.Map unique = new java.util.TreeMap<>(); + for (Property prop : props.getProperties()) { + unique.putIfAbsent(prop.getName() != null ? prop.getName() : "", prop); + } + for (Property prop : unique.values()) { + final String key = prop.getName(); + final String value = prop.getValue(); w.startElement("property"); w.writeAttribute("name", key); - if (property.indexOf('\n') == -1) { - if ("true".equals(property) || "false".equals(property)) { - w.writeAttribute("type", "bool"); - } - w.writeAttribute("value", property); - } else { + if (prop.getType() != null) { + w.writeAttribute("type", prop.getType().value()); + } else if (value != null && value.indexOf('\n') == -1 + && ("true".equals(value) || "false".equals(value))) { + w.writeAttribute("type", "bool"); + } + if (prop.getPropertyTypeName() != null && !prop.getPropertyTypeName().isEmpty()) { + w.writeAttribute("propertytype", prop.getPropertyTypeName()); + } + if (value != null && value.indexOf('\n') == -1) { + w.writeAttribute("value", value); + } else if (value != null) { // Save multiline values as character data - w.writeCDATA(property); + w.writeCharacters(value); } + // Member values of a class property + writeProperties(prop.getProperties(), w); w.endElement(); } w.endElement(); @@ -300,78 +424,163 @@ private void writeTileset(TileSet set, XMLWriter w, String wp) throws IOException { String tileBitmapFile = set.getTilebmpFile(); + org.mapeditor.core.ImageData imageData = set.getImageData(); + // The tileset image either came from an explicit import or was parsed + // from the file into ImageData. + String imageSource = tileBitmapFile != null ? getRelativePath(wp, tileBitmapFile) + : (imageData != null ? imageData.getSource() : null); String name = set.getName(); w.startElement("tileset"); - w.writeAttribute("firstgid", getFirstGidForTileset(set)); + if (firstGidPerTileset != null) { + w.writeAttribute("firstgid", getFirstGidForTileset(set)); + } else { + // A standalone TSX file has no firstgid but carries the format + // version, like the C++ writer. + w.writeAttribute("version", CURRENT_VERSION); + } if (name != null) { w.writeAttribute("name", name); } - if (tileBitmapFile != null) { - w.writeAttribute("tilewidth", set.getTileWidth()); - w.writeAttribute("tileheight", set.getTileHeight()); + if (isNonEmpty(set.getClassName())) { + w.writeAttribute("class", set.getClassName()); + } + + w.writeAttribute("tilewidth", set.getTileWidth()); + w.writeAttribute("tileheight", set.getTileHeight()); + + final int tileSpacing = set.getTileSpacing() != null ? set.getTileSpacing() : 0; + final int tileMargin = set.getTileMargin() != null ? set.getTileMargin() : 0; + if (tileSpacing != 0) { + w.writeAttribute("spacing", tileSpacing); + } + if (tileMargin != 0) { + w.writeAttribute("margin", tileMargin); + } + + w.writeAttribute("tilecount", set.getTilecount() != null ? set.getTilecount() : set.size()); + w.writeAttribute("columns", set.getColumns()); - final int tileSpacing = set.getTileSpacing(); - final int tileMargin = set.getTileMargin(); - if (tileSpacing != 0) { - w.writeAttribute("spacing", tileSpacing); + if (isNonEmpty(set.getObjectalignment())) { + w.writeAttribute("objectalignment", set.getObjectalignment()); + } + if (isNonEmpty(set.getTilerendersize())) { + w.writeAttribute("tilerendersize", set.getTilerendersize()); + } + if (isNonEmpty(set.getFillmode())) { + w.writeAttribute("fillmode", set.getFillmode()); + } + if (isNonEmpty(set.getBackgroundcolor())) { + w.writeAttribute("backgroundcolor", set.getBackgroundcolor()); + } + + if (set.getTileoffset() != null) { + org.mapeditor.core.TileOffset tileOffset = set.getTileoffset(); + if ((tileOffset.getX() != null && tileOffset.getX() != 0) + || (tileOffset.getY() != null && tileOffset.getY() != 0)) { + w.startElement("tileoffset"); + w.writeAttribute("x", tileOffset.getX() != null ? tileOffset.getX() : 0); + w.writeAttribute("y", tileOffset.getY() != null ? tileOffset.getY() : 0); + w.endElement(); } - if (tileMargin != 0) { - w.writeAttribute("margin", tileMargin); + } + + if (set.getGrid() != null) { + org.mapeditor.core.Grid grid = set.getGrid(); + w.startElement("grid"); + if (grid.getOrientation() != null) { + w.writeAttribute("orientation", grid.getOrientation().value()); } + if (grid.getWidth() != null) { + w.writeAttribute("width", grid.getWidth()); + } + if (grid.getHeight() != null) { + w.writeAttribute("height", grid.getHeight()); + } + w.endElement(); } - if (tileBitmapFile != null) { + writeProperties(set.getProperties(), w); + + if (imageSource != null || set.getTileSetImage() != null) { w.startElement("image"); - w.writeAttribute("source", getRelativePath(wp, tileBitmapFile)); + if (imageSource != null) { + w.writeAttribute("source", imageSource); + } Color trans = set.getTransparentColor(); if (trans != null) { - w.writeAttribute("trans", Integer.toHexString( - trans.getRGB()).substring(2)); + w.writeAttribute("trans", + String.format("%06x", trans.getRGB() & 0xFFFFFF)); + } else if (imageData != null && imageData.getTrans() != null) { + w.writeAttribute("trans", imageData.getTrans()); + } + + if (imageData != null) { + if (imageData.getWidth() != null) { + w.writeAttribute("width", imageData.getWidth()); + } + if (imageData.getHeight() != null) { + w.writeAttribute("height", imageData.getHeight()); + } + } + if (imageSource == null) { + w.writeAttribute("format", "png"); + writeEmbeddedImageData(w, set.getTileSetImage()); } w.endElement(); // Write tile properties when necessary. for (Tile tile : set) { // todo: move the null check back into the iterator? - if (tile != null - && (!tile.getProperties().isEmpty() - || !tile.getType().isEmpty())) { + if (tile != null && tileNeedsWrite(tile, false)) { w.startElement("tile"); w.writeAttribute("id", tile.getId()); - if (!tile.getType().isEmpty()) { + if (isNonEmpty(tile.getType())) { w.writeAttribute("type", tile.getType()); + } else if (isNonEmpty(tile.getClassName())) { + w.writeAttribute("type", tile.getClassName()); + } + if (tile.getProbability() != null && tile.getProbability() != 1.0) { + w.writeAttribute("probability", tile.getProbability()); + } + if (tile.getImageX() != null) { + w.writeAttribute("x", tile.getImageX()); + } + if (tile.getImageY() != null) { + w.writeAttribute("y", tile.getImageY()); + } + if (tile.getImageWidth() != null) { + w.writeAttribute("width", tile.getImageWidth()); + } + if (tile.getImageHeight() != null) { + w.writeAttribute("height", tile.getImageHeight()); } if (!tile.getProperties().isEmpty()) { writeProperties(tile.getProperties(), w); } + if (tile.getCollisionObjectGroup() != null) { + writeObjectGroup(tile.getCollisionObjectGroup(), w, wp); + } + if (hasAnimation(tile)) { + writeAnimation(tile, w); + } w.endElement(); } } } else { // Check to see if there is a need to write tile elements boolean needWrite = false; - - // As long as one has properties, they all need to be written. - // TODO: This shouldn't be necessary for (Tile tile : set) { - if (!tile.getProperties().isEmpty() - || !tile.getType().isEmpty() - || tile.getSource() != null) { + if (tileNeedsWrite(tile, true)) { needWrite = true; break; } } if (needWrite) { - w.writeAttribute("tilewidth", set.getTileWidth()); - w.writeAttribute("tileheight", set.getTileHeight()); - w.writeAttribute("tilecount", set.size()); - w.writeAttribute("columns", set.getColumns()); - for (Tile tile : set) { // todo: move this check back into the iterator? if (tile != null) { @@ -380,6 +589,139 @@ private void writeTileset(TileSet set, XMLWriter w, String wp) } } } + + if (set.getTransformations() != null) { + org.mapeditor.core.Transformations trans = set.getTransformations(); + w.startElement("transformations"); + if (trans.isHflip() != null) { + w.writeAttribute("hflip", trans.isHflip() ? "1" : "0"); + } + if (trans.isVflip() != null) { + w.writeAttribute("vflip", trans.isVflip() ? "1" : "0"); + } + if (trans.isRotate() != null) { + w.writeAttribute("rotate", trans.isRotate() ? "1" : "0"); + } + if (trans.isPreferuntransformed() != null) { + w.writeAttribute("preferuntransformed", trans.isPreferuntransformed() ? "1" : "0"); + } + w.endElement(); + } + + if (set.getWangsets() != null) { + WangSets wangSets = set.getWangsets(); + if (!wangSets.getWangset().isEmpty()) { + w.startElement("wangsets"); + for (WangSet ws : wangSets.getWangset()) { + w.startElement("wangset"); + if (ws.getName() != null) { + w.writeAttribute("name", ws.getName()); + } + if (isNonEmpty(ws.getType())) { + w.writeAttribute("type", ws.getType()); + } + if (ws.getTile() != null) { + w.writeAttribute("tile", ws.getTile()); + } + if (isNonEmpty(ws.getClassName())) { + w.writeAttribute("class", ws.getClassName()); + } + // Write unified wangcolor elements + for (WangColor wc : ws.getWangcolor()) { + w.startElement("wangcolor"); + if (wc.getName() != null) { + w.writeAttribute("name", wc.getName()); + } + if (isNonEmpty(wc.getClassName())) { + w.writeAttribute("class", wc.getClassName()); + } + if (wc.getColor() != null) { + w.writeAttribute("color", wc.getColor()); + } + if (wc.getTile() != null) { + w.writeAttribute("tile", wc.getTile()); + } + if (wc.getProbability() != null) { + w.writeAttribute("probability", wc.getProbability()); + } + if (wc.getProperties() != null && !wc.getProperties().isEmpty()) { + writeProperties(wc.getProperties(), w); + } + w.endElement(); + } + // Write wangtile elements + for (WangTile wt : ws.getWangtile()) { + w.startElement("wangtile"); + if (wt.getTileid() != null) { + w.writeAttribute("tileid", wt.getTileid()); + } + if (wt.getWangid() != null) { + w.writeAttribute("wangid", wt.getWangid()); + } + w.endElement(); + } + // Write wangset properties + if (ws.getProperties() != null && !ws.getProperties().isEmpty()) { + writeProperties(ws.getProperties(), w); + } + w.endElement(); + } + w.endElement(); + } + } + + w.endElement(); + } + + private void writeImageLayer(ImageLayer il, XMLWriter w, String wp) + throws IOException { + w.startElement("imagelayer"); + + writeLayerAttributes(il, w); + + if (il.isRepeatx() != null && il.isRepeatx()) { + w.writeAttribute("repeatx", "1"); + } + if (il.isRepeaty() != null && il.isRepeaty()) { + w.writeAttribute("repeaty", "1"); + } + + writeProperties(il.getProperties(), w); + + org.mapeditor.core.ImageData image = il.getImage(); + if (image != null && (image.getSource() != null || image.getData() != null)) { + w.startElement("image"); + if (image.getSource() != null) { + w.writeAttribute("source", getRelativePath(wp, image.getSource())); + } else if (image.getFormat() != null) { + w.writeAttribute("format", image.getFormat()); + } + if (image.getWidth() != null) { + w.writeAttribute("width", image.getWidth()); + } + if (image.getHeight() != null) { + w.writeAttribute("height", image.getHeight()); + } + if (image.getTrans() != null) { + w.writeAttribute("trans", image.getTrans()); + } + if (image.getSource() == null && image.getData() != null) { + org.mapeditor.core.Data data = image.getData(); + w.startElement("data"); + if (data.getEncoding() != null) { + w.writeAttribute("encoding", data.getEncoding().value()); + } + if (data.getCompression() != null) { + w.writeAttribute("compression", data.getCompression().value()); + } + if (data.getValue() != null) { + w.writeCDATA(data.getValue().trim()); + } + w.endElement(); + } + w.endElement(); + } + w.endElement(); } @@ -387,7 +729,7 @@ private void writeObjectGroup(ObjectGroup o, XMLWriter w, String wp) throws IOException { w.startElement("objectgroup"); - if (o.getColor() != null && o.getColor().isEmpty()) { + if (isNonEmpty(o.getColor())) { w.writeAttribute("color", o.getColor()); } if (o.getDraworder() != null && !o.getDraworder().equalsIgnoreCase("topdown")) { @@ -396,9 +738,8 @@ private void writeObjectGroup(ObjectGroup o, XMLWriter w, String wp) writeLayerAttributes(o, w); writeProperties(o.getProperties(), w); - Iterator itr = o.getObjects().iterator(); - while (itr.hasNext()) { - writeMapObject(itr.next(), w, wp); + for (MapObject mo : o.getObjects()) { + writeMapObject(mo, w, wp); } w.endElement(); @@ -413,7 +754,9 @@ private void writeObjectGroup(ObjectGroup o, XMLWriter w, String wp) private void writeLayerAttributes(MapLayer l, XMLWriter w) throws IOException { Rectangle bounds = l.getBounds(); - w.writeAttribute("id", l.getId()); + if (l.getId() != null && l.getId() != 0) { + w.writeAttribute("id", l.getId()); + } w.writeAttribute("name", l.getName()); if (l instanceof TileLayer) { @@ -424,11 +767,19 @@ private void writeLayerAttributes(MapLayer l, XMLWriter w) throws IOException { w.writeAttribute("height", bounds.height); } } - if (bounds.x != 0) { - w.writeAttribute("x", bounds.x); - } - if (bounds.y != 0) { - w.writeAttribute("y", bounds.y); + // For infinite maps the chunk coordinates carry the layer origin, so + // writing it as x/y as well would shift the layer on every round trip. + boolean chunked = l instanceof TileLayer + && l.getMap() != null + && l.getMap().getInfinite() != null + && l.getMap().getInfinite() != 0; + if (!chunked) { + if (bounds.x != 0) { + w.writeAttribute("x", bounds.x); + } + if (bounds.y != 0) { + w.writeAttribute("y", bounds.y); + } } Boolean isVisible = l.isVisible(); @@ -450,6 +801,26 @@ private void writeLayerAttributes(MapLayer l, XMLWriter w) throws IOException { if (l.getLocked() != null && l.getLocked() != 0) { w.writeAttribute("locked", l.getLocked()); } + + if (isNonEmpty(l.getTintcolor())) { + w.writeAttribute("tintcolor", l.getTintcolor()); + } + + if (l.getParallaxx() != null && l.getParallaxx() != 1.0) { + w.writeAttribute("parallaxx", l.getParallaxx()); + } + + if (l.getParallaxy() != null && l.getParallaxy() != 1.0) { + w.writeAttribute("parallaxy", l.getParallaxy()); + } + + if (isNonEmpty(l.getClassName())) { + w.writeAttribute("class", l.getClassName()); + } + + if (isNonEmpty(l.getMode()) && !"normal".equalsIgnoreCase(l.getMode())) { + w.writeAttribute("mode", l.getMode()); + } } /** @@ -465,76 +836,71 @@ private void writeMapLayer(TileLayer l, XMLWriter w, String wp) throws IOExcepti writeLayerAttributes(l, w); writeProperties(l.getProperties(), w); - final TileLayer tl = l; w.startElement("data"); - if (ENCODE_LAYER_DATA) { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - OutputStream out; - + if (usesCsvEncoding()) { + w.writeAttribute("encoding", "csv"); + } else if (ENCODE_LAYER_DATA) { w.writeAttribute("encoding", "base64"); - - DeflaterOutputStream dos; if (COMPRESS_LAYER_DATA) { - if (Settings.LAYER_COMPRESSION_METHOD_ZLIB.equalsIgnoreCase(settings.layerCompressionMethod)) { - dos = new DeflaterOutputStream(baos); - } else if (Settings.LAYER_COMPRESSION_METHOD_GZIP.equalsIgnoreCase(settings.layerCompressionMethod)) { - dos = new GZIPOutputStream(baos); - } else { - throw new IOException("Unrecognized compression method \"" + settings.layerCompressionMethod + "\" for map layer " + l.getName()); - } - out = dos; w.writeAttribute("compression", settings.layerCompressionMethod); - } else { - out = baos; } + } - for (int y = 0; y < l.getHeight(); y++) { - for (int x = 0; x < l.getWidth(); x++) { - Tile tile = tl.getTileAt(x + bounds.x, - y + bounds.y); - int gid = 0; - - if (tile != null) { - gid = getGid(tile); - gid |= tl.getFlagsAt(x, y); - } + boolean isInfinite = l.getMap() != null + && l.getMap().getInfinite() != null + && l.getMap().getInfinite() != 0; - out.write(gid & LAST_BYTE); - out.write(gid >> Byte.SIZE & LAST_BYTE); - out.write(gid >> Byte.SIZE * 2 & LAST_BYTE); - out.write(gid >> Byte.SIZE * 3 & LAST_BYTE); - } + if (isInfinite) { + int chunkW = 16; + int chunkH = 16; + if (l.getMap().getEditorChunkWidth() != null && l.getMap().getEditorChunkWidth() > 0) { + chunkW = l.getMap().getEditorChunkWidth(); } - - if (COMPRESS_LAYER_DATA && dos != null) { - dos.finish(); + if (l.getMap().getEditorChunkHeight() != null && l.getMap().getEditorChunkHeight() > 0) { + chunkH = l.getMap().getEditorChunkHeight(); } - byte[] dec = baos.toByteArray(); - w.writeCDATA(Base64.getEncoder().encodeToString(dec)); - } else { - for (int y = 0; y < l.getHeight(); y++) { - for (int x = 0; x < l.getWidth(); x++) { - Tile tile = tl.getTileAt(x + bounds.x, y + bounds.y); - int gid = 0; - - if (tile != null) { - gid = getGid(tile); + int startCX = Math.floorDiv(bounds.x, chunkW) * chunkW; + int startCY = Math.floorDiv(bounds.y, chunkH) * chunkH; + int endX = bounds.x + bounds.width; + int endY = bounds.y + bounds.height; + + for (int cy = startCY; cy < endY; cy += chunkH) { + for (int cx = startCX; cx < endX; cx += chunkW) { + boolean hasData = false; + chunkCheck: + for (int y = cy; y < cy + chunkH; y++) { + for (int x = cx; x < cx + chunkW; x++) { + Properties tip = l.getTileInstancePropertiesAt(x, y); + if (l.getTileAt(x, y) != null || (tip != null && !tip.isEmpty())) { + hasData = true; + break chunkCheck; + } + } } + if (!hasData) continue; + + w.startElement("chunk"); + w.writeAttribute("x", cx); + w.writeAttribute("y", cy); + w.writeAttribute("width", chunkW); + w.writeAttribute("height", chunkH); + + writeLayerDataRect(l, w, cx, cy, chunkW, chunkH); - w.startElement("tile"); - w.writeAttribute("gid", gid); w.endElement(); } } + } else { + writeLayerDataRect(l, w, bounds.x, bounds.y, bounds.width, bounds.height); } w.endElement(); boolean tilePropertiesElementStarted = false; - for (int y = 0; y < l.getHeight(); y++) { - for (int x = 0; x < l.getWidth(); x++) { - Properties tip = tl.getTileInstancePropertiesAt(x, y); + for (int y = bounds.y; y < bounds.y + bounds.height; y++) { + for (int x = bounds.x; x < bounds.x + bounds.width; x++) { + Properties tip = l.getTileInstancePropertiesAt(x, y); if (tip != null && !tip.isEmpty()) { if (!tilePropertiesElementStarted) { @@ -560,6 +926,107 @@ private void writeMapLayer(TileLayer l, XMLWriter w, String wp) throws IOExcepti w.endElement(); } + private boolean usesCsvEncoding() { + return Settings.LAYER_ENCODING_CSV.equalsIgnoreCase(settings.layerEncoding); + } + + private void writeLayerDataRect(TileLayer tl, XMLWriter w, int startX, int startY, int rectWidth, int rectHeight) throws IOException { + if (usesCsvEncoding()) { + StringBuilder csv = new StringBuilder(); + for (int y = startY; y < startY + rectHeight; y++) { + for (int x = startX; x < startX + rectWidth; x++) { + Tile tile = tl.getTileAt(x, y); + int gid = 0; + + if (tile != null) { + gid = getGid(tile); + gid |= tl.getFlagsAt(x, y); + } + + csv.append(gid & 0xFFFFFFFFL); + boolean lastValue = y == startY + rectHeight - 1 + && x == startX + rectWidth - 1; + if (!lastValue) { + csv.append(','); + if (x == startX + rectWidth - 1) { + csv.append('\n'); + } + } + } + } + w.writeCDATA(csv.toString()); + } else if (ENCODE_LAYER_DATA) { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + OutputStream out; + + OutputStream compressedOut = null; + if (COMPRESS_LAYER_DATA) { + if (Settings.LAYER_COMPRESSION_METHOD_ZLIB.equalsIgnoreCase(settings.layerCompressionMethod)) { + compressedOut = new DeflaterOutputStream(baos); + } else if (Settings.LAYER_COMPRESSION_METHOD_GZIP.equalsIgnoreCase(settings.layerCompressionMethod)) { + compressedOut = new GZIPOutputStream(baos); + } else if (Settings.LAYER_COMPRESSION_METHOD_ZSTD.equalsIgnoreCase(settings.layerCompressionMethod)) { + try { + compressedOut = new ZstdOutputStream(baos); + } catch (NoClassDefFoundError e) { + throw new IOException("Writing zstd-compressed data requires the" + + " com.github.luben:zstd-jni dependency", e); + } + } else { + throw new IOException("Unrecognized compression method \"" + settings.layerCompressionMethod + "\""); + } + out = compressedOut; + } else { + out = baos; + } + + for (int y = startY; y < startY + rectHeight; y++) { + for (int x = startX; x < startX + rectWidth; x++) { + Tile tile = tl.getTileAt(x, y); + int gid = 0; + + if (tile != null) { + gid = getGid(tile); + gid |= tl.getFlagsAt(x, y); + } + + out.write(gid & LAST_BYTE); + out.write(gid >> Byte.SIZE & LAST_BYTE); + out.write(gid >> Byte.SIZE * 2 & LAST_BYTE); + out.write(gid >> Byte.SIZE * 3 & LAST_BYTE); + } + } + + if (COMPRESS_LAYER_DATA && compressedOut != null) { + if (compressedOut instanceof DeflaterOutputStream) { + ((DeflaterOutputStream) compressedOut).finish(); + } else { + compressedOut.close(); + } + } + + byte[] dec = baos.toByteArray(); + w.writeCDATA(java.util.Base64.getEncoder().encodeToString(dec)); + } else { + for (int y = startY; y < startY + rectHeight; y++) { + for (int x = startX; x < startX + rectWidth; x++) { + Tile tile = tl.getTileAt(x, y); + int gid = 0; + + if (tile != null) { + gid = getGid(tile); + gid |= tl.getFlagsAt(x, y); + } + + w.startElement("tile"); + // Write as unsigned so set flip flags don't turn the gid negative. + w.writeAttribute("gid", gid & 0xFFFFFFFFL); + w.endElement(); + } + } + } + } + /** * Used to write tile elements for tilesets not based on a tileset image. * @@ -571,20 +1038,48 @@ private void writeTile(Tile tile, XMLWriter w, String wp) throws IOException { w.startElement("tile"); w.writeAttribute("id", tile.getId()); - if (!tile.getType().isEmpty()) { + if (tile.getImageX() != null) { + w.writeAttribute("x", tile.getImageX()); + } + if (tile.getImageY() != null) { + w.writeAttribute("y", tile.getImageY()); + } + if (tile.getImageWidth() != null) { + w.writeAttribute("width", tile.getImageWidth()); + } + if (tile.getImageHeight() != null) { + w.writeAttribute("height", tile.getImageHeight()); + } + + if (isNonEmpty(tile.getType())) { w.writeAttribute("type", tile.getType()); } + if (tile.getProbability() != null && tile.getProbability() != 1.0) { + w.writeAttribute("probability", tile.getProbability()); + } + if (!tile.getProperties().isEmpty()) { writeProperties(tile.getProperties(), w); } if (tile.getSource() != null) { writeImage(tile, w, wp); + } else if (tile.getImage() != null) { + w.startElement("image"); + w.writeAttribute("width", tile.getWidth()); + w.writeAttribute("height", tile.getHeight()); + w.writeAttribute("format", "png"); + writeEmbeddedImageData(w, tile.getImage()); + w.endElement(); } - if (tile instanceof AnimatedTile) { - writeAnimation(((AnimatedTile) tile).getSprite(), w); + if (tile.getCollisionObjectGroup() != null) { + writeObjectGroup(tile.getCollisionObjectGroup(), w, wp); + } + + if (hasAnimation(tile)) { + writeAnimation(tile, w); } w.endElement(); @@ -598,47 +1093,84 @@ private void writeImage(Tile t, XMLWriter w, String wp) throws IOException { w.endElement(); } - private void writeAnimation(Sprite s, XMLWriter w) throws IOException { - w.startElement("animation"); - for (int k = 0; k < s.getTotalKeys(); k++) { - Sprite.KeyFrame key = s.getKey(k); - w.startElement("keyframe"); - w.writeAttribute("name", key.getName()); - for (int it = 0; it < key.getTotalFrames(); it++) { - Tile stile = key.getFrame(it); - w.startElement("tile"); - w.writeAttribute("gid", getGid(stile)); + /** + * Writes an image as an embedded base64-encoded PNG data element. + */ + private static void writeEmbeddedImageData(XMLWriter w, Image image) throws IOException { + BufferedImage buffered; + if (image instanceof BufferedImage) { + buffered = (BufferedImage) image; + } else { + buffered = new BufferedImage( + image.getWidth(null), image.getHeight(null), BufferedImage.TYPE_INT_ARGB); + Graphics2D g = buffered.createGraphics(); + try { + g.drawImage(image, 0, 0, null); + } finally { + g.dispose(); + } + } + w.startElement("data"); + w.writeAttribute("encoding", "base64"); + w.writeCDATA(java.util.Base64.getEncoder().encodeToString(ImageHelper.imageToPNG(buffered))); + w.endElement(); + } + + private void writeAnimation(Tile tile, XMLWriter w) throws IOException { + Animation anim = tile.getAnimation(); + if (anim != null && anim.getFrame() != null && !anim.getFrame().isEmpty()) { + w.startElement("animation"); + for (Frame frame : anim.getFrame()) { + w.startElement("frame"); + w.writeAttribute("tileid", frame.getTileid()); + w.writeAttribute("duration", frame.getDuration() != null ? frame.getDuration() : 100); w.endElement(); } w.endElement(); + } else if (tile instanceof AnimatedTile) { + Sprite s = ((AnimatedTile) tile).getSprite(); + if (s != null) { + w.startElement("animation"); + for (int k = 0; k < s.getTotalKeys(); k++) { + Sprite.KeyFrame key = s.getKey(k); + for (int it = 0; it < key.getTotalFrames(); it++) { + Tile stile = key.getFrame(it); + if (stile != null) { + w.startElement("frame"); + w.writeAttribute("tileid", stile.getId()); + w.writeAttribute("duration", 100); + w.endElement(); + } + } + } + w.endElement(); + } } - w.endElement(); } private void writeMapObject(MapObject mapObject, XMLWriter w, String wp) throws IOException { w.startElement("object"); - w.writeAttribute("id", mapObject.getId()); + if (mapObject.getId() != null) { + w.writeAttribute("id", mapObject.getId()); + } + + if (isNonEmpty(mapObject.getTemplate())) { + w.writeAttribute("template", mapObject.getTemplate()); + } long gid = 0; if (mapObject.getTile() != null) { Tile t = mapObject.getTile(); - gid = firstGidPerTileset.get(t.getTileSet()) + t.getId(); + Integer firstGid = firstGidPerTileset.get(t.getTileSet()); + if (firstGid != null) { + gid = firstGid + t.getId(); + } } else if (mapObject.getGid() != null) { gid = mapObject.getGid(); } - if (mapObject.getFlipHorizontal()) { - gid |= TMXMapReader.FLIPPED_HORIZONTALLY_FLAG; - } - - if (mapObject.getFlipVertical()) { - gid |= TMXMapReader.FLIPPED_VERTICALLY_FLAG; - } - - if (mapObject.getFlipDiagonal()) { - gid |= TMXMapReader.FLIPPED_DIAGONALLY_FLAG; - } + gid |= buildFlipFlags(mapObject); if (gid != 0) { w.writeAttribute("gid", gid); @@ -648,35 +1180,98 @@ private void writeMapObject(MapObject mapObject, XMLWriter w, String wp) w.writeAttribute("name", mapObject.getName()); } - if (mapObject.getType().length() != 0) { + if (!mapObject.getType().isEmpty()) { w.writeAttribute("type", mapObject.getType()); } w.writeAttribute("x", mapObject.getX()); w.writeAttribute("y", mapObject.getY()); - // TODO: Implement Polygon, Ellipse & Polyline too - boolean isPoint = mapObject.getPoint() != null; - if (isPoint) { - w.startElement("point"); - w.endElement(); + if (mapObject.getWidth() != null && mapObject.getWidth() != 0) { + w.writeAttribute("width", mapObject.getWidth()); } - else { - if (mapObject.getWidth() != 0) { - w.writeAttribute("width", mapObject.getWidth()); - } - if (mapObject.getHeight() != 0) { - w.writeAttribute("height", mapObject.getHeight()); - } + if (mapObject.getHeight() != null && mapObject.getHeight() != 0) { + w.writeAttribute("height", mapObject.getHeight()); } if (mapObject.getRotation() != 0) { w.writeAttribute("rotation", mapObject.getRotation()); } + if (mapObject.getOpacity() != null && mapObject.getOpacity() != 1.0) { + w.writeAttribute("opacity", mapObject.getOpacity()); + } + + if (mapObject.isVisible() != null && !mapObject.isVisible()) { + w.writeAttribute("visible", "0"); + } + writeProperties(mapObject.getProperties(), w); - if (mapObject.getImageSource().length() > 0) { + if (mapObject.getPoint() != null) { + w.startElement("point"); + w.endElement(); + } else if (mapObject.getEllipse() != null) { + w.startElement("ellipse"); + w.endElement(); + } else if (mapObject.getPolygon() != null) { + w.startElement("polygon"); + if (mapObject.getPolygon().getPoints() != null) { + w.writeAttribute("points", mapObject.getPolygon().getPoints()); + } + w.endElement(); + } else if (mapObject.getPolyline() != null) { + w.startElement("polyline"); + if (mapObject.getPolyline().getPoints() != null) { + w.writeAttribute("points", mapObject.getPolyline().getPoints()); + } + w.endElement(); + } else if (mapObject.getText() != null) { + org.mapeditor.core.Text text = mapObject.getText(); + w.startElement("text"); + if (text.getFontfamily() != null) { + w.writeAttribute("fontfamily", text.getFontfamily()); + } + if (text.getPixelsize() != null) { + w.writeAttribute("pixelsize", text.getPixelsize()); + } + if (text.isWrap()) { + w.writeAttribute("wrap", "1"); + } + if (text.getColor() != null) { + w.writeAttribute("color", text.getColor()); + } + if (text.isBold()) { + w.writeAttribute("bold", "1"); + } + if (text.isItalic()) { + w.writeAttribute("italic", "1"); + } + if (text.isUnderline()) { + w.writeAttribute("underline", "1"); + } + if (text.isStrikeout()) { + w.writeAttribute("strikeout", "1"); + } + if (!text.isKerning()) { + w.writeAttribute("kerning", "0"); + } + if (text.getHalign() != org.mapeditor.core.HorizontalAlignment.LEFT) { + w.writeAttribute("halign", text.getHalign().value()); + } + if (text.getValign() != org.mapeditor.core.VerticalAlignment.TOP) { + w.writeAttribute("valign", text.getValign().value()); + } + if (text.getValue() != null) { + w.writeCharacters(text.getValue()); + } + w.endElement(); + } else if (mapObject.getCapsule() != null) { + w.startElement("capsule"); + w.endElement(); + } + + if (!mapObject.getImageSource().isEmpty()) { w.startElement("image"); w.writeAttribute("source", getRelativePath(wp, mapObject.getImageSource())); diff --git a/util/java/libtiled-java/src/main/java/org/mapeditor/io/xml/XMLWriter.java b/util/java/libtiled-java/src/main/java/org/mapeditor/io/xml/XMLWriter.java index ce6178350d..e66bccb7c5 100644 --- a/util/java/libtiled-java/src/main/java/org/mapeditor/io/xml/XMLWriter.java +++ b/util/java/libtiled-java/src/main/java/org/mapeditor/io/xml/XMLWriter.java @@ -52,6 +52,7 @@ public class XMLWriter { private final Stack openElements; private boolean bStartTagOpen; private boolean bDocumentOpen; + private boolean bInlineText; /** * Constructor for XMLWriter. @@ -187,6 +188,9 @@ public void endElement() throws IOException { if (bStartTagOpen) { w.write("/>" + newLine); bStartTagOpen = false; + } else if (bInlineText) { + w.write("" + newLine); + bInlineText = false; } else { writeIndent(); w.write("" + newLine); @@ -210,7 +214,7 @@ public void writeAttribute(String name, String content) throws IOException, XMLWriterException { if (bStartTagOpen) { String escapedContent = (content != null) - ? content.replaceAll("\"", """) : ""; + ? escapeText(content).replace("\"", """) : ""; w.write(" " + name + "=\"" + escapedContent + "\""); } else { throw new XMLWriterException( @@ -218,6 +222,12 @@ public void writeAttribute(String name, String content) } } + private static String escapeText(String content) { + return content.replace("&", "&") + .replace("<", "<") + .replace(">", ">"); + } + /** * writeAttribute. * @@ -283,6 +293,26 @@ public void writeAttribute(String name, double content) * @param content a {@link java.lang.String} object. * @throws java.io.IOException if any. */ + /** + * Writes element text content inline, without surrounding indentation or + * newlines, so the value round-trips exactly. + * + * @param content the text content to write + * @throws java.io.IOException if any. + * @throws org.mapeditor.io.xml.XMLWriterException if any. + */ + public void writeCharacters(String content) + throws IOException, XMLWriterException { + if (!bStartTagOpen) { + throw new XMLWriterException( + "Can't write characters without open start tag."); + } + w.write(">"); + bStartTagOpen = false; + w.write(escapeText(content != null ? content : "")); + bInlineText = true; + } + public void writeCDATA(String content) throws IOException { if (bStartTagOpen) { w.write(">" + newLine); @@ -290,7 +320,7 @@ public void writeCDATA(String content) throws IOException { } writeIndent(); - w.write(content + newLine); + w.write(escapeText(content != null ? content : "") + newLine); } /** diff --git a/util/java/libtiled-java/src/main/java/org/mapeditor/util/ImageHelper.java b/util/java/libtiled-java/src/main/java/org/mapeditor/util/ImageHelper.java index 8bb6745040..ff339e6683 100644 --- a/util/java/libtiled-java/src/main/java/org/mapeditor/util/ImageHelper.java +++ b/util/java/libtiled-java/src/main/java/org/mapeditor/util/ImageHelper.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,13 +30,21 @@ */ package org.mapeditor.util; +import java.awt.Graphics2D; import java.awt.image.BufferedImage; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.io.File; import java.io.IOException; +import java.net.URL; +import java.util.Locale; import javax.imageio.ImageIO; +import com.github.weisj.jsvg.SVGDocument; +import com.github.weisj.jsvg.parser.SVGLoader; +import com.github.weisj.jsvg.view.FloatSize; + /** * This class provides functions to help out with saving/loading images. * @@ -81,4 +89,90 @@ public static byte[] imageToPNG(BufferedImage image) { public static BufferedImage bytesToImage(byte[] imageData) throws IOException { return ImageIO.read(new ByteArrayInputStream(imageData)); } + + /** + * Returns whether the given path refers to an SVG file. + * + * @param path a file path or URL string + * @return true if the path ends with .svg or .svgz + */ + public static boolean isSvg(String path) { + if (path == null) { + return false; + } + String lower = path.toLowerCase(Locale.ROOT); + return lower.endsWith(".svg") || lower.endsWith(".svgz"); + } + + /** + * Reads an SVG file from the given URL and renders it to a BufferedImage. + * If width or height is 0, the SVG's intrinsic size is used. + * + * @param url the URL to read the SVG from + * @param width desired width, or 0 to use the SVG's intrinsic width + * @param height desired height, or 0 to use the SVG's intrinsic height + * @return a BufferedImage with the rendered SVG, or null if loading fails + * @throws IOException if the SVG cannot be read + */ + public static BufferedImage readSvg(URL url, int width, int height) throws IOException { + // Guarded in a separate method so a missing jsvg dependency + // surfaces here as a catchable NoClassDefFoundError. + try { + return readSvgImpl(url, width, height); + } catch (NoClassDefFoundError e) { + throw new IOException("Rendering SVG images requires the" + + " com.github.weisj:jsvg dependency", e); + } + } + + private static BufferedImage readSvgImpl(URL url, int width, int height) throws IOException { + SVGLoader loader = new SVGLoader(); + SVGDocument doc = loader.load(url); + if (doc == null) { + return null; + } + + FloatSize size = doc.size(); + int w = width > 0 ? width : Math.max(1, (int) size.width); + int h = height > 0 ? height : Math.max(1, (int) size.height); + + BufferedImage image = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = image.createGraphics(); + try { + doc.render(null, g); + } finally { + g.dispose(); + } + return image; + } + + /** + * Reads an image from a URL. SVG files are rendered using JSVG; + * other formats are read using ImageIO. + * + * @param url the URL to read from + * @return the loaded BufferedImage, or null if the format is unsupported + * @throws IOException if the image cannot be read + */ + public static BufferedImage readImage(URL url) throws IOException { + if (isSvg(url.getPath())) { + return readSvg(url, 0, 0); + } + return ImageIO.read(url); + } + + /** + * Reads an image from a File. SVG files are rendered using JSVG; + * other formats are read using ImageIO. + * + * @param file the file to read from + * @return the loaded BufferedImage, or null if the format is unsupported + * @throws IOException if the image cannot be read + */ + public static BufferedImage readImage(File file) throws IOException { + if (isSvg(file.getName())) { + return readSvg(file.toURI().toURL(), 0, 0); + } + return ImageIO.read(file); + } } diff --git a/util/java/libtiled-java/src/main/java/org/mapeditor/view/AbstractRenderer.java b/util/java/libtiled-java/src/main/java/org/mapeditor/view/AbstractRenderer.java new file mode 100644 index 0000000000..f2960f73b7 --- /dev/null +++ b/util/java/libtiled-java/src/main/java/org/mapeditor/view/AbstractRenderer.java @@ -0,0 +1,236 @@ +/*- + * #%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.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 @@ + + + + + +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 + + +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, +0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 + + + + 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 @@ + + + + + + + + + + + + + + + + + + +0,0,0, +0,0,0, +0,0,0 + + + + +0,0,0, +0,0,0, +0,0,0 + + + + +0,0,0, +0,0,0, +0,0,0 + + + + + + + + + + + + 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 @@ + + + + + +1,2, +2,1 + + + 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 @@ + + + + + +1,1, +1,1 + + + 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 +}