diff --git a/src/de/gurkenlabs/litiengine/graphics/animation/AsepriteHandler.java b/src/de/gurkenlabs/litiengine/graphics/animation/AsepriteHandler.java index 452b032c9..23d6d60d7 100644 --- a/src/de/gurkenlabs/litiengine/graphics/animation/AsepriteHandler.java +++ b/src/de/gurkenlabs/litiengine/graphics/animation/AsepriteHandler.java @@ -1,21 +1,181 @@ package de.gurkenlabs.litiengine.graphics.animation; +import java.io.File; +import java.io.FileReader; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.awt.Dimension; import java.awt.Image; +import java.awt.image.BufferedImage; +import java.util.Set; +import java.util.Map; +import java.util.HashMap; +import java.util.List; +import javax.imageio.ImageIO; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.annotations.SerializedName; +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; +import com.google.gson.JsonObject; -import java.util.HashMap; -import java.util.Map; -import java.util.List; - +import de.gurkenlabs.litiengine.graphics.Spritesheet; import de.gurkenlabs.litiengine.graphics.animation.Animation; import de.gurkenlabs.litiengine.graphics.animation.KeyFrame; -import de.gurkenlabs.litiengine.graphics.Spritesheet; +/** + * 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 Error { + public ImportAnimationException(String message) { + super(message); + } + } + + /** + * Imports an Aseprite animation (.json + sprite sheet). + * Note: searches for sprite sheet path through .json metadata, specifically 'image' element. This should be an absolute path in system. + * + * @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); + } + + String spriteSheetPath = getSpriteSheetPath(rootElement); + File spriteSheetFile = new File(spriteSheetPath); + if(!spriteSheetFile.exists()) { + throw new FileNotFoundException("FileNotFoundException: Could not find sprite sheet file. " + + "Expected location is 'image' in .json metadata, which evaluates to: " + spriteSheetPath); + } + + 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, + spriteSheetPath, + (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; } + } + + /** + * @param rootElement root element of JSON data. + * + * @return path (including filename) to animation sprite sheet. + * */ + private static String getSpriteSheetPath(JsonElement rootElement) { + + JsonElement metaData = rootElement.getAsJsonObject().get("meta"); + String spriteSheetPath = metaData.getAsJsonObject().get("image").getAsString(); + + return spriteSheetPath; + } + + /** + * @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 */ @@ -207,6 +367,4 @@ public Layer(String name, int opacity, String blendMode){ } } -} - - +} \ No newline at end of file diff --git a/tests/de/gurkenlabs/litiengine/graphics/animation/AsepriteHandlerTests.java b/tests/de/gurkenlabs/litiengine/graphics/animation/AsepriteHandlerTests.java index 9694d28b9..79336380c 100644 --- a/tests/de/gurkenlabs/litiengine/graphics/animation/AsepriteHandlerTests.java +++ b/tests/de/gurkenlabs/litiengine/graphics/animation/AsepriteHandlerTests.java @@ -1,25 +1,86 @@ 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.Animation; +import de.gurkenlabs.litiengine.graphics.animation.AsepriteHandler.ImportAnimationException; +import de.gurkenlabs.litiengine.resources.ImageFormat; 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"); + 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()); + } + catch(AsepriteHandler.ImportAnimationException 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, which evaluates to: tests/de/gurkenlabs/litiengine/graphics/animation/aseprite_test_animations/Sprite-0002-sheet.png", exception_withoutSpriteSheet.getMessage()); + } /** * Test that just create a json and prints in to standard output. */ @Test public void exportAnimationTest() { - String spritesheetPath = "C:/Users/Nikla/Documents/Programmering/SoftwareFundamentals/Assignment-3-EC/litiengine/tests/de/gurkenlabs/litiengine/graphics/animation/aseprite_test_animation/Sprite-0001-sheet.png"; + 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,2); @@ -27,6 +88,5 @@ public void exportAnimationTest() { AsepriteHandler aseprite = new AsepriteHandler(); String result = aseprite.exportAnimation(animation); System.out.println(result); - } -} \ No newline at end of file +} diff --git a/tests/de/gurkenlabs/litiengine/graphics/animation/aseprite_test_animation/Sprite-0001-sheet.png b/tests/de/gurkenlabs/litiengine/graphics/animation/aseprite_test_animations/Sprite-0001-sheet.png similarity index 100% rename from tests/de/gurkenlabs/litiengine/graphics/animation/aseprite_test_animation/Sprite-0001-sheet.png rename to tests/de/gurkenlabs/litiengine/graphics/animation/aseprite_test_animations/Sprite-0001-sheet.png 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