You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1453 lines
64 KiB
Java

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

package com.razz.dfashion.client.screen;
import com.razz.dfashion.block.ClosetBlockEntity;
import com.razz.dfashion.client.ClientSharedCosmeticCache;
import com.razz.dfashion.client.ClientSharedCosmeticUploader;
import com.razz.dfashion.client.ClientSkinCache;
import com.razz.dfashion.client.CosmeticCache;
import com.razz.dfashion.client.CosmeticRenderLayer;
import com.razz.dfashion.client.SkinInfoOverride;
import com.razz.dfashion.cosmetic.CosmeticAttachments;
import com.razz.dfashion.cosmetic.CosmeticDefinition;
import com.razz.dfashion.cosmetic.CosmeticRef;
import com.razz.dfashion.cosmetic.share.BoneExtraction;
import com.razz.dfashion.cosmetic.share.CosmeticLibrary;
import com.razz.dfashion.cosmetic.share.CosmeticLibraryEntry;
import com.razz.dfashion.cosmetic.share.packet.AssignSharedCosmetic;
import com.razz.dfashion.cosmetic.share.packet.DeleteCosmetic;
import com.razz.dfashion.skin.SkinAttachments;
import com.razz.dfashion.skin.SkinData;
import com.razz.dfashion.skin.SkinLibrary;
import com.razz.dfashion.skin.SkinLibraryEntry;
import com.razz.dfashion.skin.SkinModel;
import com.razz.dfashion.skin.packet.AssignSkin;
import com.razz.dfashion.skin.packet.DeleteSkin;
import com.razz.dfashion.skin.packet.RequestSkin;
import net.minecraft.core.ClientAsset;
import net.minecraft.world.entity.player.PlayerModelType;
import net.minecraft.world.entity.player.PlayerSkin;
import net.minecraft.client.Camera;
import net.minecraft.client.CameraType;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.components.StringWidget;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.player.LocalPlayer;
import net.minecraft.client.renderer.entity.EntityRenderDispatcher;
import net.minecraft.client.renderer.entity.EntityRenderer;
import net.minecraft.client.renderer.entity.state.AvatarRenderState;
import net.minecraft.client.renderer.entity.state.EntityRenderState;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.phys.Vec3;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.neoforge.client.event.ClientTickEvent;
import net.neoforged.neoforge.common.NeoForge;
import org.lwjgl.PointerBuffer;
import org.lwjgl.system.MemoryStack;
import org.lwjgl.util.tinyfd.TinyFileDialogs;
import org.jspecify.annotations.Nullable;
import org.joml.Matrix4f;
import org.joml.Quaternionf;
import org.joml.Vector3f;
import org.lwjgl.glfw.GLFW;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
public class ClosetScreen extends Screen {
// Tab vocabulary — matches category memory. "skin" is a pseudo-category that drives the
// per-player skin override UI (upload/reset) rather than the usual cosmetic row.
// "shared" is a pseudo-category for the shared-cosmetic library (vertical scroll grid).
private static final String[] CATEGORIES = {
"hat", "head", "torso", "arms", "legs", "wrist", "feet", "wings", "particle", "skin", "shared"
};
private static final String SKIN_TAB = "skin";
private static final String SHARED_TAB = "shared";
/** Fallback equip category used when a shared cosmetic has no canonical bones. */
private static final String FALLBACK_SHARED_CATEGORY = "particle";
/**
* Bone-based filters on the shared tab. {@code null} = "All"; others are canonical
* bone group names used by {@link BoneExtraction}. Labels are user-facing ({@code Torso}
* rather than the canonical {@code Body}).
*/
private static final String[] SHARED_FILTER_KEYS = { null, "Head", "Body", "Arm", "Leg" };
private static final String[] SHARED_FILTER_LABELS = { "All", "Head", "Torso", "Arms", "Legs" };
// Per-tick rotation rate while an arrow is held (20 tps → 120°/sec).
private static final float ROTATE_PER_TICK = 6f;
// Layout constants — tabs run horizontally across the top.
private static final int TAB_X_START = 10;
private static final int TAB_Y = 10;
private static final int TAB_W = 20;
private static final int TAB_H = 20;
private static final int TAB_SPACING = 2;
private static final int COSMETIC_ROW_LEFT = 10; // x where row starts (full-width now)
private static final int COSMETIC_ROW_BOTTOM_MARGIN = 82; // y from bottom — just above Done
private static final int COSMETIC_SIZE = 36;
private static final int COSMETIC_SPACING = 4;
private static final int COSMETIC_INNER_PADDING = 3;
private static final int COSMETIC_BORDER_COLOR = 0xFFFFFFFF; // normal frame around each preview
private static final int COSMETIC_BORDER_EQUIPPED_COLOR = 0xFF55FF55; // green frame when this item is currently worn
private static final int COSMETIC_BORDER_SHARED_COLOR = 0xFFFFB060; // amber frame — entry came from the user's shared library
private static final float COSMETIC_PREVIEW_SIZE = 11f; // entity render scale — small enough to fit cosmetics that extend the silhouette
private static final float COSMETIC_PREVIEW_YAW = -20f; // degrees — angled for better cosmetic visibility
private final @Nullable ClosetBlockEntity closet;
private CameraType previousCamera;
private @Nullable Button leftArrow, rightArrow;
// Body/head yaw applied every tick. yRot (camera-facing yaw) is left untouched so
// rotating doesn't pan the camera — only the player's visible body/head turn.
private float displayYaw = 0f;
private @Nullable String selectedCategory;
private int cosmeticScroll = 0;
private final List<Button> cosmeticButtons = new ArrayList<>();
private boolean showPreviews = true; // toggled by the bottom-right button
// Current skin-model selection on the skin tab. Drives the Upload button's model arg
// and the "Slim/Wide" toggle label. Seeded from the player's live SkinData on tab open.
private SkinModel pendingSkinModel = SkinModel.WIDE;
// Maps a skin-box button to its cached skin hash — needed so the preview renderer can
// look up which skin to show per box, and so the delete button knows what to remove.
private final Map<Button, String> skinButtonHashes = new HashMap<>();
// Last-known cached-skin count; when the cache grows (upload finishes + registers), the
// skin grid needs a rebuild. Checked each tick while the skin tab is active.
private int lastKnownSkinCount = -1;
// True once one-time setup (event-bus registration, initial displayYaw, etc.) has run.
// Guards against init() re-runs triggered by setScreen(this) after equip commands.
private boolean oneTimeInitDone = false;
// Edge-detect for left-mouse-down so we only fire player-zone navigation on press.
private boolean prevMouseLeftDown = false;
// Drag-to-scroll state for the cosmetic row.
private static final double DRAG_THRESHOLD_PX = 3;
private boolean pressStartedInRow = false;
private double dragAnchorX = 0;
private double dragLastX = 0;
private boolean isDraggingRow = false;
// ---- Share-cosmetic form state ----
// When showingShareForm is true, init() replaces the normal tabs/grid/buttons with a
// modal form. Widget refs are nullable because they're rebuilt on every widget refresh
// (Screen.rebuildWidgets clears children and re-runs init).
private boolean showingShareForm = false;
private @Nullable EditBox shareTextureField;
private @Nullable EditBox shareModelField;
private @Nullable EditBox shareNameField;
private @Nullable StringWidget shareStatusLabel;
private String shareStatusMessage = "";
/** UX cap on displayName shown in the field. Wire codec caps at 128; 64 is user-friendly. */
private static final int SHARE_NAME_MAX_LEN = 64;
/** OS path-length slack. Real paths rarely exceed 4 KB; inputs past this are suspect. */
private static final int SHARE_PATH_MAX_LEN = 4096;
// ---- Shared-library tab state ----
/** {@code null} = no filter (show all); else one of {@link #SHARED_FILTER_KEYS}. */
private @Nullable String sharedBoneFilter = null;
private int sharedVerticalScroll = 0;
private final Map<Button, String> sharedButtonHashes = new HashMap<>();
private int lastKnownSharedCount = -1;
/** Filter bar Y (below the tab row). */
private static final int SHARED_FILTER_Y = TAB_Y + TAB_H + 10;
private static final int SHARED_FILTER_H = 20;
private static final int SHARED_FILTER_BTN_W = 50;
private static final int SHARED_FILTER_BTN_GAP = 4;
/** Top of the vertical-scroll grid on the Shared tab (below the filter bar). */
private static final int SHARED_GRID_TOP = SHARED_FILTER_Y + SHARED_FILTER_H + 10;
/** Height reserved for bottom buttons; the grid stops above this. */
private static final int SHARED_GRID_BOTTOM_PAD = 50;
public ClosetScreen(@Nullable ClosetBlockEntity closet) {
super(Component.literal("Wardrobe"));
this.closet = closet;
}
@Override
protected void init() {
Minecraft mc = Minecraft.getInstance();
// One-time setup — guarded against re-init triggered by setScreen(this) after
// `sendUnattendedCommand` completes and MC re-runs init via rebuildWidgets().
if (!oneTimeInitDone) {
oneTimeInitDone = true;
previousCamera = mc.options.getCameraType();
mc.options.setCameraType(CameraType.THIRD_PERSON_FRONT);
if (closet != null) closet.setOpen(true);
LocalPlayer player = mc.player;
if (player != null) displayYaw = player.getYRot();
NeoForge.EVENT_BUS.register(this);
if (selectedCategory == null) selectedCategory = CATEGORIES[0];
// Catch any PNGs added to the cache directory since last client setup —
// e.g. user dropped files in manually, or the folder was just populated.
ClientSkinCache.scanDisk();
}
if (showingShareForm) {
buildShareForm();
return;
}
buildTabs();
buildBottomControls();
selectCategory(selectedCategory != null ? selectedCategory : CATEGORIES[0]);
}
private void buildTabs() {
int x = TAB_X_START;
for (String cat : CATEGORIES) {
String label = tabLabel(cat);
addRenderableWidget(Button.builder(
Component.literal(label),
b -> selectCategory(cat)
).bounds(x, TAB_Y, TAB_W, TAB_H).build());
x += TAB_W + TAB_SPACING;
}
}
/** Single-char tab labels; {@code shared} overridden to "L" so it doesn't collide with {@code skin}. */
private static String tabLabel(String cat) {
return switch (cat) {
case SHARED_TAB -> "L";
default -> cat.substring(0, 1).toUpperCase();
};
}
private void buildBottomControls() {
int centerX = this.width / 2;
int y = this.height - 40;
leftArrow = Button.builder(Component.literal("<"), b -> {})
.bounds(centerX - 90, y, 20, 20).build();
rightArrow = Button.builder(Component.literal(">"), b -> {})
.bounds(centerX + 70, y, 20, 20).build();
// Arrows render but don't receive events/focus — we drive them via GLFW polling
// in tick(), so they shouldn't get "stuck focused" by MC's widget system.
addRenderableOnly(leftArrow);
addRenderableWidget(Button.builder(
Component.literal("Done"),
b -> this.onClose()
).bounds(centerX - 40, y, 80, 20).build());
addRenderableOnly(rightArrow);
// Bottom-right: toggle the whole cosmetic row (boxes + 3D renders) on/off.
addRenderableWidget(Button.builder(
Component.literal("Toggle"),
b -> {
showPreviews = !showPreviews;
rebuildCosmeticRow(); // re-apply visibility to the cosmetic buttons
}
).bounds(this.width - 70, y, 60, 20).build());
// Open the shared-cosmetic upload form. All validation + parsing happens in the
// uploader's pipeline (SafePngReader + BbModelParser + BbmodelCodec); this button
// just swaps the closet UI into the form's widgets.
addRenderableWidget(Button.builder(
Component.literal("Share"),
b -> openShareForm()
).bounds(this.width - 140, y, 60, 20).build());
}
private void selectCategory(String cat) {
selectedCategory = cat;
cosmeticScroll = 0;
lastKnownSkinCount = -1; // force the next skin-tab tick to re-evaluate the grid
rebuildCosmeticRow();
}
private void rebuildCosmeticRow() {
for (Button btn : cosmeticButtons) removeWidget(btn);
cosmeticButtons.clear();
sharedButtonHashes.clear();
if (selectedCategory == null) return;
if (SKIN_TAB.equals(selectedCategory)) {
buildSkinTabRow();
return;
}
if (SHARED_TAB.equals(selectedCategory)) {
buildSharedTabRow();
return;
}
int x = COSMETIC_ROW_LEFT - cosmeticScroll;
int y = this.height - COSMETIC_ROW_BOTTOM_MARGIN;
// Authored cosmetics for this category.
for (Map.Entry<Identifier, CosmeticDefinition> entry : CosmeticCache.catalog.entrySet()) {
CosmeticDefinition def = entry.getValue();
if (!selectedCategory.equals(def.category())) continue;
Identifier id = entry.getKey();
// No text label — the mini-player preview + green-border-when-equipped already
// identify the cosmetic. Label text would render at a different stratum than
// the entity preview and poke through behind the player.
Button btn = Button.builder(
Component.empty(),
b -> equip(def.category(), id)
).bounds(x, y, COSMETIC_SIZE, COSMETIC_SIZE).build();
// Hide buttons that would intrude into the tab column or sit off-screen right,
// or when the user has toggled the whole preview row off.
btn.visible = showPreviews && (x >= COSMETIC_ROW_LEFT) && (x < this.width);
btn.active = btn.visible;
cosmeticButtons.add(btn);
addRenderableWidget(btn);
x += COSMETIC_SIZE + COSMETIC_SPACING;
}
// Unified browse: append shared-library entries whose bones map to this category.
// Same box size + stride as authored — they scroll together in the same horizontal
// row. Visual distinguisher is a different border color in the preview renderer.
LocalPlayer player = Minecraft.getInstance().player;
CosmeticLibrary lib = player != null
? player.getData(CosmeticAttachments.SHARED_LIBRARY.get())
: CosmeticLibrary.EMPTY;
for (CosmeticLibraryEntry libEntry : lib.entries()) {
if (!BoneExtraction.categoriesFor(libEntry.bones()).contains(selectedCategory)) continue;
// Pull from server if the blob hasn't materialized locally yet.
ClientSharedCosmeticCache.requestIfMissing(libEntry.hash());
Button btn = Button.builder(
Component.empty(),
b -> equipShared(libEntry)
).bounds(x, y, COSMETIC_SIZE, COSMETIC_SIZE).build();
btn.visible = showPreviews && (x >= COSMETIC_ROW_LEFT) && (x < this.width);
btn.active = btn.visible;
cosmeticButtons.add(btn);
addRenderableWidget(btn);
sharedButtonHashes.put(btn, libEntry.hash());
x += COSMETIC_SIZE + COSMETIC_SPACING;
}
}
// Skin tab layout — action buttons stack vertically on the left, grid runs right.
private static final int SKIN_ACTION_W = 90;
private static final int SKIN_ACTION_H = 20;
private static final int SKIN_ACTION_GAP = 4;
private static final int SKIN_GRID_LEFT_PAD = 8;
private static final int SKIN_GRID_START_X =
COSMETIC_ROW_LEFT + SKIN_ACTION_W + SKIN_GRID_LEFT_PAD;
/**
* Skin tab layout — Upload / Slim-Wide toggle / Reset stacked vertically on the left,
* catalogue-shaped grid of cached skins to the right. Grid boxes are the same size as
* cosmetic boxes so the 3D preview path can be reused. Each box carries a tiny 'x'
* above it for per-skin delete.
*/
private void buildSkinTabRow() {
skinButtonHashes.clear();
// Seed toggle from whatever the player currently has on; otherwise remember last choice.
LocalPlayer player = Minecraft.getInstance().player;
if (player != null) {
SkinData current = player.getData(SkinAttachments.DATA.get());
if (current != null && !current.isEmpty()) pendingSkinModel = current.model();
}
int gridY = this.height - COSMETIC_ROW_BOTTOM_MARGIN;
// Center the 3-button action column vertically against the 36-tall grid row.
int columnHeight = SKIN_ACTION_H * 3 + SKIN_ACTION_GAP * 2; // 68
int actionYTop = gridY + (COSMETIC_SIZE - columnHeight) / 2; // gridY - 16
Button upload = Button.builder(
Component.literal("Upload Skin"),
b -> pickAndUpload(pendingSkinModel)
).bounds(COSMETIC_ROW_LEFT, actionYTop, SKIN_ACTION_W, SKIN_ACTION_H).build();
addRenderableWidget(upload);
cosmeticButtons.add(upload);
Button toggle = Button.builder(
Component.literal(pendingSkinModel == SkinModel.SLIM ? "Slim" : "Wide"),
b -> flipSkinModel()
).bounds(COSMETIC_ROW_LEFT, actionYTop + SKIN_ACTION_H + SKIN_ACTION_GAP,
SKIN_ACTION_W, SKIN_ACTION_H).build();
addRenderableWidget(toggle);
cosmeticButtons.add(toggle);
Button reset = Button.builder(
Component.literal("Reset"),
b -> {
LocalPlayer local = Minecraft.getInstance().player;
if (local != null) local.setData(SkinAttachments.DATA.get(), SkinData.EMPTY);
ClientSkinCache.sendToServer(new AssignSkin("", SkinModel.WIDE));
SkinInfoOverride.syncAll();
}
).bounds(COSMETIC_ROW_LEFT, actionYTop + (SKIN_ACTION_H + SKIN_ACTION_GAP) * 2,
SKIN_ACTION_W, SKIN_ACTION_H).build();
addRenderableWidget(reset);
cosmeticButtons.add(reset);
// Grid row starts past the action column. Boxes honor the Toggle button; action
// column above stays visible either way. Source of truth is the per-player server
// library attachment — not the local blob cache — so other players' uploads never
// appear here even when their blobs happen to be on disk.
int boxX = SKIN_GRID_START_X - cosmeticScroll;
LocalPlayer localPlayer = Minecraft.getInstance().player;
SkinLibrary library = localPlayer != null
? localPlayer.getData(SkinAttachments.LIBRARY.get())
: SkinLibrary.EMPTY;
for (SkinLibraryEntry libEntry : library.entries()) {
final String hash = libEntry.hash();
// Pull bytes from the server if this client hasn't seen them yet.
if (ClientSkinCache.ensureLoadedFromDisk(hash) == null
&& !ClientSkinCache.isAwaiting(hash)) {
ClientSkinCache.markRequested(hash);
ClientSkinCache.sendToServer(new RequestSkin(hash));
}
boolean onScreen = (boxX >= SKIN_GRID_START_X) && (boxX < this.width);
boolean show = showPreviews && onScreen;
Button pick = Button.builder(
Component.empty(),
b -> pickCachedSkin(hash)
).bounds(boxX, gridY, COSMETIC_SIZE, COSMETIC_SIZE).build();
pick.visible = show;
pick.active = show;
cosmeticButtons.add(pick);
addRenderableWidget(pick);
skinButtonHashes.put(pick, hash);
// Placed just ABOVE the pick box so it doesn't overlap — MC dispatches mouse
// events to widgets in insertion order, and `pick` (added first, full 36x36)
// would otherwise consume clicks before `del` could see them.
Button del = Button.builder(
Component.literal("x"),
b -> deleteCachedSkin(hash)
).bounds(boxX + COSMETIC_SIZE - 12, gridY - 12, 12, 12).build();
del.visible = show;
del.active = show;
cosmeticButtons.add(del);
addRenderableWidget(del);
boxX += COSMETIC_SIZE + COSMETIC_SPACING;
}
}
// ---- Shared-library tab (vertical scroll grid) ----
/**
* Shared tab layout: horizontal filter row at top (All / Head / Torso / Arms / Legs),
* wrapping grid below using full width. Clicking a filter narrows the grid to entries
* whose bone set includes the filter's bone group. Entries with only {@link
* BoneExtraction#OTHER} bones show only under "All".
*/
private void buildSharedTabRow() {
sharedButtonHashes.clear();
// Filter bar — one button per filter option; active filter is dimmed so the player
// can tell which one is applied without hover state.
int fx = COSMETIC_ROW_LEFT;
for (int i = 0; i < SHARED_FILTER_KEYS.length; i++) {
final String key = SHARED_FILTER_KEYS[i];
Button filterBtn = Button.builder(
Component.literal(SHARED_FILTER_LABELS[i]),
b -> setSharedBoneFilter(key)
).bounds(fx, SHARED_FILTER_Y, SHARED_FILTER_BTN_W, SHARED_FILTER_H).build();
filterBtn.active = !java.util.Objects.equals(sharedBoneFilter, key);
addRenderableWidget(filterBtn);
cosmeticButtons.add(filterBtn);
fx += SHARED_FILTER_BTN_W + SHARED_FILTER_BTN_GAP;
}
// Grid area — full-width now that the slot selector is gone.
int gridY = SHARED_GRID_TOP;
int gridLeft = COSMETIC_ROW_LEFT;
int gridRight = this.width - 10;
int gridBottom = this.height - SHARED_GRID_BOTTOM_PAD;
int stride = COSMETIC_SIZE + COSMETIC_SPACING;
int boxesPerRow = Math.max(1, (gridRight - gridLeft + COSMETIC_SPACING) / stride);
LocalPlayer player = Minecraft.getInstance().player;
CosmeticLibrary lib = player != null
? player.getData(CosmeticAttachments.SHARED_LIBRARY.get())
: CosmeticLibrary.EMPTY;
int idx = 0;
for (CosmeticLibraryEntry libEntry : lib.entries()) {
if (!entryMatchesSharedFilter(libEntry)) continue;
String hash = libEntry.hash();
// Pull from server if the blob hasn't materialized locally yet.
ClientSharedCosmeticCache.requestIfMissing(hash);
int row = idx / boxesPerRow;
int col = idx % boxesPerRow;
int boxX = gridLeft + col * stride;
int boxY = gridY + row * stride - sharedVerticalScroll;
idx++;
boolean onScreen = boxY + COSMETIC_SIZE > gridY && boxY < gridBottom;
boolean show = showPreviews && onScreen;
Button pick = Button.builder(
Component.empty(),
b -> equipShared(libEntry)
).bounds(boxX, boxY, COSMETIC_SIZE, COSMETIC_SIZE).build();
pick.visible = show;
pick.active = show;
cosmeticButtons.add(pick);
addRenderableWidget(pick);
sharedButtonHashes.put(pick, hash);
Button del = Button.builder(
Component.literal("x"),
b -> deleteShared(hash)
).bounds(boxX + COSMETIC_SIZE - 12, boxY - 12, 12, 12).build();
del.visible = show;
del.active = show;
cosmeticButtons.add(del);
addRenderableWidget(del);
}
}
private void setSharedBoneFilter(@Nullable String filter) {
sharedBoneFilter = filter;
sharedVerticalScroll = 0;
rebuildCosmeticRow();
}
/**
* Does an entry's bone set include the currently-selected filter's group? Maps
* filter keys to canonical bones: {@code "Head"} → just {@code Head};
* {@code "Body"} → {@code Body}; {@code "Arm"} → either left/right arm;
* {@code "Leg"} → either left/right leg. {@code null} filter always matches.
*/
private boolean entryMatchesSharedFilter(CosmeticLibraryEntry entry) {
if (sharedBoneFilter == null) return true;
List<String> bones = entry.bones();
return switch (sharedBoneFilter) {
case "Head" -> bones.contains(BoneExtraction.HEAD);
case "Body" -> bones.contains(BoneExtraction.BODY);
case "Arm" -> bones.contains(BoneExtraction.LEFT_ARM) || bones.contains(BoneExtraction.RIGHT_ARM);
case "Leg" -> bones.contains(BoneExtraction.LEFT_LEG) || bones.contains(BoneExtraction.RIGHT_LEG);
default -> false;
};
}
/**
* Derive the equip category from the cosmetic's primary bone and toggle equip in
* that slot — matches the authored path's click-to-unequip behavior. An empty hash
* on the wire tells the server to clear the slot (see
* {@link com.razz.dfashion.cosmetic.share.CosmeticShareNetwork#onAssignShared}).
*/
private void equipShared(CosmeticLibraryEntry entry) {
String category = BoneExtraction.primaryCategory(entry.bones());
if (category == null) category = FALLBACK_SHARED_CATEGORY;
LocalPlayer player = Minecraft.getInstance().player;
Map<String, CosmeticRef> currentEquipped = player != null
? player.getData(CosmeticAttachments.EQUIPPED.get())
: Map.of();
CosmeticRef current = currentEquipped.get(category);
boolean alreadyEquipped = current instanceof CosmeticRef.Shared s
&& entry.hash().equals(s.hash());
String hashOnWire = alreadyEquipped ? "" : entry.hash();
ClientSharedCosmeticCache.sendToServer(new AssignSharedCosmetic(category, hashOnWire));
}
/**
* Look up the library entry for a given content hash. O(N) over library entries —
* fine since {@link CosmeticLibrary#MAX_ENTRIES} caps this at a small number.
*/
private @Nullable CosmeticLibraryEntry findSharedEntry(String hash) {
LocalPlayer player = Minecraft.getInstance().player;
if (player == null) return null;
CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get());
for (CosmeticLibraryEntry e : lib.entries()) {
if (e.hash().equals(hash)) return e;
}
return null;
}
/** Same derivation used by {@link #equipShared}, looked up by hash — for preview rendering. */
private String sharedCategoryForHash(String hash) {
CosmeticLibraryEntry entry = findSharedEntry(hash);
if (entry == null) return FALLBACK_SHARED_CATEGORY;
String cat = BoneExtraction.primaryCategory(entry.bones());
return cat != null ? cat : FALLBACK_SHARED_CATEGORY;
}
private void deleteShared(String hash) {
// Server drops the hash from this player's library; the local call releases the
// baked model + texture and deletes the local blob cache file.
ClientSharedCosmeticCache.sendToServer(new DeleteCosmetic(hash));
ClientSharedCosmeticCache.delete(hash);
rebuildCosmeticRow();
}
/** Total vertical extent of the shared grid, counting only entries that pass the filter. */
private int sharedGridContentHeight() {
LocalPlayer player = Minecraft.getInstance().player;
if (player == null) return 0;
CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get());
int stride = COSMETIC_SIZE + COSMETIC_SPACING;
int gridLeft = COSMETIC_ROW_LEFT;
int gridRight = this.width - 10;
int boxesPerRow = Math.max(1, (gridRight - gridLeft + COSMETIC_SPACING) / stride);
int visible = 0;
for (CosmeticLibraryEntry e : lib.entries()) {
if (entryMatchesSharedFilter(e)) visible++;
}
int rows = (visible + boxesPerRow - 1) / boxesPerRow;
return rows * stride;
}
private void clampSharedScroll() {
int viewport = (this.height - SHARED_GRID_BOTTOM_PAD) - SHARED_GRID_TOP;
int max = Math.max(0, sharedGridContentHeight() - viewport);
if (sharedVerticalScroll < 0) sharedVerticalScroll = 0;
if (sharedVerticalScroll > max) sharedVerticalScroll = max;
}
private static String titleCaseAscii(String s) {
if (s.isEmpty()) return s;
return Character.toUpperCase(s.charAt(0)) + s.substring(1);
}
/**
* Apply a cached skin locally + tell the server. Also syncs {@link SkinInfoOverride}
* immediately so the dispatcher picks the right renderer this frame, not next tick.
*/
private void pickCachedSkin(String hash) {
SkinData updated = new SkinData(hash, pendingSkinModel);
LocalPlayer player = Minecraft.getInstance().player;
if (player != null) player.setData(SkinAttachments.DATA.get(), updated);
ClientSkinCache.sendToServer(new AssignSkin(hash, pendingSkinModel));
SkinInfoOverride.syncAll();
}
private void deleteCachedSkin(String hash) {
// Tell the server to drop it from this player's library (and reset their active
// SkinData if they were wearing it). The server broadcasts nothing to other
// players beyond the SkinData reset — other players' libraries aren't touched.
ClientSkinCache.sendToServer(new DeleteSkin(hash));
ClientSkinCache.delete(hash);
LocalPlayer player = Minecraft.getInstance().player;
if (player != null) {
SkinLibrary lib = player.getData(SkinAttachments.LIBRARY.get());
player.setData(SkinAttachments.LIBRARY.get(), lib.remove(hash));
SkinData current = player.getData(SkinAttachments.DATA.get());
if (current != null && hash.equals(current.hash())) {
player.setData(SkinAttachments.DATA.get(), SkinData.EMPTY);
SkinInfoOverride.syncAll();
}
}
lastKnownSkinCount = -1;
rebuildCosmeticRow();
}
/**
* Flip the local toggle. If the player already has a skin equipped, tell the server to
* re-apply with the new model so it syncs to others — AND set the local attachment
* immediately so the live player render picks up the new model this frame, instead of
* waiting on the server round-trip.
*/
private void flipSkinModel() {
pendingSkinModel = pendingSkinModel == SkinModel.SLIM ? SkinModel.WIDE : SkinModel.SLIM;
LocalPlayer player = Minecraft.getInstance().player;
if (player != null) {
SkinData current = player.getData(SkinAttachments.DATA.get());
if (current != null && !current.isEmpty()) {
SkinData updated = new SkinData(current.hash(), pendingSkinModel);
player.setData(SkinAttachments.DATA.get(), updated);
ClientSkinCache.sendToServer(new AssignSkin(current.hash(), pendingSkinModel));
SkinInfoOverride.syncAll();
}
}
rebuildCosmeticRow(); // refresh toggle label
}
/** Open the OS file picker for a PNG, upload what the user chose. */
private void pickAndUpload(SkinModel model) {
try (MemoryStack stack = MemoryStack.stackPush()) {
PointerBuffer filters = stack.mallocPointer(1);
filters.put(stack.UTF8("*.png"));
filters.flip();
String path = TinyFileDialogs.tinyfd_openFileDialog(
"Select skin PNG", "", filters, "PNG images", false);
if (path == null || path.isEmpty()) return;
ClientSkinCache.uploadFromFile(java.nio.file.Paths.get(path), model);
}
}
// ---- Shared-cosmetic upload form ----
private void openShareForm() {
showingShareForm = true;
rebuildWidgets();
}
private void closeShareForm() {
showingShareForm = false;
shareTextureField = null;
shareModelField = null;
shareNameField = null;
shareStatusLabel = null;
shareStatusMessage = "";
rebuildWidgets();
}
/**
* Build the modal form. EditBox contents are plain user text — every byte is re-validated
* by {@link #submitShareForm} before anything reaches the uploader, and the uploader
* itself does the real sanitization (file-size gates, {@code SafePngReader},
* {@code BbModelParser}, {@code BbmodelCodec}). This UI layer just provides friendly
* early rejection so the user sees a clear error before the upload attempt.
*/
private void buildShareForm() {
int panelW = Math.min(360, this.width - 40);
int cx = this.width / 2;
int x0 = cx - panelW / 2;
int fieldW = panelW - 20;
int fieldH = 20;
int rowGap = 10;
int y = this.height / 2 - 90;
StringWidget title = new StringWidget(
x0, y, panelW, 15,
Component.literal("Share Cosmetic"),
this.font
);
addRenderableWidget(title);
y += 22;
// Texture path row: EditBox + Browse.
int browseW = 60;
shareTextureField = new EditBox(this.font, x0 + 10, y, fieldW - browseW - 5, fieldH,
Component.literal("Texture path"));
shareTextureField.setMaxLength(SHARE_PATH_MAX_LEN);
shareTextureField.setHint(Component.literal("Path to .png"));
addRenderableWidget(shareTextureField);
addRenderableWidget(Button.builder(
Component.literal("Browse"),
b -> pickShareTexturePath()
).bounds(x0 + 10 + fieldW - browseW, y, browseW, fieldH).build());
y += fieldH + rowGap;
// Model path row: EditBox + Browse.
shareModelField = new EditBox(this.font, x0 + 10, y, fieldW - browseW - 5, fieldH,
Component.literal("Model path"));
shareModelField.setMaxLength(SHARE_PATH_MAX_LEN);
shareModelField.setHint(Component.literal("Path to .bbmodel"));
addRenderableWidget(shareModelField);
addRenderableWidget(Button.builder(
Component.literal("Browse"),
b -> pickShareModelPath()
).bounds(x0 + 10 + fieldW - browseW, y, browseW, fieldH).build());
y += fieldH + rowGap;
// Name (optional).
shareNameField = new EditBox(this.font, x0 + 10, y, fieldW, fieldH,
Component.literal("Display name"));
shareNameField.setMaxLength(SHARE_NAME_MAX_LEN);
shareNameField.setHint(Component.literal("Name (optional — defaults to texture filename)"));
addRenderableWidget(shareNameField);
y += fieldH + rowGap;
// Status line (updated after submit).
shareStatusLabel = new StringWidget(
x0 + 10, y, fieldW, 15,
Component.literal(shareStatusMessage),
this.font
);
addRenderableWidget(shareStatusLabel);
y += 22;
// Submit / Cancel.
int btnW = (fieldW - 10) / 2;
addRenderableWidget(Button.builder(
Component.literal("Submit"),
b -> submitShareForm()
).bounds(x0 + 10, y, btnW, fieldH).build());
addRenderableWidget(Button.builder(
Component.literal("Cancel"),
b -> closeShareForm()
).bounds(x0 + 10 + btnW + 10, y, btnW, fieldH).build());
}
private void pickShareTexturePath() {
try (MemoryStack stack = MemoryStack.stackPush()) {
PointerBuffer filters = stack.mallocPointer(1);
filters.put(stack.UTF8("*.png"));
filters.flip();
String path = TinyFileDialogs.tinyfd_openFileDialog(
"Select texture PNG", "", filters, "PNG images", false);
if (path != null && !path.isEmpty() && shareTextureField != null) {
shareTextureField.setValue(path);
}
}
}
private void pickShareModelPath() {
try (MemoryStack stack = MemoryStack.stackPush()) {
PointerBuffer filters = stack.mallocPointer(1);
filters.put(stack.UTF8("*.bbmodel"));
filters.flip();
String path = TinyFileDialogs.tinyfd_openFileDialog(
"Select .bbmodel", "", filters, "bbmodel files", false);
if (path != null && !path.isEmpty() && shareModelField != null) {
shareModelField.setValue(path);
}
}
}
/**
* Sanitize every field, reject obvious bad input early with clear messages, and then
* hand off to {@link ClientSharedCosmeticUploader} which runs the actual security
* pipeline (file-size gates, {@code SafePngReader}, {@code BbModelParser},
* {@code BbmodelCodec}, library-dedup check, chunking).
*/
private void submitShareForm() {
if (shareTextureField == null || shareModelField == null || shareNameField == null) return;
String texturePath = sanitizeSharePath(shareTextureField.getValue());
String modelPath = sanitizeSharePath(shareModelField.getValue());
String name = sanitizeShareName(shareNameField.getValue());
if (texturePath.isEmpty()) { setShareStatus("Texture path required"); return; }
if (modelPath.isEmpty()) { setShareStatus("Model path required"); return; }
Path texture;
Path model;
try {
texture = Paths.get(texturePath);
model = Paths.get(modelPath);
} catch (InvalidPathException ex) {
setShareStatus("Invalid path: " + ex.getMessage());
return;
}
if (!Files.isRegularFile(texture)) { setShareStatus("Texture file not found"); return; }
if (!Files.isRegularFile(model)) { setShareStatus("Model file not found"); return; }
String texLower = texturePath.toLowerCase(Locale.ROOT);
String mdlLower = modelPath.toLowerCase(Locale.ROOT);
if (!texLower.endsWith(".png")) { setShareStatus("Texture must be .png"); return; }
if (!mdlLower.endsWith(".bbmodel")) { setShareStatus("Model must be .bbmodel"); return; }
if (name.isEmpty()) name = titleCaseFilenameStem(texture.getFileName().toString());
String result = ClientSharedCosmeticUploader.upload(model, texture, name);
setShareStatus(result);
// Clear the fields on successful submission so a second upload starts fresh.
// The uploader's status string is the only signal of outcome — "Uploading " means
// chunks went to the server, "Already in library" means dedup short-circuited
// (still a valid state, nothing to retry). Failures keep the fields populated so
// the user can correct and resubmit without re-entering paths.
if (result != null
&& (result.startsWith("Uploading ") || result.startsWith("Already in library"))) {
shareTextureField.setValue("");
shareModelField.setValue("");
shareNameField.setValue("");
}
}
private void setShareStatus(String msg) {
shareStatusMessage = msg == null ? "" : msg;
if (shareStatusLabel != null) shareStatusLabel.setMessage(Component.literal(shareStatusMessage));
}
/** Trim, strip matched wrapping quotes, reject null bytes and absurdly long inputs. */
private static String sanitizeSharePath(String raw) {
if (raw == null) return "";
String s = raw.trim();
if (s.length() >= 2
&& ((s.charAt(0) == '"' && s.charAt(s.length() - 1) == '"')
|| (s.charAt(0) == '\'' && s.charAt(s.length() - 1) == '\''))) {
s = s.substring(1, s.length() - 1).trim();
}
if (s.indexOf('\0') >= 0) return "";
if (s.length() > SHARE_PATH_MAX_LEN) return "";
return s;
}
/** Trim, drop control characters, cap length. Null byte / DEL / C0 all stripped. */
private static String sanitizeShareName(String raw) {
if (raw == null) return "";
String s = raw.trim();
StringBuilder sb = new StringBuilder(s.length());
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c >= 0x20 && c != 0x7F) sb.append(c);
}
String out = sb.toString();
if (out.length() > SHARE_NAME_MAX_LEN) out = out.substring(0, SHARE_NAME_MAX_LEN);
return out;
}
/** "red_hat.png" → "Red Hat". Used when the user leaves the name field blank. */
private static String titleCaseFilenameStem(String filename) {
int dot = filename.lastIndexOf('.');
String stem = dot < 0 ? filename : filename.substring(0, dot);
StringBuilder out = new StringBuilder(stem.length());
boolean capitalize = true;
for (int i = 0; i < stem.length(); i++) {
char c = stem.charAt(i);
if (c == '_' || c == '-' || c == ' ') {
out.append(' ');
capitalize = true;
} else if (capitalize) {
out.append(Character.toUpperCase(c));
capitalize = false;
} else {
out.append(c);
}
}
return out.toString();
}
private void equip(String category, Identifier cosmeticId) {
Minecraft mc = Minecraft.getInstance();
LocalPlayer player = mc.player;
if (player == null || player.connection == null) return;
// Toggle: if this cosmetic is already equipped in that category, unequip.
Map<String, com.razz.dfashion.cosmetic.CosmeticRef> currentEquipped =
player.getData(CosmeticAttachments.EQUIPPED.get());
com.razz.dfashion.cosmetic.CosmeticRef current = currentEquipped.get(category);
boolean alreadyEquipped = current instanceof com.razz.dfashion.cosmetic.CosmeticRef.Local l
&& l.id().equals(cosmeticId);
String cmd = alreadyEquipped
? "decofashion unequip " + category
: "decofashion equip " + category + " " + cosmeticId;
player.connection.sendUnattendedCommand(cmd, this);
}
private void scrollToCosmetic(String category, Identifier cosmeticId) {
int idx = 0;
for (Map.Entry<Identifier, CosmeticDefinition> entry : CosmeticCache.catalog.entrySet()) {
if (!category.equals(entry.getValue().category())) continue;
if (entry.getKey().equals(cosmeticId)) {
int targetX = idx * (COSMETIC_SIZE + COSMETIC_SPACING);
cosmeticScroll = targetX; // place the match at the leftmost visible slot
rebuildCosmeticRow();
return;
}
idx++;
}
}
@Override
public boolean mouseScrolled(double mx, double my, double deltaX, double deltaY) {
// Shared tab uses the full right-side region as a vertical scroll area.
if (SHARED_TAB.equals(selectedCategory)
&& my >= SHARED_GRID_TOP
&& my <= this.height - SHARED_GRID_BOTTOM_PAD
&& mx >= SKIN_GRID_START_X) {
sharedVerticalScroll -= (int) (deltaY * 20);
clampSharedScroll();
rebuildCosmeticRow();
return true;
}
if (my > this.height - COSMETIC_ROW_BOTTOM_MARGIN - 10
&& my < this.height - COSMETIC_ROW_BOTTOM_MARGIN + COSMETIC_SIZE + 10) {
cosmeticScroll -= (int) (deltaY * 20);
clampScroll();
rebuildCosmeticRow();
return true;
}
return super.mouseScrolled(mx, my, deltaX, deltaY);
}
@Override
public void tick() {
super.tick();
Minecraft mc = Minecraft.getInstance();
LocalPlayer player = mc.player;
if (player == null) return;
// When on the skin tab, rebuild the grid when the cached-skin count changes — this
// covers uploads (which add a new entry asynchronously after the server echo arrives)
// and any new skin received from another player.
if (SKIN_TAB.equals(selectedCategory)) {
int count = player.getData(SkinAttachments.LIBRARY.get()).entries().size();
if (count != lastKnownSkinCount) {
lastKnownSkinCount = count;
rebuildCosmeticRow();
}
}
if (SHARED_TAB.equals(selectedCategory)) {
int count = player.getData(CosmeticAttachments.SHARED_LIBRARY.get()).entries().size();
if (count != lastKnownSharedCount) {
lastKnownSharedCount = count;
clampSharedScroll();
rebuildCosmeticRow();
}
}
long handle = mc.getWindow().handle();
boolean leftDown = GLFW.glfwGetMouseButton(handle, GLFW.GLFW_MOUSE_BUTTON_LEFT) == GLFW.GLFW_PRESS;
double scale = mc.getWindow().getGuiScale();
double mx = mc.mouseHandler.xpos() / scale;
double my = mc.mouseHandler.ypos() / scale;
if (leftDown) {
if (leftArrow != null && leftArrow.isMouseOver(mx, my)) {
displayYaw -= ROTATE_PER_TICK;
} else if (rightArrow != null && rightArrow.isMouseOver(mx, my)) {
displayYaw += ROTATE_PER_TICK;
}
}
// Mouse-press edge: decide whether this press starts a drag on the cosmetic row,
// hits a widget, or lands on one of the player's equipped cosmetics.
if (leftDown && !prevMouseLeftDown) {
if (isInCosmeticRow(mx, my)) {
pressStartedInRow = true;
dragAnchorX = mx;
dragLastX = mx;
isDraggingRow = false;
} else {
pressStartedInRow = false;
if (!isOverAnyWidget(mx, my)) {
String hit = findClickedEquippedCategory(player, mx, my);
if (hit != null) {
selectCategory(hit);
com.razz.dfashion.cosmetic.CosmeticRef ref =
player.getData(CosmeticAttachments.EQUIPPED.get()).get(hit);
if (ref instanceof com.razz.dfashion.cosmetic.CosmeticRef.Local l) {
scrollToCosmetic(hit, l.id());
}
}
}
}
}
// While held inside the row: promote to drag once the anchor-delta clears threshold.
if (leftDown && pressStartedInRow) {
if (!isDraggingRow && Math.abs(mx - dragAnchorX) > DRAG_THRESHOLD_PX) {
isDraggingRow = true;
}
if (isDraggingRow) {
cosmeticScroll -= (int) Math.round(mx - dragLastX);
clampScroll();
dragLastX = mx;
rebuildCosmeticRow();
}
}
if (!leftDown) {
pressStartedInRow = false;
isDraggingRow = false;
}
prevMouseLeftDown = leftDown;
}
private boolean isInCosmeticRow(double mx, double my) {
int y0 = this.height - COSMETIC_ROW_BOTTOM_MARGIN;
int y1 = y0 + COSMETIC_SIZE;
// On the skin tab the drag-scrollable area is to the right of the action column;
// presses on the stacked Upload/Slim/Reset buttons must not initiate a grid drag.
int left = SKIN_TAB.equals(selectedCategory) ? SKIN_GRID_START_X : COSMETIC_ROW_LEFT;
return my >= y0 && my <= y1 && mx >= left;
}
private boolean isOverAnyWidget(double mx, double my) {
for (var child : this.children()) {
if (child instanceof Button b && b.isMouseOver(mx, my)) return true;
}
// Arrows are render-only (outside the children list) but still shouldn't trigger
// player-zone clicks when pressed.
if (leftArrow != null && leftArrow.isMouseOver(mx, my)) return true;
if (rightArrow != null && rightArrow.isMouseOver(mx, my)) return true;
return false;
}
// Maximum screen-pixel distance between a click and a cosmetic's projected anchor.
private static final double PLAYER_CLICK_THRESHOLD_PX = 35.0;
/**
* World→screen project each equipped cosmetic's bone anchor and pick the one closest
* to the click. Returns {@code null} if no equipped cosmetic's projected point is
* within the threshold.
*
* Uses the camera's own {@code viewRotation × projection} matrix (so FOV, zoom,
* underwater tweaks etc. all match the actually-rendered view). Points must be in
* camera-local space (subtract {@code camera.position()}).
*/
private @Nullable String findClickedEquippedCategory(LocalPlayer player, double clickX, double clickY) {
Map<String, com.razz.dfashion.cosmetic.CosmeticRef> equipped =
player.getData(CosmeticAttachments.EQUIPPED.get());
if (equipped.isEmpty()) return null;
Camera camera = Minecraft.getInstance().gameRenderer.getMainCamera();
if (!camera.isInitialized()) return null;
Matrix4f viewProj = new Matrix4f();
camera.getViewRotationProjectionMatrix(viewProj);
Vec3 camPos = camera.position();
String best = null;
double bestDistSq = PLAYER_CLICK_THRESHOLD_PX * PLAYER_CLICK_THRESHOLD_PX;
for (Map.Entry<String, com.razz.dfashion.cosmetic.CosmeticRef> entry : equipped.entrySet()) {
String category = entry.getKey();
Vec3 bonePos = approximateBoneWorldPos(player, category);
// Camera-local coords; apply the view-rot × projection matrix.
Vector3f screen = new Vector3f(
(float) (bonePos.x - camPos.x),
(float) (bonePos.y - camPos.y),
(float) (bonePos.z - camPos.z));
viewProj.transformProject(screen);
// transformProject yields NDC [-1..1]. z outside = behind camera / past far plane.
if (screen.z < -1f || screen.z > 1f) continue;
double sx = (screen.x + 1.0) * 0.5 * this.width;
double sy = (1.0 - screen.y) * 0.5 * this.height; // flip Y (screen is y-down)
double dx = clickX - sx;
double dy = clickY - sy;
double distSq = dx * dx + dy * dy;
if (distSq < bestDistSq) {
bestDistSq = distSq;
best = category;
}
}
return best;
}
/**
* Anchor points for each category on a standing player (relative to player.position(),
* which is at the feet). Approximations — tuned for vanilla player proportions.
*/
private static Vec3 approximateBoneWorldPos(LocalPlayer player, String category) {
Vec3 base = player.position();
double dy = switch (category) {
case "hat", "head" -> 1.65; // top of head
case "torso", "arms", "wrist"-> 1.15; // chest height
case "wings" -> 1.00;
case "legs" -> 0.55;
case "feet" -> 0.10;
case "particle" -> 1.10;
default -> 1.00;
};
return base.add(0, dy, 0);
}
private void clampScroll() {
int count;
int rowLeft;
if (SKIN_TAB.equals(selectedCategory)) {
// Skin grid: rows come from the server-authoritative player library (not the
// client's content-addressed blob pool), so only this player's 5 slots count.
LocalPlayer p = Minecraft.getInstance().player;
count = p != null ? p.getData(SkinAttachments.LIBRARY.get()).entries().size() : 0;
rowLeft = SKIN_GRID_START_X;
} else {
count = 0;
if (selectedCategory != null) {
for (Map.Entry<Identifier, CosmeticDefinition> entry : CosmeticCache.catalog.entrySet()) {
if (selectedCategory.equals(entry.getValue().category())) count++;
}
}
rowLeft = COSMETIC_ROW_LEFT;
}
int totalWidth = count * (COSMETIC_SIZE + COSMETIC_SPACING);
int visibleWidth = Math.max(0, this.width - rowLeft - 10);
int maxScroll = Math.max(0, totalWidth - visibleWidth);
cosmeticScroll = Math.max(0, Math.min(cosmeticScroll, maxScroll));
}
@SubscribeEvent
public void onClientTickPost(ClientTickEvent.Post event) {
LocalPlayer player = Minecraft.getInstance().player;
if (player == null) return;
player.setYBodyRot(displayYaw);
player.setYHeadRot(displayYaw);
player.yBodyRotO = displayYaw;
player.yHeadRotO = displayYaw;
}
@Override
public boolean isPauseScreen() {
return false;
}
@Override
public void extractBackground(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
// transparent — keep the 3D world visible behind the UI
}
@Override
public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
// Clear previous frame's preview overrides so they don't accumulate. Last frame's
// entries have already been consumed by the deferred render pass before we get here.
CosmeticRenderLayer.RENDER_OVERRIDES.clear();
super.extractRenderState(graphics, mouseX, mouseY, partialTick);
renderCosmeticPreviews(graphics);
}
private void renderCosmeticPreviews(GuiGraphicsExtractor graphics) {
// Toggle off → render nothing (no frames, no entities). Buttons are already
// hidden + inactive (handled in rebuildCosmeticRow).
if (!showPreviews) return;
Minecraft mc = Minecraft.getInstance();
LocalPlayer player = mc.player;
if (player == null || selectedCategory == null || cosmeticButtons.isEmpty()) return;
if (SKIN_TAB.equals(selectedCategory)) {
renderSkinPreviews(graphics, player);
return;
}
if (SHARED_TAB.equals(selectedCategory)) {
renderSharedPreviews(graphics, player);
return;
}
EntityRenderDispatcher dispatcher = mc.getEntityRenderDispatcher();
EntityRenderer<? super LivingEntity, ?> renderer = dispatcher.getRenderer(player);
Map<String, com.razz.dfashion.cosmetic.CosmeticRef> liveEquipped =
player.getData(CosmeticAttachments.EQUIPPED.get());
// Authored loop first. Shared entries appended to this category tab render via
// renderSharedPreviews below — they're not matched by the catalog iteration.
int idx = 0;
for (Map.Entry<Identifier, CosmeticDefinition> entry : CosmeticCache.catalog.entrySet()) {
CosmeticDefinition def = entry.getValue();
if (!selectedCategory.equals(def.category())) continue;
if (idx >= cosmeticButtons.size()) break;
Button btn = cosmeticButtons.get(idx);
idx++;
// Button bounds (the visible frame).
int fx0 = btn.getX();
int fy0 = btn.getY();
int fx1 = fx0 + btn.getWidth();
int fy1 = fy0 + btn.getHeight();
// Skip off-screen or encroaching-on-tabs buttons (match btn.visible logic).
if (fx0 < COSMETIC_ROW_LEFT || fx0 >= this.width) continue;
com.razz.dfashion.cosmetic.CosmeticRef liveRef = liveEquipped.get(def.category());
boolean isEquipped = liveRef instanceof com.razz.dfashion.cosmetic.CosmeticRef.Local l
&& entry.getKey().equals(l.id());
int borderColor = isEquipped ? COSMETIC_BORDER_EQUIPPED_COLOR : COSMETIC_BORDER_COLOR;
// Frame (green if currently equipped).
graphics.outline(fx0, fy0, fx1 - fx0, fy1 - fy0, borderColor);
// Inset where the entity actually renders (adds visual padding).
int x0 = fx0 + COSMETIC_INNER_PADDING;
int y0 = fy0 + COSMETIC_INNER_PADDING;
int x1 = fx1 - COSMETIC_INNER_PADDING;
int y1 = fy1 - COSMETIC_INNER_PADDING;
EntityRenderState state = renderer.createRenderState(player, 1.0F);
if (!(state instanceof AvatarRenderState avatar)) continue;
// Clean up vanilla bits we don't want in the preview.
avatar.shadowPieces.clear();
avatar.outlineColor = 0; // outline framebuffer doesn't work in GUI (produced a white blob)
// Slight Y rotation so the player is angled, making side-attached cosmetics
// (shoulder pieces, side details) visible rather than head-on flat.
avatar.bodyRot = 180f + COSMETIC_PREVIEW_YAW;
avatar.yRot = COSMETIC_PREVIEW_YAW;
avatar.xRot = 0f;
// Register the override — layer will read this instead of the live player's
// equipped attachment when rendering this specific render state.
CosmeticRenderLayer.RENDER_OVERRIDES.put(avatar,
Map.of(def.category(), new com.razz.dfashion.cosmetic.CosmeticRef.Local(entry.getKey())));
Vector3f translation = new Vector3f(0f, avatar.boundingBoxHeight / 2f + 0.0625f, 0f);
Quaternionf rotation = new Quaternionf().rotateZ((float) Math.PI);
Quaternionf xRotation = new Quaternionf();
graphics.entity(avatar, COSMETIC_PREVIEW_SIZE, translation, rotation, xRotation,
x0, y0, x1, y1);
}
// Shared entries that `rebuildCosmeticRow` appended to this category tab render
// via the shared preview path — they're not in the catalog.
renderSharedPreviews(graphics, player);
}
/**
* 3D-mini-player preview per shared-library entry. Per-box we inject a
* {@link CosmeticRef.Shared} override so the cosmetic renders in the category derived
* from the entry's bones. Boxes that are scrolled off the grid are skipped. Green frame
* when equipped; amber frame for un-equipped shared entries so they're distinguishable
* from authored ones (white frame).
*/
private void renderSharedPreviews(GuiGraphicsExtractor graphics, LocalPlayer player) {
if (sharedButtonHashes.isEmpty()) return;
Minecraft mc = Minecraft.getInstance();
EntityRenderDispatcher dispatcher = mc.getEntityRenderDispatcher();
EntityRenderer<? super LivingEntity, ?> renderer = dispatcher.getRenderer(player);
Map<String, CosmeticRef> liveEquipped = player.getData(CosmeticAttachments.EQUIPPED.get());
// Vertical clip only matters on the shared tab (vertical scroll grid).
// On authored category tabs the shared buttons live in the bottom row alongside
// authored ones, so we use the full-screen range and rely on the button's visible
// flag + horizontal clip.
boolean onSharedTab = SHARED_TAB.equals(selectedCategory);
int gridTop = onSharedTab ? SHARED_GRID_TOP : 0;
int gridBottom = onSharedTab ? (this.height - SHARED_GRID_BOTTOM_PAD) : this.height;
for (Map.Entry<Button, String> e : sharedButtonHashes.entrySet()) {
Button btn = e.getKey();
String hash = e.getValue();
if (!btn.visible) continue;
int fx0 = btn.getX();
int fy0 = btn.getY();
int fx1 = fx0 + btn.getWidth();
int fy1 = fy0 + btn.getHeight();
// Clip against vertical-scroll viewport.
if (fy1 <= gridTop || fy0 >= gridBottom) continue;
if (fx0 < COSMETIC_ROW_LEFT || fx0 >= this.width) continue;
// Baked entry may not have arrived yet (requestIfMissing sent above).
if (ClientSharedCosmeticCache.getBaked(hash) == null) continue;
String previewCategory = sharedCategoryForHash(hash);
CosmeticRef liveRef = liveEquipped.get(previewCategory);
boolean isEquipped = liveRef instanceof CosmeticRef.Shared s && hash.equals(s.hash());
graphics.outline(fx0, fy0, fx1 - fx0, fy1 - fy0,
isEquipped ? COSMETIC_BORDER_EQUIPPED_COLOR : COSMETIC_BORDER_SHARED_COLOR);
int x0 = fx0 + COSMETIC_INNER_PADDING;
int y0 = fy0 + COSMETIC_INNER_PADDING;
int x1 = fx1 - COSMETIC_INNER_PADDING;
int y1 = fy1 - COSMETIC_INNER_PADDING;
EntityRenderState state = renderer.createRenderState(player, 1.0F);
if (!(state instanceof AvatarRenderState avatar)) continue;
avatar.shadowPieces.clear();
avatar.outlineColor = 0;
avatar.bodyRot = 180f + COSMETIC_PREVIEW_YAW;
avatar.yRot = COSMETIC_PREVIEW_YAW;
avatar.xRot = 0f;
CosmeticRenderLayer.RENDER_OVERRIDES.put(avatar,
Map.of(previewCategory, new CosmeticRef.Shared(hash)));
Vector3f translation = new Vector3f(0f, avatar.boundingBoxHeight / 2f + 0.0625f, 0f);
Quaternionf rotation = new Quaternionf().rotateZ((float) Math.PI);
Quaternionf xRotation = new Quaternionf();
graphics.entity(avatar, COSMETIC_PREVIEW_SIZE, translation, rotation, xRotation,
x0, y0, x1, y1);
}
}
/**
* Same 3D-mini-player preview pattern as cosmetics, but per-box we swap the render state's
* {@code skin} to a specific cached texture so each box shows a different skin variant.
* {@link SkinInfoOverride} runs at tick-level on {@link PlayerInfo}, not on these
* preview states, so our per-box swap here isn't clobbered.
*/
private void renderSkinPreviews(GuiGraphicsExtractor graphics, LocalPlayer player) {
if (skinButtonHashes.isEmpty()) return;
Minecraft mc = Minecraft.getInstance();
EntityRenderDispatcher dispatcher = mc.getEntityRenderDispatcher();
EntityRenderer<? super LivingEntity, ?> renderer = dispatcher.getRenderer(player);
SkinData liveSkin = player.getData(SkinAttachments.DATA.get());
for (Map.Entry<Button, String> entry : skinButtonHashes.entrySet()) {
Button btn = entry.getKey();
String hash = entry.getValue();
int fx0 = btn.getX();
int fy0 = btn.getY();
int fx1 = fx0 + btn.getWidth();
int fy1 = fy0 + btn.getHeight();
if (fx0 < SKIN_GRID_START_X || fx0 >= this.width) continue;
Identifier tex = ClientSkinCache.ensureLoadedFromDisk(hash);
if (tex == null) continue;
boolean isEquipped = liveSkin != null && hash.equals(liveSkin.hash());
graphics.outline(fx0, fy0, fx1 - fx0, fy1 - fy0,
isEquipped ? COSMETIC_BORDER_EQUIPPED_COLOR : COSMETIC_BORDER_COLOR);
int x0 = fx0 + COSMETIC_INNER_PADDING;
int y0 = fy0 + COSMETIC_INNER_PADDING;
int x1 = fx1 - COSMETIC_INNER_PADDING;
int y1 = fy1 - COSMETIC_INNER_PADDING;
EntityRenderState state = renderer.createRenderState(player, 1.0F);
if (!(state instanceof AvatarRenderState avatar)) continue;
avatar.shadowPieces.clear();
avatar.outlineColor = 0;
avatar.bodyRot = 180f + COSMETIC_PREVIEW_YAW;
avatar.yRot = COSMETIC_PREVIEW_YAW;
avatar.xRot = 0f;
// Swap this state's skin to the cached texture; opt out of the per-player handler.
PlayerSkin existing = avatar.skin;
ClientAsset.Texture body = new ClientAsset.DownloadedTexture(tex, "decofashion:skin/" + hash);
PlayerModelType modelType = pendingSkinModel == SkinModel.SLIM
? PlayerModelType.SLIM : PlayerModelType.WIDE;
avatar.skin = existing == null
? PlayerSkin.insecure(body, null, null, modelType)
: PlayerSkin.insecure(body, existing.cape(), existing.elytra(), modelType);
Vector3f translation = new Vector3f(0f, avatar.boundingBoxHeight / 2f + 0.0625f, 0f);
Quaternionf rotation = new Quaternionf().rotateZ((float) Math.PI);
Quaternionf xRotation = new Quaternionf();
graphics.entity(avatar, COSMETIC_PREVIEW_SIZE, translation, rotation, xRotation,
x0, y0, x1, y1);
}
}
@Override
public void onClose() {
NeoForge.EVENT_BUS.unregister(this);
CosmeticRenderLayer.RENDER_OVERRIDES.clear();
if (closet != null) closet.setOpen(false);
if (previousCamera != null) {
Minecraft.getInstance().options.setCameraType(previousCamera);
}
super.onClose();
}
}