From 2f7b9f6b10d8a498ec04f30027949ec2207a0766 Mon Sep 17 00:00:00 2001 From: Rainnny7 Date: Thu, 11 Apr 2024 03:04:50 -0400 Subject: [PATCH] Add base for 3D skin heads!!! --- pom.xml | 14 +++ .../java/me/braydon/mc/common/ImageUtils.java | 62 +++-------- .../mc/common/renderer/SkinPartRenderer.java | 93 ++++++++++++++++ .../renderer/impl/BasicSkinPartRenderer.java | 54 ++++++++++ .../impl/IsometricSkinPartRenderer.java | 101 ++++++++++++++++++ src/main/java/me/braydon/mc/model/Skin.java | 91 ++++++++++++++-- .../me/braydon/mc/service/MojangService.java | 47 ++++++-- 7 files changed, 391 insertions(+), 71 deletions(-) create mode 100644 src/main/java/me/braydon/mc/common/renderer/SkinPartRenderer.java create mode 100644 src/main/java/me/braydon/mc/common/renderer/impl/BasicSkinPartRenderer.java create mode 100644 src/main/java/me/braydon/mc/common/renderer/impl/IsometricSkinPartRenderer.java diff --git a/pom.xml b/pom.xml index 762a83e..a6ac3b4 100644 --- a/pom.xml +++ b/pom.xml @@ -116,6 +116,20 @@ compile + + + java3d + j3d-core + 1.3.1 + compile + + + java3d + j3d-core-utils + 1.3.1 + compile + + org.springdoc diff --git a/src/main/java/me/braydon/mc/common/ImageUtils.java b/src/main/java/me/braydon/mc/common/ImageUtils.java index 5d98dc7..0323274 100644 --- a/src/main/java/me/braydon/mc/common/ImageUtils.java +++ b/src/main/java/me/braydon/mc/common/ImageUtils.java @@ -24,68 +24,30 @@ package me.braydon.mc.common; import lombok.NonNull; -import lombok.SneakyThrows; import lombok.experimental.UtilityClass; -import me.braydon.mc.model.Skin; -import javax.imageio.ImageIO; +import java.awt.*; import java.awt.geom.AffineTransform; -import java.awt.image.AffineTransformOp; import java.awt.image.BufferedImage; -import java.io.ByteArrayOutputStream; -import java.net.URL; /** * @author Braydon */ @UtilityClass public final class ImageUtils { - public static final int SKIN_TEXTURE_SIZE = 64; // The skin of a skin texture - /** - * Get the texture of a part of a skin. + * Scale the given image to the provided size. * - * @param skin the skin to get the part texture from - * @param part the part of the skin to get - * @param size the size to scale the texture to - * @return the texture of the skin part + * @param image the image to scale + * @param size the size to scale the image to + * @return the scaled image */ - @SneakyThrows - public static byte[] getSkinPart(@NonNull Skin skin, @NonNull Skin.Part part, int size) { - return getSkinPartTexture(skin, part.getX(), part.getY(), part.getWidth(), part.getHeight(), size); - } - - /** - * Get the texture of a specific part of a skin. - * - * @param skin the skin to get the part from - * @param x the x position of the part - * @param y the y position of the part - * @param width the width of the part - * @param height the height of the part - * @param size the size to scale the part to - * @return the texture of the skin part - */ - @SneakyThrows - public static byte[] getSkinPartTexture(@NonNull Skin skin, int x, int y, int width, int height, int size) { - BufferedImage skinImage = ImageIO.read(new URL(skin.getUrl())); // The skin texture - - // Create a new BufferedImage for the part of the skin texture - BufferedImage headTexture = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); - - // Crop just the part we want based on our x, y, width, and height - headTexture.getGraphics().drawImage(skinImage, 0, 0, width, height, x, y, x + width, y + height, null); - - // Scale the skin part texture - double scale = (double) size / width; - AffineTransform transform = AffineTransform.getScaleInstance(scale, scale); - headTexture = new AffineTransformOp(transform, AffineTransformOp.TYPE_NEAREST_NEIGHBOR).filter(headTexture, null); - - // Convert BufferedImage to byte array - try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { - ImageIO.write(headTexture, "png", outputStream); - outputStream.flush(); - return outputStream.toByteArray(); - } + @NonNull + public static BufferedImage resize(@NonNull BufferedImage image, double size) { + BufferedImage scaled = new BufferedImage((int) (image.getWidth() * size), (int) (image.getHeight() * size), BufferedImage.TYPE_INT_ARGB); + Graphics2D graphics = scaled.createGraphics(); + graphics.drawImage(image, AffineTransform.getScaleInstance(size, size), null); + graphics.dispose(); + return scaled; } } \ No newline at end of file diff --git a/src/main/java/me/braydon/mc/common/renderer/SkinPartRenderer.java b/src/main/java/me/braydon/mc/common/renderer/SkinPartRenderer.java new file mode 100644 index 0000000..d0eea81 --- /dev/null +++ b/src/main/java/me/braydon/mc/common/renderer/SkinPartRenderer.java @@ -0,0 +1,93 @@ +/* + * MIT License + * + * Copyright (c) 2024 Braydon (Rainnny). + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package me.braydon.mc.common.renderer; + +import lombok.NonNull; +import lombok.SneakyThrows; +import me.braydon.mc.common.ImageUtils; +import me.braydon.mc.model.Skin; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.net.URL; + +/** + * A renderer for a {@link Skin.Part}. + * + * @author Braydon + * @param the type of part to render + */ +public abstract class SkinPartRenderer { + /** + * Invoke this render to render the + * given skin part for the provided skin. + * + * @param skin the skin to render the part for + * @param part the part to render + * @param overlays whether to render overlays + * @param size the size to scale the skin part to + * @return the rendered skin part + */ + @NonNull public abstract BufferedImage render(@NonNull Skin skin, @NonNull T part, boolean overlays, int size); + + /** + * Get the texture of a part of a skin. + * + * @param skin the skin to get the part texture from + * @param part the part of the skin to get + * @param size the size to scale the texture to + * @return the texture of the skin part + */ + @SneakyThrows + protected BufferedImage getSkinPart(@NonNull Skin skin, @NonNull Skin.Part part, double size) { + return getSkinPartTexture(skin, part.getCoordinates().getX(), part.getCoordinates().getY(), part.getWidth(), part.getHeight(), size); + } + + /** + * Get the texture of a specific part of a skin. + * + * @param skin the skin to get the part from + * @param x the x position of the part + * @param y the y position of the part + * @param width the width of the part + * @param height the height of the part + * @param size the size to scale the part to + * @return the texture of the skin part + */ + @SneakyThrows + private BufferedImage getSkinPartTexture(@NonNull Skin skin, int x, int y, int width, int height, double size) { + BufferedImage skinImage = ImageIO.read(new URL(skin.getUrl())); // The skin texture + + // Create a new BufferedImage for the part of the skin texture + BufferedImage headTexture = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); + + // Crop just the part we want based on our x, y, width, and height + headTexture.getGraphics().drawImage(skinImage, 0, 0, width, height, x, y, x + width, y + height, null); + + // Scale the skin part texture + headTexture = ImageUtils.resize(headTexture, size); + + return headTexture; + } +} \ No newline at end of file diff --git a/src/main/java/me/braydon/mc/common/renderer/impl/BasicSkinPartRenderer.java b/src/main/java/me/braydon/mc/common/renderer/impl/BasicSkinPartRenderer.java new file mode 100644 index 0000000..bc38e09 --- /dev/null +++ b/src/main/java/me/braydon/mc/common/renderer/impl/BasicSkinPartRenderer.java @@ -0,0 +1,54 @@ +/* + * MIT License + * + * Copyright (c) 2024 Braydon (Rainnny). + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package me.braydon.mc.common.renderer.impl; + +import lombok.NonNull; +import me.braydon.mc.common.renderer.SkinPartRenderer; +import me.braydon.mc.model.Skin; + +import java.awt.image.BufferedImage; + +/** + * A basic 2D renderer for a {@link Skin.Part}. + * + * @author Braydon + */ +public final class BasicSkinPartRenderer extends SkinPartRenderer { + public static final BasicSkinPartRenderer INSTANCE = new BasicSkinPartRenderer(); + + /** + * Invoke this render to render the + * given skin part for the provided skin. + * + * @param skin the skin to render the part for + * @param part the part to render + * @param overlays whether to render overlays + * @param size the size to scale the skin part to + * @return the rendered skin part + */ + @Override @NonNull + public BufferedImage render(@NonNull Skin skin, @NonNull Skin.Part part, boolean overlays, int size) { + return getSkinPart(skin, part, size / 8D); + } +} \ No newline at end of file diff --git a/src/main/java/me/braydon/mc/common/renderer/impl/IsometricSkinPartRenderer.java b/src/main/java/me/braydon/mc/common/renderer/impl/IsometricSkinPartRenderer.java new file mode 100644 index 0000000..eb206a1 --- /dev/null +++ b/src/main/java/me/braydon/mc/common/renderer/impl/IsometricSkinPartRenderer.java @@ -0,0 +1,101 @@ +/* + * MIT License + * + * Copyright (c) 2024 Braydon (Rainnny). + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package me.braydon.mc.common.renderer.impl; + +import lombok.NonNull; +import me.braydon.mc.common.renderer.SkinPartRenderer; +import me.braydon.mc.model.Skin; + +import java.awt.*; +import java.awt.geom.AffineTransform; +import java.awt.image.BufferedImage; + +/** + * A isometric 3D renderer for a {@link Skin.Part}. + * + * @author Braydon + */ +public final class IsometricSkinPartRenderer extends SkinPartRenderer { + public static final IsometricSkinPartRenderer INSTANCE = new IsometricSkinPartRenderer(); + + private static final double SKEW_A = 26D / 45D; // 0.57777777 + private static final double SKEW_B = SKEW_A * 2D; // 1.15555555 + + private static final AffineTransform HEAD_TOP_TRANSFORM = new AffineTransform(1D, -SKEW_A, 1, SKEW_A, 0, 0); + private static final AffineTransform FACE_TRANSFORM = new AffineTransform(1D, -SKEW_A, 0D, SKEW_B, 0d, SKEW_A); + private static final AffineTransform HEAD_LEFT_TRANSFORM = new AffineTransform(1D, SKEW_A, 0D, SKEW_B, 0D, 0D); + + /** + * Invoke this render to render the + * given skin part for the provided skin. + * + * @param skin the skin to render the part for + * @param part the part to render + * @param overlays whether to render overlays + * @param size the size to scale the skin part to + * @return the rendered skin part + */ + @Override @NonNull + public BufferedImage render(@NonNull Skin skin, @NonNull Skin.IsometricPart part, boolean overlays, int size) { + double scale = (size / 8D) / 2.5; + double zOffset = scale * 3.5D; + double xOffset = scale * 2D; + + BufferedImage texture = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB); + Graphics2D graphics = texture.createGraphics(); + + BufferedImage headTop = getSkinPart(skin, Skin.Part.HEAD_TOP, scale); + BufferedImage face = getSkinPart(skin, Skin.Part.FACE, scale); + BufferedImage headLeft = getSkinPart(skin, Skin.Part.HEAD_LEFT, scale); + + // Draw the top of the left + drawPart(graphics, headTop, HEAD_TOP_TRANSFORM, -0.5 - zOffset, xOffset + zOffset, headTop.getWidth(), headTop.getHeight() + 2); + + // Draw the face of the head + double x = xOffset + 8 * scale; + drawPart(graphics, face, FACE_TRANSFORM, x, x + zOffset - 0.5, face.getWidth(), face.getHeight()); + + // Draw the left side of the head + drawPart(graphics, headLeft, HEAD_LEFT_TRANSFORM, xOffset + 1, zOffset - 0.5, headLeft.getWidth(), headLeft.getHeight()); + + return texture; + } + + /** + * Draw a part onto the texture. + * + * @param graphics the graphics to draw to + * @param partImage the part image to draw + * @param transform the transform to apply + * @param x the x position to draw at + * @param y the y position to draw at + * @param width the part image width + * @param height the part image height + */ + private void drawPart(@NonNull Graphics2D graphics, @NonNull BufferedImage partImage, @NonNull AffineTransform transform, + double x, double y, int width, int height) { + graphics.setTransform(transform); + graphics.drawImage(partImage, (int) x, (int) y, width, height, null); + } +} \ No newline at end of file diff --git a/src/main/java/me/braydon/mc/model/Skin.java b/src/main/java/me/braydon/mc/model/Skin.java index 9a857a1..85b5b0e 100644 --- a/src/main/java/me/braydon/mc/model/Skin.java +++ b/src/main/java/me/braydon/mc/model/Skin.java @@ -26,11 +26,11 @@ package me.braydon.mc.model; import com.fasterxml.jackson.annotation.JsonProperty; import com.google.gson.JsonObject; import lombok.*; -import me.braydon.mc.common.ImageUtils; import me.braydon.mc.config.AppConfig; import java.util.HashMap; import java.util.Map; +import java.util.function.Consumer; /** * A skin for a {@link Player}. @@ -64,8 +64,14 @@ public final class Skin { */ @NonNull public Skin populatePartUrls(@NonNull String playerUuid) { - for (Part part : Part.values()) { + Consumer addPart = part -> { partUrls.put(part.name(), AppConfig.INSTANCE.getServerPublicUrl() + "/player/" + part.name().toLowerCase() + "/" + playerUuid + ".png"); + }; + for (Part part : Part.values()) { + addPart.accept(part); + } + for (IsometricPart part : IsometricPart.values()) { + addPart.accept(part); } return this; } @@ -93,28 +99,91 @@ public final class Skin { * Possible models for a skin. */ public enum Model { - SLIM, DEFAULT + DEFAULT, SLIM + } + + /** + * Represents a part of a skin. + */ + public interface IPart { + /** + * Get the name of this part. + * + * @return the part name + */ + @NonNull String name(); } /** * The part of a skin. */ - @AllArgsConstructor @Getter @ToString - public enum Part { - HEAD_TOP(8, 0, ImageUtils.SKIN_TEXTURE_SIZE / 8, ImageUtils.SKIN_TEXTURE_SIZE / 8), - FACE(8, 8, ImageUtils.SKIN_TEXTURE_SIZE / 8, ImageUtils.SKIN_TEXTURE_SIZE / 8), - HEAD_LEFT(0, 8, ImageUtils.SKIN_TEXTURE_SIZE / 8, ImageUtils.SKIN_TEXTURE_SIZE / 8), - HEAD_RIGHT(16, 8, ImageUtils.SKIN_TEXTURE_SIZE / 8, ImageUtils.SKIN_TEXTURE_SIZE / 8), - HEAD_BOTTOM(8, 8, ImageUtils.SKIN_TEXTURE_SIZE / 8, ImageUtils.SKIN_TEXTURE_SIZE / 8); + @AllArgsConstructor @RequiredArgsConstructor @Getter @ToString + public enum Part implements IPart { + // Head + HEAD_TOP(new Coordinates(8, 0), 8, 8), + FACE(new Coordinates(8, 8), 8, 8), + HEAD_LEFT(new Coordinates(0, 8), 8, 8), + HEAD_RIGHT(new Coordinates(16, 8), 8, 8), + HEAD_BOTTOM(new Coordinates(16, 0), 8, 8), + HEAD_BACK(new Coordinates(24, 8), 8, 8); /** * The coordinates of this part. */ - private final int x, y; + @NonNull private final Coordinates coordinates; + + /** + * The legacy coordinates of this part. + *

+ * This is for older skin textures + * that use different positions. + *

+ */ + private LegacyCoordinates legacyCoordinates; /** * The size of this part. */ private final int width, height; + + /** + * Coordinates of a part of a skin. + */ + @AllArgsConstructor @Getter @ToString + public static class Coordinates { + /** + * The X coordinate. + */ + private final int x; + + /** + * The Y coordinate. + */ + private final int y; + } + + /** + * Legacy coordinates of a part of a skin. + */ + @Getter @ToString + public static class LegacyCoordinates extends Coordinates { + /** + * Whether the part at these coordinates is flipped. + */ + private final boolean flipped; + + public LegacyCoordinates(int x, int y) { + this(x, y, false); + } + + public LegacyCoordinates(int x, int y, boolean flipped) { + super(x, y); + this.flipped = flipped; + } + } + } + + public enum IsometricPart implements IPart { + HEAD } } \ No newline at end of file diff --git a/src/main/java/me/braydon/mc/service/MojangService.java b/src/main/java/me/braydon/mc/service/MojangService.java index 0b7b84c..cf8a225 100644 --- a/src/main/java/me/braydon/mc/service/MojangService.java +++ b/src/main/java/me/braydon/mc/service/MojangService.java @@ -32,6 +32,8 @@ import lombok.NonNull; import lombok.SneakyThrows; import lombok.extern.log4j.Log4j2; import me.braydon.mc.common.*; +import me.braydon.mc.common.renderer.impl.BasicSkinPartRenderer; +import me.braydon.mc.common.renderer.impl.IsometricSkinPartRenderer; import me.braydon.mc.common.web.JsonWebException; import me.braydon.mc.common.web.JsonWebRequest; import me.braydon.mc.exception.impl.BadRequestException; @@ -60,6 +62,9 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpMethod; import org.springframework.stereotype.Service; +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; import java.io.InputStream; import java.net.InetSocketAddress; import java.net.URL; @@ -163,8 +168,26 @@ public final class MojangService { * @throws BadRequestException if the extension is invalid * @throws MojangRateLimitException if the Mojang API rate limit is reached */ + @SneakyThrows public byte[] getSkinPartTexture(@NonNull String partName, @NonNull String query, @NonNull String extension, String sizeString) throws BadRequestException, MojangRateLimitException { + partName = partName.toUpperCase(); // The part name to get + + // Get the part from the given name + Skin.IPart part = EnumUtils.getEnumConstant(Skin.Part.class, partName); // The skin part to get + if (part == null) { // The given part is invalid, try a isometric part + part = EnumUtils.getEnumConstant(Skin.IsometricPart.class, partName);; + } + if (part == null) { // Default to the face + part = Skin.Part.FACE; + } + + // Ensure the extension is valid + if (extension.isBlank()) { + throw new BadRequestException("Invalid extension"); + } + + // Get the size of the part Integer size = null; if (sizeString != null) { // Attempt to parse the size try { @@ -173,13 +196,6 @@ public final class MojangService { // Safely ignore, invalid number provided } } - Skin.Part part = EnumUtils.getEnumConstant(Skin.Part.class, partName.toUpperCase()); // The skin part to get - if (part == null) { // Default to the head part - part = Skin.Part.FACE; - } - if (extension.isBlank()) { // Invalid extension - throw new BadRequestException("Invalid extension"); - } if (size == null || size <= 0) { // Invalid size size = DEFAULT_PART_TEXTURE_SIZE; } @@ -201,9 +217,19 @@ public final class MojangService { if (target == null) { // Fallback to the default skin target = Skin.DEFAULT_STEVE; } - byte[] texture = ImageUtils.getSkinPart(target, part, size); - skinPartTextureCache.save(new CachedSkinPartTexture(id, texture)); // Cache the texture - return texture; + BufferedImage texture = part instanceof Skin.IsometricPart isometricPart ? + IsometricSkinPartRenderer.INSTANCE.render(target, isometricPart, true, size) + : BasicSkinPartRenderer.INSTANCE.render(target, (Skin.Part) part, true, size); // Render the skin part + + // Convert BufferedImage to byte array + try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { + ImageIO.write(texture, "png", outputStream); + outputStream.flush(); + + byte[] bytes = outputStream.toByteArray(); + skinPartTextureCache.save(new CachedSkinPartTexture(id, bytes)); // Cache the texture + return bytes; + } } /** @@ -268,6 +294,7 @@ public final class MojangService { uuid, token.getName(), skinProperties.getSkin() == null ? Skin.DEFAULT_STEVE : skinProperties.getSkin(), skinProperties.getCape(), + token.getProperties(), profileActions.length == 0 ? null : profileActions, System.currentTimeMillis() );