Volver al índice

src/main/java/ar/com/companeros/horror/client/ParasiteHostSkin.java

package ar.com.companeros.horror.client;

import ar.com.companeros.Companeros;
import ar.com.companeros.horror.ParasiteEntity;
import ar.com.companeros.identity.HostSkinAtlas;
import ar.com.companeros.identity.SkinHttp;
import ar.com.companeros.identity.SkinSources;
import com.mojang.blaze3d.platform.NativeImage;
import java.net.URI;
import java.util.*;
import java.util.concurrent.*;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.texture.DynamicTexture;
import net.minecraft.client.resources.DefaultPlayerSkin;
import net.minecraft.resources.ResourceLocation;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.client.event.ClientPlayerNetworkEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;

/** Piel del huésped, compuesta por etapa y guardada en caché; ninguna descarga ocurre en render(). */
@Mod.EventBusSubscriber(modid = Companeros.ID, value = Dist.CLIENT)
public final class ParasiteHostSkin {
    private static final ResourceLocation STEVE = new ResourceLocation("minecraft", "textures/entity/player/wide/steve.png");
    private static final Map<String, Pixels> SOURCES = new LinkedHashMap<>();
    private static final Map<String, ResourceLocation> ATLASES = new LinkedHashMap<>();
    private static final Set<String> PENDING = new HashSet<>();
    private static final Map<String, Long> RETRY_AT = new HashMap<>();
    private static final ThreadPoolExecutor DOWNLOADS = new ThreadPoolExecutor(1, 1, 30, TimeUnit.SECONDS,
        new ArrayBlockingQueue<>(4), task -> { Thread thread = new Thread(task, "raps-host-skin"); thread.setDaemon(true); return thread; });
    private static long generation;
    private record Pixels(int[] argb, int height) { }
    private ParasiteHostSkin() { }

    public static ResourceLocation texture(ParasiteEntity body) {
        Minecraft minecraft = Minecraft.getInstance();
        UUID host = body.hostId().orElse(body.getUUID());
        String selected = body.getHostSkinSource();
        boolean steve = selected.isEmpty() || selected.equalsIgnoreCase("steve") || selected.equalsIgnoreCase("default");
        if (!steve && selected.matches("[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}")) {
            host = UUID.fromString(selected); selected = "";
        } else if (steve) selected = "";
        else try { selected = SkinSources.texture(selected); } catch (IllegalArgumentException invalid) { selected = ""; steve = true; }
        String fallback = steve ? "builtin:steve" : "default:" + host;
        String source = selected.isEmpty() ? fallback : selected;
        Pixels pixels = SOURCES.get(source);
        if (pixels == null && !selected.isEmpty()) {
            request(selected);
            source = fallback;
        }
        pixels = SOURCES.get(source);
        if (pixels == null) {
            pixels = defaultSkin(host, steve); SOURCES.put(source, pixels); trimSources();
        }
        String key = source + ":" + body.getMutationStage();
        ResourceLocation location = ATLASES.get(key);
        if (location != null) return location;
        int[] composed = HostSkinAtlas.compose(pixels.argb(), pixels.height(), body.getMutationStage());
        NativeImage image = new NativeImage(128, 128, false);
        for (int y = 0; y < 128; y++) for (int x = 0; x < 128; x++) image.setPixelRGBA(x, y, reverseRedBlue(composed[y * 128 + x]));
        location = minecraft.getTextureManager().register("raps_host_skin", new DynamicTexture(image));
        ATLASES.put(key, location);
        while (ATLASES.size() > 64) {
            String oldest = ATLASES.keySet().iterator().next();
            minecraft.getTextureManager().release(ATLASES.remove(oldest));
        }
        return location;
    }

    // Vanilla elige su skin predeterminada por UUID, incluso cuando nunca hubo acceso a Luna.
    private static Pixels defaultSkin(UUID host, boolean steve) {
        ResourceLocation resource = steve ? STEVE : DefaultPlayerSkin.getDefaultSkin(host);
        try (var input = Minecraft.getInstance().getResourceManager().getResourceOrThrow(resource).open();
                NativeImage skin = NativeImage.read(input)) {
            return pixels(skin);
        } catch (java.io.IOException unavailable) {
            // Un pack que elimina assets vanilla no bloquea el renderizado.
            int[] safe = new int[64 * 64]; Arrays.fill(safe, 0xFFC58E73); return new Pixels(safe, 64);
        }
    }
    private static Pixels pixels(NativeImage image) {
        if (image.getWidth() != 64 || (image.getHeight() != 32 && image.getHeight() != 64)) throw new IllegalArgumentException("Skin fuera de dimensiones");
        int[] values = new int[64 * image.getHeight()];
        for (int y = 0; y < image.getHeight(); y++) for (int x = 0; x < 64; x++) values[y * 64 + x] = reverseRedBlue(image.getPixelRGBA(x, y));
        return new Pixels(values, image.getHeight());
    }
    private static int reverseRedBlue(int rgba) { return (rgba & 0xFF00FF00) | ((rgba & 255) << 16) | ((rgba >>> 16) & 255); }

    // URL enviada por el servidor, revalidada en cliente, respuesta limitada y PNG verificado antes de decodificar.
    private static void request(String source) {
        if (PENDING.contains(source) || RETRY_AT.getOrDefault(source, 0L) > System.currentTimeMillis()) return;
        PENDING.add(source); long requestedGeneration = generation;
        try {
            DOWNLOADS.execute(() -> {
                Pixels loaded = null;
                try {
                    byte[] png = SkinHttp.get(URI.create(SkinSources.texture(source)), 131_072);
                    if (!SkinSources.playerPng(png)) throw new IllegalArgumentException("PNG de skin inválido");
                    try (NativeImage image = NativeImage.read(png)) { loaded = pixels(image); }
                } catch (java.io.IOException | InterruptedException | RuntimeException unavailable) { }
                Pixels completed = loaded;
                Minecraft.getInstance().execute(() -> {
                    if (generation != requestedGeneration) return;
                    PENDING.remove(source);
                    if (completed == null) RETRY_AT.put(source, System.currentTimeMillis() + 300_000);
                    else { SOURCES.put(source, completed); trimSources(); RETRY_AT.remove(source); }
                });
            });
        } catch (RejectedExecutionException full) { PENDING.remove(source); RETRY_AT.put(source, System.currentTimeMillis() + 60_000); }
    }
    private static void trimSources() { while (SOURCES.size() > 16) SOURCES.remove(SOURCES.keySet().iterator().next()); }
    @SubscribeEvent public static void logout(ClientPlayerNetworkEvent.LoggingOut event) {
        generation++; PENDING.clear(); RETRY_AT.clear(); SOURCES.clear();
        ATLASES.values().forEach(location -> Minecraft.getInstance().getTextureManager().release(location)); ATLASES.clear();
    }
}