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