forked from MinecraftUtilities/Backend
make the skin renderer less bad (thanks bray)
This commit is contained in:
parent
83a95fb26c
commit
2ea58d8080
@ -1,41 +0,0 @@
|
|||||||
package cc.fascinated.common;
|
|
||||||
|
|
||||||
import jakarta.validation.constraints.NotNull;
|
|
||||||
import lombok.extern.log4j.Log4j2;
|
|
||||||
|
|
||||||
import java.awt.*;
|
|
||||||
import java.awt.geom.AffineTransform;
|
|
||||||
import java.awt.image.BufferedImage;
|
|
||||||
|
|
||||||
@Log4j2
|
|
||||||
public class ImageUtils {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resize an image.
|
|
||||||
*
|
|
||||||
* @param src the source image
|
|
||||||
* @param scale the scale factor
|
|
||||||
* @return the scaled image
|
|
||||||
*/
|
|
||||||
public static BufferedImage resize(@NotNull final BufferedImage src, final double scale) {
|
|
||||||
BufferedImage scaled = new BufferedImage((int) (src.getWidth() * scale), (int) (src.getHeight() * scale), BufferedImage.TYPE_INT_ARGB);
|
|
||||||
Graphics2D graphics = scaled.createGraphics();
|
|
||||||
graphics.drawImage(src, AffineTransform.getScaleInstance(scale, scale), null);
|
|
||||||
graphics.dispose();
|
|
||||||
return scaled;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Flip an image.
|
|
||||||
*
|
|
||||||
* @param src the source image
|
|
||||||
* @return the flipped image
|
|
||||||
*/
|
|
||||||
public static BufferedImage flip(@NotNull final BufferedImage src) {
|
|
||||||
BufferedImage flipped = new BufferedImage(src.getWidth(), src.getHeight(), BufferedImage.TYPE_INT_ARGB);
|
|
||||||
Graphics2D graphics = flipped.createGraphics();
|
|
||||||
graphics.drawImage(src, src.getWidth(), 0, 0, src.getHeight(), 0, 0, src.getWidth(), src.getHeight(), null);
|
|
||||||
graphics.dispose();
|
|
||||||
return flipped;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,233 +0,0 @@
|
|||||||
package cc.fascinated.model.player;
|
|
||||||
|
|
||||||
import cc.fascinated.common.PlayerUtils;
|
|
||||||
import cc.fascinated.config.Config;
|
|
||||||
import cc.fascinated.exception.impl.BadRequestException;
|
|
||||||
import cc.fascinated.service.skin.SkinRenderer;
|
|
||||||
import cc.fascinated.service.skin.impl.BodyRenderer;
|
|
||||||
import cc.fascinated.service.skin.impl.HeadRenderer;
|
|
||||||
import cc.fascinated.service.skin.impl.IsometricHeadRenderer;
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
|
||||||
import com.google.gson.JsonObject;
|
|
||||||
import lombok.AllArgsConstructor;
|
|
||||||
import lombok.Getter;
|
|
||||||
import lombok.NoArgsConstructor;
|
|
||||||
import lombok.extern.log4j.Log4j2;
|
|
||||||
|
|
||||||
import javax.imageio.ImageIO;
|
|
||||||
import java.awt.image.BufferedImage;
|
|
||||||
import java.io.ByteArrayInputStream;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
@AllArgsConstructor @NoArgsConstructor
|
|
||||||
@Getter @Log4j2
|
|
||||||
public class Skin {
|
|
||||||
/**
|
|
||||||
* The default skin, usually used when the skin is not found.
|
|
||||||
*/
|
|
||||||
public static final Skin DEFAULT_SKIN = new Skin("http://textures.minecraft.net/texture/60a5bd016b3c9a1b9272e4929e30827a67be4ebb219017adbbc4a4d22ebd5b1",
|
|
||||||
Model.DEFAULT);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The URL for the skin
|
|
||||||
*/
|
|
||||||
private String url;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The model for the skin
|
|
||||||
*/
|
|
||||||
private Model model;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The legacy status of the skin
|
|
||||||
*/
|
|
||||||
private boolean isLegacy = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The skin image for the skin
|
|
||||||
*/
|
|
||||||
@JsonIgnore
|
|
||||||
private byte[] skinImage;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The part URLs of the skin
|
|
||||||
*/
|
|
||||||
@JsonProperty("parts")
|
|
||||||
private Map<String, String> partUrls = new HashMap<>();
|
|
||||||
|
|
||||||
public Skin(String url, Model model) {
|
|
||||||
this.url = url;
|
|
||||||
this.model = model;
|
|
||||||
|
|
||||||
this.skinImage = PlayerUtils.getSkinImage(url);
|
|
||||||
if (this.skinImage != null) {
|
|
||||||
try {
|
|
||||||
BufferedImage image = ImageIO.read(new ByteArrayInputStream(this.skinImage));
|
|
||||||
if (image.getWidth() == 64 && image.getHeight() == 32) { // Using the old skin format
|
|
||||||
this.isLegacy = true;
|
|
||||||
}
|
|
||||||
} catch (Exception ignored) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the skin from a {@link JsonObject}.
|
|
||||||
*
|
|
||||||
* @param json the JSON object
|
|
||||||
* @return the skin
|
|
||||||
*/
|
|
||||||
public static Skin fromJson(JsonObject json) {
|
|
||||||
if (json == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
String url = json.get("url").getAsString();
|
|
||||||
JsonObject metadata = json.getAsJsonObject("metadata");
|
|
||||||
Model model = Model.fromName(metadata == null ? "default" : // Fall back to slim if the model is not found
|
|
||||||
metadata.get("model").getAsString());
|
|
||||||
return new Skin(url, model);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Populates the part URLs for the skin.
|
|
||||||
*
|
|
||||||
* @param playerUuid the player's UUID
|
|
||||||
*/
|
|
||||||
public Skin populatePartUrls(String playerUuid) {
|
|
||||||
for (Parts part : Parts.values()) {
|
|
||||||
String partName = part.name().toLowerCase();
|
|
||||||
this.partUrls.put(partName, Config.INSTANCE.getWebPublicUrl() + "/player/" + partName + "/" + playerUuid);
|
|
||||||
}
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The skin part enum that contains the
|
|
||||||
* information about the part.
|
|
||||||
*/
|
|
||||||
@Getter @AllArgsConstructor
|
|
||||||
public enum Parts {
|
|
||||||
FACE(new HeadRenderer()),
|
|
||||||
HEAD(new IsometricHeadRenderer()),
|
|
||||||
BODY(new BodyRenderer());
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The skin part renderer for the part.
|
|
||||||
*/
|
|
||||||
private final SkinRenderer renderer;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the name of the part.
|
|
||||||
*
|
|
||||||
* @return the name of the part
|
|
||||||
*/
|
|
||||||
public String getName() {
|
|
||||||
return this.name().toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the skin part from its name.
|
|
||||||
*
|
|
||||||
* @param name the name of the part
|
|
||||||
* @return the skin part
|
|
||||||
* @throws BadRequestException if the part is not found
|
|
||||||
*/
|
|
||||||
public static Parts fromName(String name) throws BadRequestException {
|
|
||||||
for (Parts part : values()) {
|
|
||||||
if (part.name().equalsIgnoreCase(name)) {
|
|
||||||
return part;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new BadRequestException("Invalid part name: " + name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@AllArgsConstructor @Getter
|
|
||||||
public enum PartPosition {
|
|
||||||
/**
|
|
||||||
* Skin positions
|
|
||||||
*/
|
|
||||||
HEAD_FACE(8, 8, 8, 8, null),
|
|
||||||
HEAD_TOP(8, 0, 8, 8, null),
|
|
||||||
HEAD_LEFT(0, 8, 8, 8, null),
|
|
||||||
|
|
||||||
BODY_FRONT(20, 20, 8, 12, null),
|
|
||||||
BODY_BACK(20, 36, 8, 12, null),
|
|
||||||
BODY_LEFT(32, 52, 8, 12, null),
|
|
||||||
BODY_RIGHT(44, 20, 8, 12, null),
|
|
||||||
|
|
||||||
RIGHT_ARM(44, 20, 4, 12, null),
|
|
||||||
LEFT_ARM(36, 52, 4, 12, new LegacySkinPosition(44, 20, true)),
|
|
||||||
|
|
||||||
RIGHT_LEG(4, 20, 4, 12, null),
|
|
||||||
LEFT_LEG(20, 52, 4, 12, new LegacySkinPosition(4, 20, true)),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Skin overlay (layer) positions
|
|
||||||
*/
|
|
||||||
HEAD_OVERLAY_FACE(40, 8, 8, 8, null),
|
|
||||||
HEAD_OVERLAY_TOP(40, 0, 8, 8, null),
|
|
||||||
HEAD_OVERLAY_LEFT(48, 8, 8, 8, null),
|
|
||||||
|
|
||||||
BODY_OVERLAY_FRONT(20, 36, 8, 12, null),
|
|
||||||
|
|
||||||
RIGHT_ARM_OVERLAY(44, 36, 8, 12, null),
|
|
||||||
LEFT_ARM_OVERLAY(52, 52, 8, 12, null),
|
|
||||||
|
|
||||||
RIGHT_LEG_OVERLAY(4, 36, 4, 12, null),
|
|
||||||
LEFT_LEG_OVERLAY(4, 52, 20, 12, null);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The x, and y position of the part.
|
|
||||||
*/
|
|
||||||
private final int x, y;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The width and height of the part.
|
|
||||||
*/
|
|
||||||
private final int width, height;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The legacy skin position of the part.
|
|
||||||
*/
|
|
||||||
private final LegacySkinPosition legacySkinPosition;
|
|
||||||
}
|
|
||||||
|
|
||||||
@AllArgsConstructor @Getter
|
|
||||||
public static class LegacySkinPosition {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The x, and y position of the part.
|
|
||||||
*/
|
|
||||||
private final int x, y;
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Should the part be flipped horizontally?
|
|
||||||
*/
|
|
||||||
private final boolean flipped;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The model of the skin.
|
|
||||||
*/
|
|
||||||
public enum Model {
|
|
||||||
DEFAULT,
|
|
||||||
SLIM;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the model from its name.
|
|
||||||
*
|
|
||||||
* @param name the name of the model
|
|
||||||
* @return the model
|
|
||||||
*/
|
|
||||||
public static Model fromName(String name) {
|
|
||||||
for (Model model : values()) {
|
|
||||||
if (model.name().equalsIgnoreCase(name)) {
|
|
||||||
return model;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,111 +0,0 @@
|
|||||||
package cc.fascinated.service.skin;
|
|
||||||
|
|
||||||
import cc.fascinated.common.ImageUtils;
|
|
||||||
import cc.fascinated.exception.impl.BadRequestException;
|
|
||||||
import cc.fascinated.model.player.Skin;
|
|
||||||
import lombok.AllArgsConstructor;
|
|
||||||
import lombok.Getter;
|
|
||||||
import lombok.extern.log4j.Log4j2;
|
|
||||||
|
|
||||||
import javax.imageio.ImageIO;
|
|
||||||
import java.awt.*;
|
|
||||||
import java.awt.image.BufferedImage;
|
|
||||||
import java.io.ByteArrayInputStream;
|
|
||||||
import java.io.ByteArrayOutputStream;
|
|
||||||
|
|
||||||
@AllArgsConstructor @Getter @Log4j2
|
|
||||||
public abstract class SkinRenderer {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the skin image.
|
|
||||||
*
|
|
||||||
* @param skin the skin
|
|
||||||
* @return the skin image
|
|
||||||
*/
|
|
||||||
public BufferedImage getSkinImage(Skin skin) {
|
|
||||||
try {
|
|
||||||
return ImageIO.read(new ByteArrayInputStream(skin.getSkinImage()));
|
|
||||||
} catch (Exception ex) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the skin part image.
|
|
||||||
*
|
|
||||||
* @param skin the skin
|
|
||||||
* @param position the part position information
|
|
||||||
* @param scale the scale
|
|
||||||
* @return the skin part image
|
|
||||||
*/
|
|
||||||
public BufferedImage getSkinPart(Skin skin, Skin.PartPosition position, double scale) {
|
|
||||||
try {
|
|
||||||
BufferedImage skinImage = this.getSkinImage(skin);
|
|
||||||
if (skinImage == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
int width = skin.getModel() == Skin.Model.SLIM && position.name().contains("ARM") ? position.getWidth() - 1 : position.getWidth();
|
|
||||||
BufferedImage part;
|
|
||||||
Skin.LegacySkinPosition legacySkinPosition = position.getLegacySkinPosition();
|
|
||||||
if (legacySkinPosition != null) {
|
|
||||||
part = skinImage.getSubimage(legacySkinPosition.getX(), legacySkinPosition.getY(), width, position.getHeight());
|
|
||||||
if (legacySkinPosition.isFlipped()) {
|
|
||||||
part = ImageUtils.flip(part);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
part = skinImage.getSubimage(position.getX(), position.getY(), width, position.getHeight());
|
|
||||||
}
|
|
||||||
return ImageUtils.resize(part, scale);
|
|
||||||
} catch (Exception ex) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the bytes of an image.
|
|
||||||
*
|
|
||||||
* @param image the image
|
|
||||||
* @param skin the skin
|
|
||||||
* @param partName the part name
|
|
||||||
* @return the bytes
|
|
||||||
*/
|
|
||||||
public byte[] getBytes(BufferedImage image, Skin skin, String partName) throws BadRequestException {
|
|
||||||
try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
|
|
||||||
ImageIO.write(image, "png", outputStream);
|
|
||||||
// Cleanup
|
|
||||||
outputStream.flush();
|
|
||||||
log.info("Successfully got {} part bytes for {}", partName, skin.getUrl());
|
|
||||||
return outputStream.toByteArray();
|
|
||||||
} catch (Exception ex) {
|
|
||||||
throw new BadRequestException("Failed to get " + partName + " part bytes for " + skin.getUrl());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Applies an overlay (skin layer) to the head part.
|
|
||||||
*
|
|
||||||
* @param originalImage the original image
|
|
||||||
* @param part the part
|
|
||||||
*/
|
|
||||||
public void applyOverlay(BufferedImage originalImage, BufferedImage part) {
|
|
||||||
if (part == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
Graphics2D graphics = originalImage.createGraphics();
|
|
||||||
graphics.drawImage(part, 0, 0, null);
|
|
||||||
graphics.dispose();
|
|
||||||
} catch (Exception ignored) {} // We can safely ignore this
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders a skin part.
|
|
||||||
*
|
|
||||||
* @param skin the skin
|
|
||||||
* @param partName the skin part name
|
|
||||||
* @param renderOverlay whether to render the overlay
|
|
||||||
* @param size the output size
|
|
||||||
* @return the skin part image
|
|
||||||
*/
|
|
||||||
public abstract byte[] renderPart(Skin skin, String partName, boolean renderOverlay, int size) throws BadRequestException;
|
|
||||||
}
|
|
@ -1,53 +0,0 @@
|
|||||||
package cc.fascinated.service.skin.impl;
|
|
||||||
|
|
||||||
import cc.fascinated.common.ImageUtils;
|
|
||||||
import cc.fascinated.model.player.Skin;
|
|
||||||
import cc.fascinated.service.skin.SkinRenderer;
|
|
||||||
import lombok.Getter;
|
|
||||||
import lombok.extern.log4j.Log4j2;
|
|
||||||
|
|
||||||
import java.awt.*;
|
|
||||||
import java.awt.image.BufferedImage;
|
|
||||||
|
|
||||||
@Getter @Log4j2
|
|
||||||
public class BodyRenderer extends SkinRenderer {
|
|
||||||
|
|
||||||
private static final int WIDTH = 16;
|
|
||||||
private static final int HEIGHT = 32;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public byte[] renderPart(Skin skin, String partName, boolean renderOverlay, int size) {
|
|
||||||
log.info("Getting {} part bytes for {} with size {}", partName, skin.getUrl(), size);
|
|
||||||
|
|
||||||
BufferedImage outputImage = new BufferedImage(WIDTH, HEIGHT, BufferedImage.TYPE_INT_ARGB);
|
|
||||||
Graphics2D graphics = outputImage.createGraphics();
|
|
||||||
|
|
||||||
// Get all the required body parts
|
|
||||||
BufferedImage face = this.getSkinPart(skin, Skin.PartPosition.HEAD_FACE, 1);
|
|
||||||
BufferedImage body = this.getSkinPart(skin, Skin.PartPosition.BODY_FRONT, 1);
|
|
||||||
BufferedImage rightArm = this.getSkinPart(skin, Skin.PartPosition.RIGHT_ARM, 1);
|
|
||||||
BufferedImage leftArm = this.getSkinPart(skin, Skin.PartPosition.LEFT_ARM, 1);
|
|
||||||
BufferedImage rightLeg = this.getSkinPart(skin, Skin.PartPosition.RIGHT_LEG, 1);
|
|
||||||
BufferedImage leftLeg = this.getSkinPart(skin, Skin.PartPosition.LEFT_LEG, 1);
|
|
||||||
|
|
||||||
if (renderOverlay) { // Render the skin layers
|
|
||||||
applyOverlay(face, this.getSkinPart(skin, Skin.PartPosition.HEAD_OVERLAY_FACE, 1));
|
|
||||||
applyOverlay(body, this.getSkinPart(skin, Skin.PartPosition.BODY_OVERLAY_FRONT, 1));
|
|
||||||
applyOverlay(rightArm, this.getSkinPart(skin, Skin.PartPosition.RIGHT_ARM_OVERLAY, 1));
|
|
||||||
applyOverlay(leftArm, this.getSkinPart(skin, Skin.PartPosition.LEFT_ARM_OVERLAY, 1));
|
|
||||||
applyOverlay(rightLeg, this.getSkinPart(skin, Skin.PartPosition.RIGHT_LEG_OVERLAY, 1));
|
|
||||||
applyOverlay(leftLeg, this.getSkinPart(skin, Skin.PartPosition.LEFT_LEG_OVERLAY, 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw the body
|
|
||||||
graphics.drawImage(face, 4, 0, null);
|
|
||||||
graphics.drawImage(body, 4, 8, null);
|
|
||||||
graphics.drawImage(rightArm, skin.getModel() == Skin.Model.SLIM ? 1 : 0, 8, null);
|
|
||||||
graphics.drawImage(leftArm, 12, 8, null);
|
|
||||||
graphics.drawImage(rightLeg, 4, 20, null);
|
|
||||||
graphics.drawImage(leftLeg, 8, 20, null);
|
|
||||||
|
|
||||||
graphics.dispose(); // Clean up
|
|
||||||
return super.getBytes(ImageUtils.resize(outputImage, (double) size / HEIGHT), skin, partName);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,34 +0,0 @@
|
|||||||
package cc.fascinated.service.skin.impl;
|
|
||||||
|
|
||||||
import cc.fascinated.model.player.Skin;
|
|
||||||
import cc.fascinated.service.skin.SkinRenderer;
|
|
||||||
import lombok.Getter;
|
|
||||||
import lombok.extern.log4j.Log4j2;
|
|
||||||
|
|
||||||
import java.awt.*;
|
|
||||||
import java.awt.geom.AffineTransform;
|
|
||||||
import java.awt.image.BufferedImage;
|
|
||||||
|
|
||||||
@Getter @Log4j2
|
|
||||||
public class HeadRenderer extends SkinRenderer {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public byte[] renderPart(Skin skin, String partName, boolean renderOverlay, int size) {
|
|
||||||
double scale = (double) size / 8d;
|
|
||||||
log.info("Getting {} part bytes for {} with size {} and scale {}", partName, skin.getUrl(), size, scale);
|
|
||||||
|
|
||||||
BufferedImage skinPart = this.getSkinPart(skin, Skin.PartPosition.HEAD_FACE, scale);
|
|
||||||
if (!renderOverlay) {
|
|
||||||
return super.getBytes(skinPart, skin, partName);
|
|
||||||
}
|
|
||||||
|
|
||||||
BufferedImage outputImage = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB);
|
|
||||||
Graphics2D graphics = outputImage.createGraphics();
|
|
||||||
graphics.drawImage(skinPart, 0, 0, null);
|
|
||||||
|
|
||||||
// Apply the skin overlays
|
|
||||||
applyOverlay(outputImage, this.getSkinPart(skin, Skin.PartPosition.HEAD_OVERLAY_FACE, scale));
|
|
||||||
|
|
||||||
return super.getBytes(outputImage, skin, partName);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,74 +0,0 @@
|
|||||||
package cc.fascinated.service.skin.impl;
|
|
||||||
|
|
||||||
import cc.fascinated.model.player.Skin;
|
|
||||||
import cc.fascinated.service.skin.SkinRenderer;
|
|
||||||
import lombok.Getter;
|
|
||||||
import lombok.extern.log4j.Log4j2;
|
|
||||||
|
|
||||||
import java.awt.*;
|
|
||||||
import java.awt.geom.AffineTransform;
|
|
||||||
import java.awt.image.BufferedImage;
|
|
||||||
|
|
||||||
@Getter @Log4j2
|
|
||||||
public class IsometricHeadRenderer extends SkinRenderer {
|
|
||||||
|
|
||||||
private static final double SKEW_A = 26d / 45d; // 0.57777777
|
|
||||||
private static final double SKEW_B = SKEW_A * 2d; // 1.15555555
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The head transforms
|
|
||||||
*/
|
|
||||||
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);
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public byte[] renderPart(Skin skin, String partName, boolean renderOverlay, int size) {
|
|
||||||
double scale = (size / 8d) / 2.5;
|
|
||||||
log.info("Getting {} part bytes for {} with size {} and scale {}", partName, skin.getUrl(), size, scale);
|
|
||||||
|
|
||||||
double zOffset = scale * 3.5d;
|
|
||||||
double xOffset = scale * 2d;
|
|
||||||
BufferedImage outputImage = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB);
|
|
||||||
Graphics2D graphics = outputImage.createGraphics();
|
|
||||||
|
|
||||||
// Get all the required head parts
|
|
||||||
BufferedImage headTop = this.getSkinPart(skin, Skin.PartPosition.HEAD_TOP, scale);
|
|
||||||
BufferedImage headFront = this.getSkinPart(skin, Skin.PartPosition.HEAD_FACE, scale);
|
|
||||||
BufferedImage headLeft = this.getSkinPart(skin, Skin.PartPosition.HEAD_LEFT, scale);
|
|
||||||
|
|
||||||
if (renderOverlay) { // Render the skin layers
|
|
||||||
applyOverlay(headTop, this.getSkinPart(skin, Skin.PartPosition.HEAD_OVERLAY_TOP, scale));
|
|
||||||
applyOverlay(headFront, this.getSkinPart(skin, Skin.PartPosition.HEAD_OVERLAY_FACE, scale));
|
|
||||||
applyOverlay(headLeft, this.getSkinPart(skin, Skin.PartPosition.HEAD_OVERLAY_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, headFront, FACE_TRANSFORM, x, x + zOffset - 0.5, headFront.getWidth(), headFront.getHeight());
|
|
||||||
|
|
||||||
// Draw the left side of the head
|
|
||||||
drawPart(graphics, headLeft, HEAD_LEFT_TRANSFORM, xOffset + 1, zOffset - 0.5, headLeft.getWidth(), headLeft.getHeight());
|
|
||||||
|
|
||||||
return super.getBytes(outputImage, skin, partName);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Draws a part of the head.
|
|
||||||
*
|
|
||||||
* @param graphics the graphics
|
|
||||||
* @param part the part
|
|
||||||
* @param transform the transform
|
|
||||||
* @param x the x position
|
|
||||||
* @param y the y position
|
|
||||||
* @param width the width
|
|
||||||
* @param height the height
|
|
||||||
*/
|
|
||||||
private void drawPart(Graphics2D graphics, BufferedImage part, AffineTransform transform, double x, double y, int width, int height) {
|
|
||||||
graphics.setTransform(transform);
|
|
||||||
graphics.drawImage(part, (int) x, (int) y, width, height, null);
|
|
||||||
}
|
|
||||||
}
|
|
0
src/main/java/cc.fascinated/common/DNSUtils.java → src/main/java/cc/fascinated/common/DNSUtils.java
0
src/main/java/cc.fascinated/common/DNSUtils.java → src/main/java/cc/fascinated/common/DNSUtils.java
58
src/main/java/cc/fascinated/common/ImageUtils.java
Normal file
58
src/main/java/cc/fascinated/common/ImageUtils.java
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
package cc.fascinated.common;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import lombok.SneakyThrows;
|
||||||
|
import lombok.extern.log4j.Log4j2;
|
||||||
|
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
import java.awt.*;
|
||||||
|
import java.awt.geom.AffineTransform;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
@Log4j2
|
||||||
|
public class ImageUtils {
|
||||||
|
/**
|
||||||
|
* Scale the given image to the provided size.
|
||||||
|
*
|
||||||
|
* @param image the image to scale
|
||||||
|
* @param size the size to scale the image to
|
||||||
|
* @return the scaled image
|
||||||
|
*/
|
||||||
|
public static BufferedImage resize(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flip the given image.
|
||||||
|
*
|
||||||
|
* @param image the image to flip
|
||||||
|
* @return the flipped image
|
||||||
|
*/
|
||||||
|
public static BufferedImage flip(@NotNull final BufferedImage image) {
|
||||||
|
BufferedImage flipped = new BufferedImage(image.getWidth(), image.getHeight(), BufferedImage.TYPE_INT_ARGB);
|
||||||
|
Graphics2D graphics = flipped.createGraphics();
|
||||||
|
graphics.drawImage(image, image.getWidth(), 0, 0, image.getHeight(), 0, 0, image.getWidth(), image.getHeight(), null);
|
||||||
|
graphics.dispose();
|
||||||
|
return flipped;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert an image to bytes.
|
||||||
|
*
|
||||||
|
* @param image the image to convert
|
||||||
|
* @return the image as bytes
|
||||||
|
*/
|
||||||
|
@SneakyThrows
|
||||||
|
public static byte[] imageToBytes(BufferedImage image) {
|
||||||
|
try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
|
||||||
|
ImageIO.write(image, "png", outputStream);
|
||||||
|
return outputStream.toByteArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,27 @@
|
|||||||
|
package cc.fascinated.common.renderer;
|
||||||
|
|
||||||
|
import cc.fascinated.model.skin.ISkinPart;
|
||||||
|
|
||||||
|
import java.awt.*;
|
||||||
|
import java.awt.geom.AffineTransform;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
|
||||||
|
public abstract class IsometricSkinRenderer<T extends ISkinPart> extends SkinRenderer<T> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
protected final void drawPart(Graphics2D graphics, BufferedImage partImage, AffineTransform transform,
|
||||||
|
double x, double y, int width, int height) {
|
||||||
|
graphics.setTransform(transform);
|
||||||
|
graphics.drawImage(partImage, (int) x, (int) y, width, height, null);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,95 @@
|
|||||||
|
package cc.fascinated.common.renderer;
|
||||||
|
|
||||||
|
import cc.fascinated.common.ImageUtils;
|
||||||
|
import cc.fascinated.model.skin.ISkinPart;
|
||||||
|
import cc.fascinated.model.skin.Skin;
|
||||||
|
import lombok.SneakyThrows;
|
||||||
|
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
import java.awt.*;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
|
||||||
|
public abstract class SkinRenderer<T extends ISkinPart> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
public BufferedImage getVanillaSkinPart(Skin skin, ISkinPart.Vanilla part, double size) {
|
||||||
|
ISkinPart.Vanilla.Coordinates coordinates = part.getCoordinates(); // The coordinates of the part
|
||||||
|
|
||||||
|
// The skin texture is legacy, use legacy coordinates
|
||||||
|
if (skin.isLegacy() && part.hasLegacyCoordinates()) {
|
||||||
|
coordinates = part.getLegacyCoordinates();
|
||||||
|
}
|
||||||
|
int width = part.getWidth(); // The width of the part
|
||||||
|
if (skin.getModel() == Skin.Model.SLIM && part.isFrontArm()) {
|
||||||
|
width--;
|
||||||
|
}
|
||||||
|
BufferedImage skinImage = ImageIO.read(new ByteArrayInputStream(skin.getSkinImage())); // The skin texture
|
||||||
|
BufferedImage partTexture = getSkinPartTexture(skinImage, coordinates.getX(), coordinates.getY(), width, part.getHeight(), size);
|
||||||
|
if (coordinates instanceof ISkinPart.Vanilla.LegacyCoordinates legacyCoordinates && legacyCoordinates.isFlipped()) {
|
||||||
|
partTexture = ImageUtils.flip(partTexture);
|
||||||
|
}
|
||||||
|
return partTexture;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the texture of a specific part of the skin.
|
||||||
|
*
|
||||||
|
* @param skinImage the skin image 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(BufferedImage skinImage, int x, int y, int width, int height, double size) {
|
||||||
|
// 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
|
||||||
|
if (size > 0D) {
|
||||||
|
headTexture = ImageUtils.resize(headTexture, size);
|
||||||
|
}
|
||||||
|
return headTexture;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply an overlay to a texture.
|
||||||
|
*
|
||||||
|
* @param graphics the graphics to overlay on
|
||||||
|
* @param overlayImage the part to overlay
|
||||||
|
*/
|
||||||
|
protected void applyOverlay(Graphics2D graphics, BufferedImage overlayImage) {
|
||||||
|
try {
|
||||||
|
graphics.drawImage(overlayImage, 0, 0, null);
|
||||||
|
graphics.dispose();
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// We can safely ignore this, legacy
|
||||||
|
// skins don't have overlays
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the skin part for the player's skin.
|
||||||
|
*
|
||||||
|
* @param skin the player's skin
|
||||||
|
* @param part the skin part to render
|
||||||
|
* @param renderOverlays should the overlays be rendered
|
||||||
|
* @param size the size of the part
|
||||||
|
* @return the rendered skin part
|
||||||
|
*/
|
||||||
|
public abstract BufferedImage render(Skin skin, T part, boolean renderOverlays, int size);
|
||||||
|
}
|
@ -0,0 +1,42 @@
|
|||||||
|
package cc.fascinated.common.renderer.impl;
|
||||||
|
|
||||||
|
import cc.fascinated.common.ImageUtils;
|
||||||
|
import cc.fascinated.common.renderer.SkinRenderer;
|
||||||
|
import cc.fascinated.model.skin.ISkinPart;
|
||||||
|
import cc.fascinated.model.skin.Skin;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.extern.log4j.Log4j2;
|
||||||
|
|
||||||
|
import java.awt.*;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
|
||||||
|
@AllArgsConstructor @Getter @Log4j2
|
||||||
|
public class BodyRenderer extends SkinRenderer<ISkinPart.Custom> {
|
||||||
|
public static final BodyRenderer INSTANCE = new BodyRenderer();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public BufferedImage render(Skin skin, ISkinPart.Custom part, boolean renderOverlays, int size) {
|
||||||
|
BufferedImage texture = new BufferedImage(16, 32, BufferedImage.TYPE_INT_ARGB); // The texture to return
|
||||||
|
Graphics2D graphics = texture.createGraphics(); // Create the graphics for drawing
|
||||||
|
|
||||||
|
// Get the Vanilla skin parts to draw
|
||||||
|
BufferedImage face = getVanillaSkinPart(skin, ISkinPart.Vanilla.FACE, -1);
|
||||||
|
BufferedImage body = getVanillaSkinPart(skin, ISkinPart.Vanilla.BODY_FRONT, -1);
|
||||||
|
BufferedImage leftArm = getVanillaSkinPart(skin, ISkinPart.Vanilla.LEFT_ARM_FRONT, -1);
|
||||||
|
BufferedImage rightArm = getVanillaSkinPart(skin, ISkinPart.Vanilla.RIGHT_ARM_FRONT, -1);
|
||||||
|
BufferedImage leftLeg = getVanillaSkinPart(skin, ISkinPart.Vanilla.LEFT_LEG_FRONT, -1);
|
||||||
|
BufferedImage rightLeg = getVanillaSkinPart(skin, ISkinPart.Vanilla.RIGHT_LEG_FRONT, -1);
|
||||||
|
|
||||||
|
// Draw the body parts
|
||||||
|
graphics.drawImage(face, 4, 0, null);
|
||||||
|
graphics.drawImage(body, 4, 8, null);
|
||||||
|
graphics.drawImage(leftArm, skin.getModel() == Skin.Model.SLIM ? 1 : 0, 8, null);
|
||||||
|
graphics.drawImage(rightArm, 12, 8, null);
|
||||||
|
graphics.drawImage(leftLeg, 8, 20, null);
|
||||||
|
graphics.drawImage(rightLeg, 4, 20, null);
|
||||||
|
|
||||||
|
graphics.dispose();
|
||||||
|
return ImageUtils.resize(texture, (double) size / 32);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,48 @@
|
|||||||
|
package cc.fascinated.common.renderer.impl;
|
||||||
|
|
||||||
|
import cc.fascinated.common.renderer.IsometricSkinRenderer;
|
||||||
|
import cc.fascinated.model.skin.ISkinPart;
|
||||||
|
import cc.fascinated.model.skin.Skin;
|
||||||
|
|
||||||
|
import java.awt.*;
|
||||||
|
import java.awt.geom.AffineTransform;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
|
||||||
|
public class IsometricHeadRenderer extends IsometricSkinRenderer<ISkinPart.Custom> {
|
||||||
|
public static final IsometricHeadRenderer INSTANCE = new IsometricHeadRenderer();
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public BufferedImage render(Skin skin, ISkinPart.Custom part, boolean renderOverlays, 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); // The texture to return
|
||||||
|
Graphics2D graphics = texture.createGraphics(); // Create the graphics for drawing
|
||||||
|
|
||||||
|
// Get the Vanilla skin parts to draw
|
||||||
|
BufferedImage headTop = getVanillaSkinPart(skin, ISkinPart.Vanilla.HEAD_TOP, scale);
|
||||||
|
BufferedImage face = getVanillaSkinPart(skin, ISkinPart.Vanilla.FACE, scale);
|
||||||
|
BufferedImage headLeft = getVanillaSkinPart(skin, ISkinPart.Vanilla.HEAD_LEFT, scale);
|
||||||
|
|
||||||
|
// Draw the top head part
|
||||||
|
drawPart(graphics, headTop, HEAD_TOP_TRANSFORM, -0.5 - zOffset, xOffset + zOffset, headTop.getWidth(), headTop.getHeight() + 2);
|
||||||
|
|
||||||
|
// Draw the face part
|
||||||
|
double x = xOffset + 8 * scale;
|
||||||
|
drawPart(graphics, face, FACE_TRANSFORM, x, x + zOffset - 0.5, face.getWidth(), face.getHeight());
|
||||||
|
|
||||||
|
// Draw the left head part
|
||||||
|
drawPart(graphics, headLeft, HEAD_LEFT_TRANSFORM, xOffset + 1, zOffset - 0.5, headLeft.getWidth(), headLeft.getHeight());
|
||||||
|
|
||||||
|
graphics.dispose();
|
||||||
|
return texture;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,39 @@
|
|||||||
|
package cc.fascinated.common.renderer.impl;
|
||||||
|
|
||||||
|
import cc.fascinated.common.renderer.SkinRenderer;
|
||||||
|
import cc.fascinated.model.skin.ISkinPart;
|
||||||
|
import cc.fascinated.model.skin.Skin;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.extern.log4j.Log4j2;
|
||||||
|
|
||||||
|
import java.awt.*;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
|
||||||
|
@AllArgsConstructor @Getter @Log4j2
|
||||||
|
public class SquareRenderer extends SkinRenderer<ISkinPart.Vanilla> {
|
||||||
|
public static final SquareRenderer INSTANCE = new SquareRenderer();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public BufferedImage render(Skin skin, ISkinPart.Vanilla part, boolean renderOverlays, int size) {
|
||||||
|
double scale = size / 8D;
|
||||||
|
BufferedImage partImage = getVanillaSkinPart(skin, part, scale); // Get the part image
|
||||||
|
if (!renderOverlays) { // Not rendering overlays
|
||||||
|
return partImage;
|
||||||
|
}
|
||||||
|
// Create a new image, draw our skin part texture, and then apply overlays
|
||||||
|
BufferedImage texture = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB); // The texture to return
|
||||||
|
Graphics2D graphics = texture.createGraphics(); // Create the graphics for drawing
|
||||||
|
graphics.drawImage(partImage, 0, 0, null);
|
||||||
|
|
||||||
|
// Draw part overlays
|
||||||
|
ISkinPart.Vanilla[] overlayParts = part.getOverlays();
|
||||||
|
if (overlayParts != null) {
|
||||||
|
for (ISkinPart.Vanilla overlay : overlayParts) {
|
||||||
|
applyOverlay(graphics, getVanillaSkinPart(skin, overlay, scale));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
graphics.dispose();
|
||||||
|
return texture;
|
||||||
|
}
|
||||||
|
}
|
@ -2,7 +2,7 @@ package cc.fascinated.controller;
|
|||||||
|
|
||||||
import cc.fascinated.model.cache.CachedPlayer;
|
import cc.fascinated.model.cache.CachedPlayer;
|
||||||
import cc.fascinated.model.cache.CachedPlayerName;
|
import cc.fascinated.model.cache.CachedPlayerName;
|
||||||
import cc.fascinated.model.player.Skin;
|
import cc.fascinated.model.skin.Skin;
|
||||||
import cc.fascinated.service.PlayerService;
|
import cc.fascinated.service.PlayerService;
|
||||||
import io.swagger.v3.oas.annotations.Parameter;
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
@ -52,7 +52,6 @@ public class PlayerController {
|
|||||||
@Parameter(description = "Whether to render the skin overlay (skin layers)", example = "false") @RequestParam(required = false, defaultValue = "false") boolean overlay,
|
@Parameter(description = "Whether to render the skin overlay (skin layers)", example = "false") @RequestParam(required = false, defaultValue = "false") boolean overlay,
|
||||||
@Parameter(description = "Whether to download the image") @RequestParam(required = false, defaultValue = "false") boolean download) {
|
@Parameter(description = "Whether to download the image") @RequestParam(required = false, defaultValue = "false") boolean download) {
|
||||||
CachedPlayer player = playerService.getPlayer(id);
|
CachedPlayer player = playerService.getPlayer(id);
|
||||||
Skin.Parts skinPart = Skin.Parts.fromName(part);
|
|
||||||
String dispositionHeader = download ? "attachment; filename=%s.png" : "inline; filename=%s.png";
|
String dispositionHeader = download ? "attachment; filename=%s.png" : "inline; filename=%s.png";
|
||||||
|
|
||||||
// Return the part image
|
// Return the part image
|
||||||
@ -60,6 +59,6 @@ public class PlayerController {
|
|||||||
.cacheControl(cacheControl)
|
.cacheControl(cacheControl)
|
||||||
.contentType(MediaType.IMAGE_PNG)
|
.contentType(MediaType.IMAGE_PNG)
|
||||||
.header(HttpHeaders.CONTENT_DISPOSITION, dispositionHeader.formatted(player.getUsername()))
|
.header(HttpHeaders.CONTENT_DISPOSITION, dispositionHeader.formatted(player.getUsername()))
|
||||||
.body(playerService.getSkinPart(player, skinPart, overlay, size).getBytes());
|
.body(playerService.getSkinPart(player, part, overlay, size).getBytes());
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -3,7 +3,7 @@ package cc.fascinated.model.cache;
|
|||||||
import cc.fascinated.model.mojang.MojangProfile;
|
import cc.fascinated.model.mojang.MojangProfile;
|
||||||
import cc.fascinated.model.player.Cape;
|
import cc.fascinated.model.player.Cape;
|
||||||
import cc.fascinated.model.player.Player;
|
import cc.fascinated.model.player.Player;
|
||||||
import cc.fascinated.model.player.Skin;
|
import cc.fascinated.model.skin.Skin;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
import lombok.Setter;
|
import lombok.Setter;
|
||||||
import lombok.ToString;
|
import lombok.ToString;
|
@ -4,7 +4,7 @@ import cc.fascinated.Main;
|
|||||||
import cc.fascinated.common.Tuple;
|
import cc.fascinated.common.Tuple;
|
||||||
import cc.fascinated.common.UUIDUtils;
|
import cc.fascinated.common.UUIDUtils;
|
||||||
import cc.fascinated.model.player.Cape;
|
import cc.fascinated.model.player.Cape;
|
||||||
import cc.fascinated.model.player.Skin;
|
import cc.fascinated.model.skin.Skin;
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
import com.google.gson.JsonObject;
|
import com.google.gson.JsonObject;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
@ -3,6 +3,7 @@ package cc.fascinated.model.player;
|
|||||||
import cc.fascinated.common.Tuple;
|
import cc.fascinated.common.Tuple;
|
||||||
import cc.fascinated.common.UUIDUtils;
|
import cc.fascinated.common.UUIDUtils;
|
||||||
import cc.fascinated.model.mojang.MojangProfile;
|
import cc.fascinated.model.mojang.MojangProfile;
|
||||||
|
import cc.fascinated.model.skin.Skin;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
import org.springframework.data.annotation.Id;
|
import org.springframework.data.annotation.Id;
|
199
src/main/java/cc/fascinated/model/skin/ISkinPart.java
Normal file
199
src/main/java/cc/fascinated/model/skin/ISkinPart.java
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
package cc.fascinated.model.skin;
|
||||||
|
|
||||||
|
import cc.fascinated.common.renderer.SkinRenderer;
|
||||||
|
import cc.fascinated.common.renderer.impl.BodyRenderer;
|
||||||
|
import cc.fascinated.common.renderer.impl.IsometricHeadRenderer;
|
||||||
|
import cc.fascinated.common.renderer.impl.SquareRenderer;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
|
||||||
|
public interface ISkinPart {
|
||||||
|
Enum<?>[][] TYPES = { Vanilla.values(), Custom.values() };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name of the part.
|
||||||
|
*
|
||||||
|
* @return the part name
|
||||||
|
*/
|
||||||
|
String name();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Should this part be hidden from the
|
||||||
|
* player skin part urls list?
|
||||||
|
*
|
||||||
|
* @return whether this part should be hidden
|
||||||
|
*/
|
||||||
|
boolean hidden();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the skin part for the skin.
|
||||||
|
*
|
||||||
|
* @param skin the skin
|
||||||
|
* @param renderOverlays should the overlays be rendered
|
||||||
|
* @param size the size of the part
|
||||||
|
* @return the rendered skin part
|
||||||
|
*/
|
||||||
|
BufferedImage render(Skin skin, boolean renderOverlays, int size);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a skin part by the given name.
|
||||||
|
*
|
||||||
|
* @param name the name of the part
|
||||||
|
* @return the part, null if none
|
||||||
|
*/
|
||||||
|
static ISkinPart getByName(String name) {
|
||||||
|
name = name.toUpperCase();
|
||||||
|
for (Enum<?>[] type : TYPES) {
|
||||||
|
for (Enum<?> part : type) {
|
||||||
|
if (!part.name().equals(name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return (ISkinPart) part;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
enum Vanilla implements ISkinPart {
|
||||||
|
// Overlays
|
||||||
|
HEAD_OVERLAY_FACE(true, new Coordinates(40, 8), 8, 8),
|
||||||
|
|
||||||
|
// Head
|
||||||
|
HEAD_TOP(true, new Coordinates(8, 0), 8, 8),
|
||||||
|
FACE(false, new Coordinates(8, 8), 8, 8, HEAD_OVERLAY_FACE),
|
||||||
|
HEAD_LEFT(true, new Coordinates(0, 8), 8, 8),
|
||||||
|
HEAD_RIGHT(true, new Coordinates(16, 8), 8, 8),
|
||||||
|
HEAD_BOTTOM(true, new Coordinates(16, 0), 8, 8),
|
||||||
|
HEAD_BACK(true, new Coordinates(24, 8), 8, 8),
|
||||||
|
|
||||||
|
// Body
|
||||||
|
BODY_FRONT(true, new Coordinates(20, 20), 8, 12),
|
||||||
|
|
||||||
|
// Arms
|
||||||
|
LEFT_ARM_TOP(true, new Coordinates(36, 48), 4, 4),
|
||||||
|
RIGHT_ARM_TOP(true, new Coordinates(44, 16), 4, 4),
|
||||||
|
|
||||||
|
LEFT_ARM_FRONT(true, new Coordinates(44, 20), 4, 12),
|
||||||
|
RIGHT_ARM_FRONT(true, new Coordinates(36, 52), new LegacyCoordinates(44, 20, true), 4, 12),
|
||||||
|
|
||||||
|
// Legs
|
||||||
|
LEFT_LEG_FRONT(true, new Coordinates(4, 20), 4, 12), // Front
|
||||||
|
RIGHT_LEG_FRONT(true, new Coordinates(20, 52), new LegacyCoordinates(4, 20, true), 4, 12); // Front
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Should this part be hidden from the
|
||||||
|
* player skin part urls list?
|
||||||
|
*/
|
||||||
|
private final boolean hidden;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The coordinates of the part.
|
||||||
|
*/
|
||||||
|
private final Coordinates coordinates;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The legacy coordinates of the part.
|
||||||
|
*/
|
||||||
|
private final LegacyCoordinates legacyCoordinates;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The width and height of the part.
|
||||||
|
*/
|
||||||
|
private final int width, height;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The overlays of the part.
|
||||||
|
*/
|
||||||
|
private final Vanilla[] overlays;
|
||||||
|
|
||||||
|
Vanilla(boolean hidden, Coordinates coordinates, int width, int height, Vanilla... overlays) {
|
||||||
|
this(hidden, coordinates, null, width, height, overlays);
|
||||||
|
}
|
||||||
|
|
||||||
|
Vanilla(boolean hidden, Coordinates coordinates, LegacyCoordinates legacyCoordinates, int width, int height, Vanilla... overlays) {
|
||||||
|
this.hidden = hidden;
|
||||||
|
this.coordinates = coordinates;
|
||||||
|
this.legacyCoordinates = legacyCoordinates;
|
||||||
|
this.width = width;
|
||||||
|
this.height = height;
|
||||||
|
this.overlays = overlays;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean hidden() {
|
||||||
|
return this.isHidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public BufferedImage render(Skin skin, boolean renderOverlays, int size) {
|
||||||
|
return SquareRenderer.INSTANCE.render(skin, this, renderOverlays, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is this part a front arm?
|
||||||
|
*
|
||||||
|
* @return whether this part is a front arm
|
||||||
|
*/
|
||||||
|
public boolean isFrontArm() {
|
||||||
|
return this == LEFT_ARM_FRONT || this == RIGHT_ARM_FRONT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Does this part have legacy coordinates?
|
||||||
|
*
|
||||||
|
* @return whether this part has legacy coordinates
|
||||||
|
*/
|
||||||
|
public boolean hasLegacyCoordinates() {
|
||||||
|
return legacyCoordinates != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@AllArgsConstructor @Getter
|
||||||
|
public static class Coordinates {
|
||||||
|
/**
|
||||||
|
* The X and Y position of the part.
|
||||||
|
*/
|
||||||
|
private final int x, y;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
public static class LegacyCoordinates extends Coordinates {
|
||||||
|
/**
|
||||||
|
* Should the part be flipped horizontally?
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@AllArgsConstructor @Getter
|
||||||
|
enum Custom implements ISkinPart {
|
||||||
|
HEAD(IsometricHeadRenderer.INSTANCE),
|
||||||
|
BODY(BodyRenderer.INSTANCE);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The renderer to use for this part
|
||||||
|
*/
|
||||||
|
private final SkinRenderer<Custom> renderer;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean hidden() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public BufferedImage render(Skin skin, boolean renderOverlays, int size) {
|
||||||
|
return renderer.render(skin, this, renderOverlays, size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
128
src/main/java/cc/fascinated/model/skin/Skin.java
Normal file
128
src/main/java/cc/fascinated/model/skin/Skin.java
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
package cc.fascinated.model.skin;
|
||||||
|
|
||||||
|
import cc.fascinated.common.PlayerUtils;
|
||||||
|
import cc.fascinated.config.Config;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.extern.log4j.Log4j2;
|
||||||
|
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@AllArgsConstructor @NoArgsConstructor
|
||||||
|
@Getter @Log4j2
|
||||||
|
public class Skin {
|
||||||
|
/**
|
||||||
|
* The default skin, usually used when the skin is not found.
|
||||||
|
*/
|
||||||
|
public static final Skin DEFAULT_SKIN = new Skin("http://textures.minecraft.net/texture/60a5bd016b3c9a1b9272e4929e30827a67be4ebb219017adbbc4a4d22ebd5b1",
|
||||||
|
Model.DEFAULT);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The URL for the skin
|
||||||
|
*/
|
||||||
|
private String url;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The model for the skin
|
||||||
|
*/
|
||||||
|
private Model model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The legacy status of the skin
|
||||||
|
*/
|
||||||
|
private boolean isLegacy = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The skin image for the skin
|
||||||
|
*/
|
||||||
|
@JsonIgnore
|
||||||
|
private byte[] skinImage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The part URLs of the skin
|
||||||
|
*/
|
||||||
|
@JsonProperty("parts")
|
||||||
|
private Map<String, String> partUrls = new HashMap<>();
|
||||||
|
|
||||||
|
public Skin(String url, Model model) {
|
||||||
|
this.url = url;
|
||||||
|
this.model = model;
|
||||||
|
|
||||||
|
this.skinImage = PlayerUtils.getSkinImage(url);
|
||||||
|
if (this.skinImage != null) {
|
||||||
|
try {
|
||||||
|
BufferedImage image = ImageIO.read(new ByteArrayInputStream(this.skinImage));
|
||||||
|
if (image.getWidth() == 64 && image.getHeight() == 32) { // Using the old skin format
|
||||||
|
this.isLegacy = true;
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the skin from a {@link JsonObject}.
|
||||||
|
*
|
||||||
|
* @param json the JSON object
|
||||||
|
* @return the skin
|
||||||
|
*/
|
||||||
|
public static Skin fromJson(JsonObject json) {
|
||||||
|
if (json == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String url = json.get("url").getAsString();
|
||||||
|
JsonObject metadata = json.getAsJsonObject("metadata");
|
||||||
|
Model model = Model.fromName(metadata == null ? "default" : // Fall back to slim if the model is not found
|
||||||
|
metadata.get("model").getAsString());
|
||||||
|
return new Skin(url, model);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Populates the part URLs for the skin.
|
||||||
|
*
|
||||||
|
* @param playerUuid the player's UUID
|
||||||
|
*/
|
||||||
|
public Skin populatePartUrls(String playerUuid) {
|
||||||
|
for (Enum<?>[] type : ISkinPart.TYPES) {
|
||||||
|
for (Enum<?> part : type) {
|
||||||
|
ISkinPart skinPart = (ISkinPart) part;
|
||||||
|
if (skinPart.hidden()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String partName = part.name().toLowerCase();
|
||||||
|
this.partUrls.put(partName, Config.INSTANCE.getWebPublicUrl() + "/player/" + partName + "/" + playerUuid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The model of the skin.
|
||||||
|
*/
|
||||||
|
public enum Model {
|
||||||
|
DEFAULT,
|
||||||
|
SLIM;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the model from its name.
|
||||||
|
*
|
||||||
|
* @param name the name of the model
|
||||||
|
* @return the model
|
||||||
|
*/
|
||||||
|
public static Model fromName(String name) {
|
||||||
|
for (Model model : values()) {
|
||||||
|
if (model.name().equalsIgnoreCase(name)) {
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,5 +1,6 @@
|
|||||||
package cc.fascinated.service;
|
package cc.fascinated.service;
|
||||||
|
|
||||||
|
import cc.fascinated.common.ImageUtils;
|
||||||
import cc.fascinated.common.PlayerUtils;
|
import cc.fascinated.common.PlayerUtils;
|
||||||
import cc.fascinated.common.Tuple;
|
import cc.fascinated.common.Tuple;
|
||||||
import cc.fascinated.common.UUIDUtils;
|
import cc.fascinated.common.UUIDUtils;
|
||||||
@ -14,7 +15,8 @@ import cc.fascinated.model.mojang.MojangProfile;
|
|||||||
import cc.fascinated.model.mojang.MojangUsernameToUuid;
|
import cc.fascinated.model.mojang.MojangUsernameToUuid;
|
||||||
import cc.fascinated.model.player.Cape;
|
import cc.fascinated.model.player.Cape;
|
||||||
import cc.fascinated.model.player.Player;
|
import cc.fascinated.model.player.Player;
|
||||||
import cc.fascinated.model.player.Skin;
|
import cc.fascinated.model.skin.ISkinPart;
|
||||||
|
import cc.fascinated.model.skin.Skin;
|
||||||
import cc.fascinated.repository.PlayerCacheRepository;
|
import cc.fascinated.repository.PlayerCacheRepository;
|
||||||
import cc.fascinated.repository.PlayerNameCacheRepository;
|
import cc.fascinated.repository.PlayerNameCacheRepository;
|
||||||
import cc.fascinated.repository.PlayerSkinPartCacheRepository;
|
import cc.fascinated.repository.PlayerSkinPartCacheRepository;
|
||||||
@ -22,6 +24,7 @@ import lombok.extern.log4j.Log4j2;
|
|||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@ -119,33 +122,41 @@ public class PlayerService {
|
|||||||
* Gets a skin part from the player's skin.
|
* Gets a skin part from the player's skin.
|
||||||
*
|
*
|
||||||
* @param player the player
|
* @param player the player
|
||||||
* @param part the part of the skin
|
* @param partName the name of the part
|
||||||
* @param renderOverlay whether to render the overlay
|
* @param renderOverlay whether to render the overlay
|
||||||
* @return the skin part
|
* @return the skin part
|
||||||
*/
|
*/
|
||||||
public CachedPlayerSkinPart getSkinPart(Player player, Skin.Parts part, boolean renderOverlay, int size) {
|
public CachedPlayerSkinPart getSkinPart(Player player, String partName, boolean renderOverlay, int size) {
|
||||||
if (size > 512) {
|
if (size > 512) {
|
||||||
log.info("Size {} is too large, setting to 512", size);
|
log.info("Size {} is too large, setting to 512", size);
|
||||||
size = 512;
|
size = 512;
|
||||||
}
|
}
|
||||||
log.info("Getting skin part {} for player: {}", part.getName(), player.getUniqueId());
|
ISkinPart part = ISkinPart.getByName(partName); // The skin part to get
|
||||||
String key = "%s-%s-%s".formatted(player.getUniqueId(), part.getName(), size);
|
if (part == null) { // Default to the face
|
||||||
|
part = ISkinPart.Vanilla.FACE;
|
||||||
|
log.warn("Invalid skin part {}, defaulting to {}", partName, part.name());
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Getting skin part {} for player: {}", part.name(), player.getUniqueId());
|
||||||
|
String key = "%s-%s-%s-%s".formatted(player.getUniqueId(), part.name(), size, renderOverlay);
|
||||||
Optional<CachedPlayerSkinPart> cache = playerSkinPartCacheRepository.findById(key);
|
Optional<CachedPlayerSkinPart> cache = playerSkinPartCacheRepository.findById(key);
|
||||||
|
|
||||||
// The skin part is cached
|
// The skin part is cached
|
||||||
if (cache.isPresent() && Config.INSTANCE.isProduction()) {
|
if (cache.isPresent() && Config.INSTANCE.isProduction()) {
|
||||||
log.info("Skin part {} for player {} is cached", part.getName(), player.getUniqueId());
|
log.info("Skin part {} for player {} is cached", part.name(), player.getUniqueId());
|
||||||
return cache.get();
|
return cache.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
long before = System.currentTimeMillis();
|
long before = System.currentTimeMillis();
|
||||||
byte[] skinPartBytes = part.getRenderer().renderPart(player.getSkin(), part.getName(), renderOverlay, size);
|
BufferedImage renderedPart = part.render(player.getSkin(), renderOverlay, size); // Render the skin part
|
||||||
log.info("Took {}ms to render skin part {} for player: {}", System.currentTimeMillis() - before, part.getName(), player.getUniqueId());
|
log.info("Took {}ms to render skin part {} for player: {}", System.currentTimeMillis() - before, part.name(), player.getUniqueId());
|
||||||
|
|
||||||
|
byte[] skinPartBytes = ImageUtils.imageToBytes(renderedPart); // Convert the image to bytes
|
||||||
CachedPlayerSkinPart skinPart = new CachedPlayerSkinPart(
|
CachedPlayerSkinPart skinPart = new CachedPlayerSkinPart(
|
||||||
key,
|
key,
|
||||||
skinPartBytes
|
skinPartBytes
|
||||||
);
|
);
|
||||||
log.info("Fetched skin part {} for player: {}", part.getName(), player.getUniqueId());
|
log.info("Fetched skin part {} for player: {}", part.name(), player.getUniqueId());
|
||||||
|
|
||||||
playerSkinPartCacheRepository.save(skinPart);
|
playerSkinPartCacheRepository.save(skinPart);
|
||||||
return skinPart;
|
return skinPart;
|
@ -1,7 +1,7 @@
|
|||||||
package cc.fascinated.tests;
|
package cc.fascinated.tests;
|
||||||
|
|
||||||
import cc.fascinated.config.TestRedisConfig;
|
import cc.fascinated.config.TestRedisConfig;
|
||||||
import cc.fascinated.model.player.Skin;
|
import cc.fascinated.model.skin.Skin;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||||
|
Loading…
Reference in New Issue
Block a user