diff --git a/build.gradle b/build.gradle index a4f372293..ae1cdfe81 100644 --- a/build.gradle +++ b/build.gradle @@ -49,6 +49,8 @@ dependencies { implementation 'com.sun.xml.bind:jaxb-core:2.3.0.1' implementation 'com.sun.xml.bind:jaxb-impl:2.3.2' + implementation 'com.google.code.gson:gson:2.8.6' + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.6.0' testImplementation 'org.junit.jupiter:junit-jupiter-params:5.6.0' testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.6.0' diff --git a/src/de/gurkenlabs/litiengine/graphics/animation/AsepriteHandler.java b/src/de/gurkenlabs/litiengine/graphics/animation/AsepriteHandler.java new file mode 100644 index 000000000..70318e23e --- /dev/null +++ b/src/de/gurkenlabs/litiengine/graphics/animation/AsepriteHandler.java @@ -0,0 +1,412 @@ +package de.gurkenlabs.litiengine.graphics.animation; + +import java.io.File; +import java.io.FileReader; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.awt.Dimension; +import java.awt.image.BufferedImage; +import java.util.Set; +import java.util.Map; +import java.util.HashMap; +import javax.imageio.ImageIO; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; +import com.google.gson.JsonObject; + +import de.gurkenlabs.litiengine.graphics.Spritesheet; +import de.gurkenlabs.litiengine.resources.Resources; +import de.gurkenlabs.litiengine.resources.SpritesheetResource; + +/** + * Offers an interface to import Aseprite JSON export format. + * Note: requires animation key frames to have same dimensions to support internal animation format. + */ +public class AsepriteHandler { + + /** + * Thrown to indicate error when importing Aseprite JSON format. + */ + public static class ImportAnimationException extends IOException { + public ImportAnimationException(String message) { + super(message); + } + + public ImportAnimationException(String message, Throwable cause) { + super(message, cause); + } + + public ImportAnimationException(Throwable cause) { + super(cause); + } + } + + /** + * Imports an Aseprite animation (.json + sprite sheet). + * + * @param jsonPath path (including filename) to Aseprite JSON. + * @return Animation object represented by each key frame in Aseprite sprite sheet. + */ + public static Animation importAnimation(String jsonPath) throws IOException, FileNotFoundException, AsepriteHandler.ImportAnimationException { + + JsonElement rootElement = null; + try { + rootElement = getRootJsonElement(jsonPath); + } catch (FileNotFoundException e) { + throw new FileNotFoundException("FileNotFoundException: Could not find .json file " + jsonPath); + } + + File spriteSheetFile = null; + try { + spriteSheetFile = getSpriteSheetFile(rootElement, jsonPath); + } catch (FileNotFoundException e) { + throw new FileNotFoundException("FileNotFoundException: Could not find sprite sheet file. " + + "Expected location is 'image' in .json metadata, or same folder as .json file."); + } + + Dimension keyFrameDimensions = getKeyFrameDimensions(rootElement); + if (areKeyFramesSameDimensions(rootElement, keyFrameDimensions)) { + + BufferedImage image = null; + try { + image = ImageIO.read(spriteSheetFile); + } catch (IOException e) { + throw new IOException("IOException: Could not write sprite sheet data to BufferedImage object."); + } + + Spritesheet spriteSheet = new Spritesheet(image, + spriteSheetFile.getPath().toString(), + (int) keyFrameDimensions.getWidth(), + (int) keyFrameDimensions.getHeight()); + + return new Animation(spriteSheet, false, getKeyFrameDurations(rootElement)); + } + + throw new AsepriteHandler.ImportAnimationException("AsepriteHandler.ImportAnimationException: animation key frames require same dimensions."); + } + + /** + * @param jsonPath path (including filename) to Aseprite .json file. + * @return root element of JSON data. + */ + private static JsonElement getRootJsonElement(String jsonPath) throws FileNotFoundException { + + File jsonFile = new File(jsonPath); + + try { + JsonElement rootElement = JsonParser.parseReader(new FileReader(jsonFile)); + return rootElement; + } catch (FileNotFoundException e) { + throw e; + } + } + + /** + * Searches for sprite sheet path through .json metadata and same folder as .json file. + * + * @param rootElement root element of JSON data. + * @param jsonPath path (including filename) to .json Aseprite file. + * @return sprite sheet file if it can be found, else an exception is thrown. + */ + private static File getSpriteSheetFile(JsonElement rootElement, String jsonPath) throws FileNotFoundException { + + //try searching path supplied in .json data + JsonElement metaData = rootElement.getAsJsonObject().get("meta"); + String spriteSheetPath = metaData.getAsJsonObject().get("image").getAsString(); + + File spriteSheetFile = new File(spriteSheetPath); + + if (spriteSheetFile.exists()) + return spriteSheetFile; + + //try searching local directory + Path jsonFilePath = Paths.get(jsonPath); + String dirPath = jsonFilePath.getParent().toString(); + + //same name as filename in 'image' element + String fileNameSheet = Paths.get(spriteSheetPath).getFileName().toString(); //name of spritesheet + String alternativePath1 = dirPath + "/" + fileNameSheet; + + spriteSheetFile = new File(alternativePath1); + if(spriteSheetFile.exists()) + return spriteSheetFile; + + //same name as .json file + String fileNameJson = jsonFilePath.getFileName().toString(); + String trimmedJsonName = fileNameJson.substring(0, fileNameJson.indexOf(".")); //without suffix + String sheetSuffix = fileNameSheet.substring(fileNameSheet.indexOf("."), fileNameSheet.length()); + String alternativePath2 = dirPath + "/" + trimmedJsonName + sheetSuffix; + + spriteSheetFile = new File(alternativePath2); + if(spriteSheetFile.exists()) + return spriteSheetFile; + + throw new FileNotFoundException(); + } + + /** + * @param rootElement root element of JSON data. + * @return dimensions of first key frame. + */ + private static Dimension getKeyFrameDimensions(JsonElement rootElement) { + + JsonElement frames = rootElement.getAsJsonObject().get("frames"); + + JsonObject firstFrameObject = frames.getAsJsonObject().entrySet().iterator().next().getValue().getAsJsonObject(); + JsonObject frameDimensions = firstFrameObject.get("sourceSize").getAsJsonObject(); + + int frameWidth = frameDimensions.get("w").getAsInt(); + int frameHeight = frameDimensions.get("h").getAsInt(); + + return new Dimension(frameWidth, frameHeight); + } + + /** + * @param rootElement root element of JSON data. + * @param expected expected dimensions of each key frame. + * @return true if key frames have same duration. + */ + private static boolean areKeyFramesSameDimensions(JsonElement rootElement, Dimension expected) { + + JsonElement frames = rootElement.getAsJsonObject().get("frames"); + + for (Map.Entry entry : frames.getAsJsonObject().entrySet()) { + JsonObject frameObject = entry.getValue().getAsJsonObject(); + JsonObject frameDimensions = frameObject.get("sourceSize").getAsJsonObject(); + + int frameWidth = frameDimensions.get("w").getAsInt(); + int frameHeight = frameDimensions.get("h").getAsInt(); + + if (frameWidth != expected.getWidth() || frameHeight != expected.getHeight()) + return false; + } + + return true; + } + + /** + * @param rootElement root element of JSON data. + * @return integer array representing duration of each key frame. + */ + public static int[] getKeyFrameDurations(JsonElement rootElement) { + + JsonElement frames = rootElement.getAsJsonObject().get("frames"); + + Set> keyFrameSet = frames.getAsJsonObject().entrySet(); + + int[] keyFrameDurations = new int[keyFrameSet.size()]; + + int frameIndex = 0; + for (Map.Entry entry : keyFrameSet) { + JsonObject frameObject = entry.getValue().getAsJsonObject(); + int frameDuration = frameObject.get("duration").getAsInt(); + keyFrameDurations[frameIndex++] = frameDuration; + } + + return keyFrameDurations; + } + + /** + * Error that is thrown by the export class + */ + public static class ExportAnimationException extends IOException { + public ExportAnimationException(String message) { + super(message); + } + + public ExportAnimationException(String message, Throwable cause) { + super(message, cause); + } + + public ExportAnimationException(Throwable cause) { + super(cause); + } + } + + /** + * Creates the json representation of an animation object and returns it. + * This is the public accesible function and can/should be changed to fit into the UI. + * + * @param spritesheetResource the animation object to export + * @throws ExportAnimationException if the export fails + */ + public static String exportAnimation(SpritesheetResource spritesheetResource) throws ExportAnimationException { + String json = createJson(spritesheetResource); + return json; + } + + /** + * Creates the json representation of an animation object and returns it as a string. + * + * @param spritesheetResource spritesheetResource object to export as json. + * @return the json as a string. + * @throws ExportAnimationException if the export fails + */ + private static String createJson(SpritesheetResource spritesheetResource) throws ExportAnimationException { + Spritesheet spritesheet = Resources.spritesheets().load(spritesheetResource); + assert spritesheet != null; + int[] keyframes = Resources.spritesheets().getCustomKeyFrameDurations(spritesheet); + Frames[] frames = new Frames[keyframes.length]; + + if (frames.length != spritesheet.getTotalNumberOfSprites()) { + throw new ExportAnimationException("Different dimensions of keyframes and sprites in spritesheet"); + } + + // Build the frames object in the json + int numCol = spritesheet.getColumns(); + int numRows = spritesheet.getRows(); + int frameWidth = spritesheet.getSpriteWidth(); + int frameHeight = spritesheet.getSpriteHeight(); + + for (int i = 0; i < numRows; i++) { + for (int j = 0; j < numCol; j++) { + final int row = i; + final int col = j; + Map frame = new HashMap() {{ + put("x", (0 + col * frameWidth)); + put("y", (0 + row * frameHeight)); + put("w", frameWidth); + put("h", frameHeight); + }}; + Map spriteSourceSize = new HashMap() {{ + put("x", 0); + put("y", 0); + put("w", frameWidth); + put("h", frameHeight); + }}; + Map sourceSize = new HashMap() {{ + put("w", frameWidth); + put("h", frameHeight); + }}; + int duration = keyframes[i + j]; + String index = String.valueOf(i + j); + frames[i + j] = new Frames("frame " + index, + frame, + false, + false, + spriteSourceSize, + sourceSize, + duration); + } + } + + // Build the meta object in the json + int spritesheetWidth = frameWidth * numCol; + int spritesheetHeight = frameHeight * numRows; + Map size = new HashMap() {{ + put("w", spritesheetWidth); + put("h", spritesheetHeight); + }}; + String spritesheetName = spritesheet.getName(); + Layer[] layers = {new Layer("Layer", 255, "normal")}; + Meta meta = new Meta("http://www.aseprite.org/", + "1.2.16.3-x64", + spritesheetName, + "RGBA8888", size, "1", layers); + + // Create the json as string + Gson gson = new GsonBuilder().setPrettyPrinting().create(); + StringBuilder sb = new StringBuilder(); + + sb.append("{ \"frames\": {\n"); + for (int i = 0; i < frames.length; i++) { + String json = gson.toJson(frames[i]); + sb.append(" \"" + frames[i].name + "\": ").append(json).append(",\n"); + } + sb.append(" },\n"); + String json = gson.toJson(meta); + sb.append("\"meta\":").append(json).append("\n}"); + + return sb.toString(); + } + + /** + * Frames class for Aseprite json structure. + */ + private static class Frames { + transient String name; + Map frame; + boolean rotated; + boolean trimmed; + Map spriteSourceSize; + Map sourceSize; + int duration; + + /** + * @param name name of frame + * @param frame x, y, w, h on the substruction of the sprite in the spritesheet. + * @param rotated is the frame rotated? + * @param trimmed is the frame trimmed? + * @param spriteSourceSize how the sprite is trimmed. + * @param sourceSize the original sprite size. + * @param duration the duration of the frame + */ + public Frames(String name, Map frame, boolean rotated, boolean trimmed, Map spriteSourceSize, Map sourceSize, int duration) { + this.name = name; + this.frame = frame; + this.rotated = rotated; + this.trimmed = trimmed; + this.spriteSourceSize = spriteSourceSize; + this.sourceSize = sourceSize; + this.duration = duration; + } + } + + /** + * Meta data class for Aseprite json structure. + */ + private static class Meta { + String app; + String version; + String image; + String format; + Map size; + String scale; + Layer[] layers; + + /** + * @param app the application the json format comes from, in this case Aseprite. + * @param version Version of application. + * @param image filename of spritesheet. + * @param format color format of spritesheet image. + * @param size Size of spritesheet. + * @param scale Scale of spritesheet. + * @param layers Layers of spritesheet. + */ + public Meta(String app, String version, String image, String format, Map size, String scale, Layer[] layers) { + this.app = app; + this.version = version; + this.image = image; + this.format = format; + this.size = size; + this.scale = scale; + this.layers = layers; + } + } + + /** + * Layer class for Aseprite json structure. + */ + private static class Layer { + String name; + int opacity; + String blendMode; + + /** + * @param name Name of layer. + * @param opacity Opacity level of layer. + * @param blendMode Blendmode of layer. + */ + public Layer(String name, int opacity, String blendMode) { + this.name = name; + this.opacity = opacity; + this.blendMode = blendMode; + } + + } +} diff --git a/tests/de/gurkenlabs/litiengine/graphics/animation/AsepriteHandlerTests.java b/tests/de/gurkenlabs/litiengine/graphics/animation/AsepriteHandlerTests.java new file mode 100644 index 000000000..808a42c7d --- /dev/null +++ b/tests/de/gurkenlabs/litiengine/graphics/animation/AsepriteHandlerTests.java @@ -0,0 +1,90 @@ +package de.gurkenlabs.litiengine.graphics.animation; + +import java.io.IOException; +import java.io.FileNotFoundException; + +import java.awt.image.BufferedImage; + +import static org.junit.jupiter.api.Assertions.fail; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; +import de.gurkenlabs.litiengine.graphics.Spritesheet; +import de.gurkenlabs.litiengine.graphics.animation.AsepriteHandler.ImportAnimationException; +import de.gurkenlabs.litiengine.graphics.animation.AsepriteHandler.ExportAnimationException; +import de.gurkenlabs.litiengine.resources.ImageFormat; +import de.gurkenlabs.litiengine.resources.SpritesheetResource; + +public class AsepriteHandlerTests { + + /** + * Tests that Aseprite animation import works as expected when given valid input. + */ + @Test + public void importAsepriteAnimationTest() { + try { + Animation animation = AsepriteHandler.importAnimation("tests/de/gurkenlabs/litiengine/graphics/animation/aseprite_test_animations/Sprite-0001.json"); + Animation animation2 = AsepriteHandler.importAnimation("tests/de/gurkenlabs/litiengine/graphics/animation/aseprite_test_animations/Sprite-0005.json"); + assertEquals("Sprite-0005", animation2.getName()); + assertEquals("Sprite-0001-sheet", animation.getName()); + assertEquals(300, animation.getTotalDuration()); + for (int keyFrameDuration : animation.getKeyFrameDurations()) + assertEquals(100, keyFrameDuration); + Spritesheet spriteSheet = animation.getSpritesheet(); + assertEquals(32, spriteSheet.getSpriteHeight()); + assertEquals(32, spriteSheet.getSpriteWidth()); + assertEquals(3, spriteSheet.getTotalNumberOfSprites()); + assertEquals(1, spriteSheet.getRows()); + assertEquals(3, spriteSheet.getColumns()); + assertEquals(ImageFormat.PNG, spriteSheet.getImageFormat()); + + BufferedImage image = spriteSheet.getImage(); + assertEquals(96, image.getWidth()); + assertEquals(32, image.getHeight()); + } catch (FileNotFoundException e) { + fail(e.getMessage()); + } catch (IOException e) { + fail(e.getMessage()); + } + } + + /** + * Test that if AsepriteHandler.ImportAnimationException will be throwed if different frame dimensions are provided. + */ + @Test + public void ImportAnimationExceptionTest() { + Throwable exception = assertThrows(ImportAnimationException.class, () -> AsepriteHandler.importAnimation("tests/de/gurkenlabs/litiengine/graphics/animation/aseprite_test_animations/Sprite-0002.json")); + assertEquals("AsepriteHandler.ImportAnimationException: animation key frames require same dimensions.", exception.getMessage()); + } + + /** + * Tests thrown FileNotFoundException when importing an Aseprite animation. + *

+ * 1.first, we test if FileNotFoundException would be throwed if .json file cannot be found. + * 2.then we test if FileNotFoundException would be throwed if spritesheet file cannot be found. + */ + @Test + public void FileNotFoundExceptionTest() { + Throwable exception_withoutJsonFile = assertThrows(FileNotFoundException.class, () -> AsepriteHandler.importAnimation("tests/de/gurkenlabs/litiengine/graphics/animation/aseprite_test_animations/Sprite-0003.json")); + assertEquals("FileNotFoundException: Could not find .json file tests/de/gurkenlabs/litiengine/graphics/animation/aseprite_test_animations/Sprite-0003.json", exception_withoutJsonFile.getMessage()); + Throwable exception_withoutSpriteSheet = assertThrows(FileNotFoundException.class, () -> AsepriteHandler.importAnimation("tests/de/gurkenlabs/litiengine/graphics/animation/aseprite_test_animations/Sprite-0004.json")); + assertEquals("FileNotFoundException: Could not find sprite sheet file. Expected location is 'image' in .json metadata, or same folder as .json file.", exception_withoutSpriteSheet.getMessage()); + } + + /** + * Test if exportAnimationException would be thrown when keyframes and spritesheet file have different dimensions. + */ + @Test + public void ExportAnimationExceptionTest(){ + String spritesheetPath = "tests/de/gurkenlabs/litiengine/graphics/animation/aseprite_test_animations/Sprite-0001-sheet.png"; + BufferedImage image = new BufferedImage(96, 32, BufferedImage.TYPE_4BYTE_ABGR); + Spritesheet spritesheet = new Spritesheet(image, spritesheetPath, 32, 32); + Animation animation = new Animation(spritesheet, false, false, 2, 2); + int[] keyFrames = animation.getKeyFrameDurations(); + SpritesheetResource spritesheetResource = new SpritesheetResource(animation.getSpritesheet()); + spritesheetResource.setKeyframes(keyFrames); + Throwable exception = assertThrows(ExportAnimationException.class, () -> AsepriteHandler.exportAnimation(spritesheetResource)); + assertEquals("Different dimensions of keyframes and sprites in spritesheet", exception.getMessage()); + } +} diff --git a/tests/de/gurkenlabs/litiengine/graphics/animation/aseprite_test_animations/Sprite-0001-sheet.png b/tests/de/gurkenlabs/litiengine/graphics/animation/aseprite_test_animations/Sprite-0001-sheet.png new file mode 100644 index 000000000..44a7c127f Binary files /dev/null and b/tests/de/gurkenlabs/litiengine/graphics/animation/aseprite_test_animations/Sprite-0001-sheet.png differ diff --git a/tests/de/gurkenlabs/litiengine/graphics/animation/aseprite_test_animations/Sprite-0001.json b/tests/de/gurkenlabs/litiengine/graphics/animation/aseprite_test_animations/Sprite-0001.json new file mode 100644 index 000000000..e512f005f --- /dev/null +++ b/tests/de/gurkenlabs/litiengine/graphics/animation/aseprite_test_animations/Sprite-0001.json @@ -0,0 +1,40 @@ +{ "frames": { + "Sprite-0001 0.png": { + "frame": { "x": 0, "y": 0, "w": 32, "h": 32 }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { "x": 0, "y": 0, "w": 32, "h": 32 }, + "sourceSize": { "w": 32, "h": 32 }, + "duration": 100 + }, + "Sprite-0001 1.png": { + "frame": { "x": 32, "y": 0, "w": 32, "h": 32 }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { "x": 0, "y": 0, "w": 32, "h": 32 }, + "sourceSize": { "w": 32, "h": 32 }, + "duration": 100 + }, + "Sprite-0001 2.png": { + "frame": { "x": 64, "y": 0, "w": 32, "h": 32 }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { "x": 0, "y": 0, "w": 32, "h": 32 }, + "sourceSize": { "w": 32, "h": 32 }, + "duration": 100 + } + }, + "meta": { + "app": "http://www.aseprite.org/", + "version": "1.1.9-dev", + "image": "tests/de/gurkenlabs/litiengine/graphics/animation/aseprite_test_animations/Sprite-0001-sheet.png", + "format": "RGBA8888", + "size": { "w": 96, "h": 32 }, + "scale": "1", + "frameTags": [ + ], + "layers": [ + { "name": "Layer 1", "opacity": 255, "blendMode": "normal" } + ] + } +} diff --git a/tests/de/gurkenlabs/litiengine/graphics/animation/aseprite_test_animations/Sprite-0002.json b/tests/de/gurkenlabs/litiengine/graphics/animation/aseprite_test_animations/Sprite-0002.json new file mode 100644 index 000000000..a75131d7d --- /dev/null +++ b/tests/de/gurkenlabs/litiengine/graphics/animation/aseprite_test_animations/Sprite-0002.json @@ -0,0 +1,41 @@ +{ "frames": { + "Sprite-0002 0.png": { + "frame": { "x": 0, "y": 0, "w": 32, "h": 32 }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { "x": 0, "y": 0, "w": 32, "h": 32 }, + "sourceSize": { "w": 32, "h": 32 }, + "duration": 100 + }, + "Sprite-0002 1.png": { + "frame": { "x": 32, "y": 0, "w": 32, "h": 32 }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { "x": 0, "y": 0, "w": 32, "h": 32 }, + "sourceSize": { "w": 64, "h": 64 }, + "duration": 100 + }, + "Sprite-0002 2.png": { + "frame": { "x": 64, "y": 0, "w": 32, "h": 32 }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { "x": 0, "y": 0, "w": 32, "h": 32 }, + "sourceSize": { "w": 32, "h": 32 }, + "duration": 100 + } + }, + "meta": { + "app": "http://www.aseprite.org/", + "version": "1.1.9-dev", + "image": "tests/de/gurkenlabs/litiengine/graphics/animation/aseprite_test_animations/Sprite-0001-sheet.png", + "format": "RGBA8888", + "size": { "w": 96, "h": 32 }, + "scale": "1", + "frameTags": [ + ], + "layers": [ + { "name": "Layer 1", "opacity": 255, "blendMode": "normal" } + ] + } + } + \ No newline at end of file diff --git a/tests/de/gurkenlabs/litiengine/graphics/animation/aseprite_test_animations/Sprite-0004.json b/tests/de/gurkenlabs/litiengine/graphics/animation/aseprite_test_animations/Sprite-0004.json new file mode 100644 index 000000000..d1aed4ced --- /dev/null +++ b/tests/de/gurkenlabs/litiengine/graphics/animation/aseprite_test_animations/Sprite-0004.json @@ -0,0 +1,41 @@ +{ "frames": { + "Sprite-0001 0.png": { + "frame": { "x": 0, "y": 0, "w": 32, "h": 32 }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { "x": 0, "y": 0, "w": 32, "h": 32 }, + "sourceSize": { "w": 32, "h": 32 }, + "duration": 100 + }, + "Sprite-0001 1.png": { + "frame": { "x": 32, "y": 0, "w": 32, "h": 32 }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { "x": 0, "y": 0, "w": 32, "h": 32 }, + "sourceSize": { "w": 32, "h": 32 }, + "duration": 100 + }, + "Sprite-0001 2.png": { + "frame": { "x": 64, "y": 0, "w": 32, "h": 32 }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { "x": 0, "y": 0, "w": 32, "h": 32 }, + "sourceSize": { "w": 32, "h": 32 }, + "duration": 100 + } + }, + "meta": { + "app": "http://www.aseprite.org/", + "version": "1.1.9-dev", + "image": "tests/de/gurkenlabs/litiengine/graphics/animation/aseprite_test_animations/Sprite-0002-sheet.png", + "format": "RGBA8888", + "size": { "w": 96, "h": 32 }, + "scale": "1", + "frameTags": [ + ], + "layers": [ + { "name": "Layer 1", "opacity": 255, "blendMode": "normal" } + ] + } + } + \ No newline at end of file diff --git a/tests/de/gurkenlabs/litiengine/graphics/animation/aseprite_test_animations/Sprite-0005.json b/tests/de/gurkenlabs/litiengine/graphics/animation/aseprite_test_animations/Sprite-0005.json new file mode 100644 index 000000000..d1aed4ced --- /dev/null +++ b/tests/de/gurkenlabs/litiengine/graphics/animation/aseprite_test_animations/Sprite-0005.json @@ -0,0 +1,41 @@ +{ "frames": { + "Sprite-0001 0.png": { + "frame": { "x": 0, "y": 0, "w": 32, "h": 32 }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { "x": 0, "y": 0, "w": 32, "h": 32 }, + "sourceSize": { "w": 32, "h": 32 }, + "duration": 100 + }, + "Sprite-0001 1.png": { + "frame": { "x": 32, "y": 0, "w": 32, "h": 32 }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { "x": 0, "y": 0, "w": 32, "h": 32 }, + "sourceSize": { "w": 32, "h": 32 }, + "duration": 100 + }, + "Sprite-0001 2.png": { + "frame": { "x": 64, "y": 0, "w": 32, "h": 32 }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { "x": 0, "y": 0, "w": 32, "h": 32 }, + "sourceSize": { "w": 32, "h": 32 }, + "duration": 100 + } + }, + "meta": { + "app": "http://www.aseprite.org/", + "version": "1.1.9-dev", + "image": "tests/de/gurkenlabs/litiengine/graphics/animation/aseprite_test_animations/Sprite-0002-sheet.png", + "format": "RGBA8888", + "size": { "w": 96, "h": 32 }, + "scale": "1", + "frameTags": [ + ], + "layers": [ + { "name": "Layer 1", "opacity": 255, "blendMode": "normal" } + ] + } + } + \ No newline at end of file diff --git a/tests/de/gurkenlabs/litiengine/graphics/animation/aseprite_test_animations/Sprite-0005.png b/tests/de/gurkenlabs/litiengine/graphics/animation/aseprite_test_animations/Sprite-0005.png new file mode 100644 index 000000000..44a7c127f Binary files /dev/null and b/tests/de/gurkenlabs/litiengine/graphics/animation/aseprite_test_animations/Sprite-0005.png differ diff --git a/utiliti/localization/strings.properties b/utiliti/localization/strings.properties index 3f08e7d9b..d875be8e4 100644 --- a/utiliti/localization/strings.properties +++ b/utiliti/localization/strings.properties @@ -58,6 +58,7 @@ menu_assets_importBlueprints=Import Blueprints... menu_assets_importTilesets=Import Tilesets... menu_assets_importSounds=Import Sounds... menu_assets_editSprite=Edit Spritesheet(s) +menu_assets_importAnimation=Import Animation... menu_help=Help menu_help_utiliti=utiLITI diff --git a/utiliti/src/de/gurkenlabs/utiliti/components/Editor.java b/utiliti/src/de/gurkenlabs/utiliti/components/Editor.java index d95dfdb15..108b3c2c8 100644 --- a/utiliti/src/de/gurkenlabs/utiliti/components/Editor.java +++ b/utiliti/src/de/gurkenlabs/utiliti/components/Editor.java @@ -38,6 +38,8 @@ import de.gurkenlabs.litiengine.environment.tilemap.xml.TmxMap; import de.gurkenlabs.litiengine.graphics.Spritesheet; import de.gurkenlabs.litiengine.graphics.TextRenderer; +import de.gurkenlabs.litiengine.graphics.animation.Animation; +import de.gurkenlabs.litiengine.graphics.animation.AsepriteHandler; import de.gurkenlabs.litiengine.graphics.emitters.xml.EmitterData; import de.gurkenlabs.litiengine.graphics.emitters.xml.EmitterLoader; import de.gurkenlabs.litiengine.gui.screens.Screen; @@ -77,6 +79,7 @@ public class Editor extends Screen { private static final String TEXTUREATLAS_FILE_NAME = "Texture Atlas XML (generic)"; private static final String IMPORT_DIALOGUE = "import_something"; + private static final String ANIMATION_FILE_NAME = "Animation file"; private static Editor instance; private static UserPreferences preferences; @@ -380,6 +383,12 @@ public void importSpriteSheets() { } } + public void importAnimation() { + if (EditorFileChooser.showFileDialog(ANIMATION_FILE_NAME, Resources.strings().get(IMPORT_DIALOGUE, ANIMATION_FILE_NAME), false, "json") == JFileChooser.APPROVE_OPTION) { + this.processAnimation(EditorFileChooser.instance().getSelectedFile()); + } + } + public void importSounds() { if (EditorFileChooser.showFileDialog(AUDIO_FILE_NAME, Resources.strings().get(IMPORT_DIALOGUE, AUDIO_FILE_NAME), true, SoundFormat.getAllExtensions()) == JFileChooser.APPROVE_OPTION) { this.importSounds(EditorFileChooser.instance().getSelectedFiles()); @@ -488,6 +497,30 @@ private void processSpritesheets(SpritesheetImportPanel spritePanel) { this.loadSpriteSheets(sprites, true); } + /** + * Loads an animation (spritesheet with keyframes) in to the editor + * @param file - a json file, encoded by the asesprite export standard + */ + public void processAnimation(File file) { + try { + Animation animation = AsepriteHandler.importAnimation(file.getAbsolutePath()); + int[] keyFrames = animation.getKeyFrameDurations(); + SpritesheetResource spritesheetResource = new SpritesheetResource(animation.getSpritesheet()); + spritesheetResource.setKeyframes(keyFrames); + Collection sprites = new ArrayList<>(Collections.singleton(spritesheetResource)); + for (SpritesheetResource info : sprites) { + Resources.spritesheets().getAll().removeIf(x -> x.getName().equals(info.getName() + "-preview")); + this.getGameFile().getSpriteSheets().removeIf(x -> x.getName().equals(info.getName())); + this.getGameFile().getSpriteSheets().add(info); + log.log(Level.INFO, "imported spritesheet {0}", new Object[]{info.getName()}); + } + this.loadSpriteSheets(sprites, true); + + } catch (IOException e) { + log.log(Level.SEVERE, e.getMessage(), e); + } + } + public void importEmitters() { XmlImportDialog.importXml("Emitter", file -> { EmitterData emitter; @@ -793,4 +826,4 @@ private void gamefileLoaded() { callback.run(); } } -} \ No newline at end of file +} diff --git a/utiliti/src/de/gurkenlabs/utiliti/swing/AssetPanelItem.java b/utiliti/src/de/gurkenlabs/utiliti/swing/AssetPanelItem.java index 240fd1b9c..92c1dcc4c 100644 --- a/utiliti/src/de/gurkenlabs/utiliti/swing/AssetPanelItem.java +++ b/utiliti/src/de/gurkenlabs/utiliti/swing/AssetPanelItem.java @@ -11,6 +11,8 @@ import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.io.File; +import java.io.FileWriter; +import java.io.Writer; import java.io.FileOutputStream; import java.io.IOException; import java.util.Collection; @@ -48,7 +50,9 @@ import de.gurkenlabs.litiengine.environment.tilemap.xml.MapObject; import de.gurkenlabs.litiengine.environment.tilemap.xml.Tileset; import de.gurkenlabs.litiengine.graphics.Spritesheet; +import de.gurkenlabs.litiengine.graphics.animation.AsepriteHandler; import de.gurkenlabs.litiengine.graphics.emitters.xml.EmitterData; +import de.gurkenlabs.litiengine.graphics.animation.AsepriteHandler; import de.gurkenlabs.litiengine.resources.ImageFormat; import de.gurkenlabs.litiengine.resources.Resources; import de.gurkenlabs.litiengine.resources.SoundFormat; @@ -431,7 +435,7 @@ private void exportSpritesheet() { ImageFormat format = sprite.getImageFormat() != ImageFormat.UNSUPPORTED ? sprite.getImageFormat() : ImageFormat.PNG; - Object[] options = { ".xml", format.toFileExtension() }; + Object[] options = { ".xml", format.toFileExtension(), ".json"}; int answer = JOptionPane.showOptionDialog(Game.window().getRenderComponent(), "Select an export format:", "Export Spritesheet", JOptionPane.DEFAULT_OPTION, JOptionPane.PLAIN_MESSAGE, null, options, options[0]); try { @@ -441,18 +445,43 @@ private void exportSpritesheet() { chooser.setFileSelectionMode(JFileChooser.FILES_ONLY); chooser.setDialogType(JFileChooser.SAVE_DIALOG); chooser.setDialogTitle("Export Spritesheet"); - if (answer == 0) { - XmlExportDialog.export(spriteSheetInfo, "Spritesheet", spriteSheetInfo.getName()); - } else if (answer == 1) { - FileFilter filter = new FileNameExtensionFilter(format.toString() + " - Image", format.toString()); - chooser.setFileFilter(filter); - chooser.addChoosableFileFilter(filter); - chooser.setSelectedFile(new File(spriteSheetInfo.getName() + format.toFileExtension())); - - int result = chooser.showSaveDialog(Game.window().getRenderComponent()); - if (result == JFileChooser.APPROVE_OPTION) { - ImageSerializer.saveImage(chooser.getSelectedFile().toString(), sprite.getImage(), format); - log.log(Level.INFO, "exported spritesheet {0} to {1}", new Object[] { spriteSheetInfo.getName(), chooser.getSelectedFile() }); + switch (answer) { + case 0: { + XmlExportDialog.export(spriteSheetInfo, "Spritesheet", spriteSheetInfo.getName()); + break; + } + case 1: { + FileFilter filter = new FileNameExtensionFilter(format.toString() + " - Image", format.toString()); + chooser.setFileFilter(filter); + chooser.addChoosableFileFilter(filter); + chooser.setSelectedFile(new File(spriteSheetInfo.getName() + format.toFileExtension())); + + int result = chooser.showSaveDialog(Game.window().getRenderComponent()); + if (result == JFileChooser.APPROVE_OPTION) { + ImageSerializer.saveImage(chooser.getSelectedFile().toString(), sprite.getImage(), format); + log.log(Level.INFO, "exported spritesheet {0} to {1}", new Object[]{spriteSheetInfo.getName(), chooser.getSelectedFile()}); + } + break; + } + case 2: { + FileFilter filter = new FileNameExtensionFilter(".json" + " - " + "Spritesheet" + " JSON", "json"); + chooser.setFileFilter(filter); + chooser.addChoosableFileFilter(filter); + chooser.setSelectedFile(new File(spriteSheetInfo.getName() + "." + "json")); + + int result = chooser.showSaveDialog(Game.window().getRenderComponent()); + if (result == JFileChooser.APPROVE_OPTION) { + String fileNameWithExtension = chooser.getSelectedFile().toString(); + if (!fileNameWithExtension.endsWith(".json")) { + fileNameWithExtension += ".json"; + } + String json = AsepriteHandler.exportAnimation(spriteSheetInfo); + try (Writer writer = new FileWriter(fileNameWithExtension)) { + writer.write(json); + log.log(Level.INFO, "Exported {0} {1} to {2}", new Object[]{"Spritesheet", spriteSheetInfo.getName(), fileNameWithExtension}); + } + } + break; } } } catch (IOException e) { diff --git a/utiliti/src/de/gurkenlabs/utiliti/swing/menus/ResourcesMenu.java b/utiliti/src/de/gurkenlabs/utiliti/swing/menus/ResourcesMenu.java index 3107eb7fe..adfa1173a 100644 --- a/utiliti/src/de/gurkenlabs/utiliti/swing/menus/ResourcesMenu.java +++ b/utiliti/src/de/gurkenlabs/utiliti/swing/menus/ResourcesMenu.java @@ -54,6 +54,10 @@ public ResourcesMenu() { exportSpriteSheets.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_E, InputEvent.CTRL_DOWN_MASK)); exportSpriteSheets.addActionListener(a -> Editor.instance().exportSpriteSheets()); + JMenuItem importAnimation = new JMenuItem(Resources.strings().get("menu_assets_importAnimation")); + importAnimation.addActionListener(a -> Editor.instance().importAnimation()); + importAnimation.setEnabled(false); + Editor.instance().onLoaded(() -> { importSpriteFile.setEnabled(Editor.instance().getCurrentResourceFile() != null); importSprite.setEnabled(Editor.instance().getCurrentResourceFile() != null); @@ -63,6 +67,7 @@ public ResourcesMenu() { importTilesets.setEnabled(Editor.instance().getCurrentResourceFile() != null); importSounds.setEnabled(Editor.instance().getCurrentResourceFile() != null); exportSpriteSheets.setEnabled(Editor.instance().getCurrentResourceFile() != null); + importAnimation.setEnabled(Editor.instance().getCurrentResourceFile() != null); }); this.add(importSprite); @@ -72,6 +77,7 @@ public ResourcesMenu() { this.add(importBlueprints); this.add(importTilesets); this.add(importSounds); + this.add(importAnimation); this.addSeparator(); this.add(exportSpriteSheets); this.add(compress);