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();
}
}