add server owner config opt ins, body hide toggles, first person arm cosmetics, bone group tabs
This commit is contained in:
@@ -38,3 +38,4 @@ run/
|
|||||||
**/src/generated/**/.cache/
|
**/src/generated/**/.cache/
|
||||||
repo/
|
repo/
|
||||||
!**/src/**/repo/
|
!**/src/**/repo/
|
||||||
|
.claude/
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import com.razz.dfashion.skin.SkinAttachments;
|
|||||||
import net.neoforged.bus.api.IEventBus;
|
import net.neoforged.bus.api.IEventBus;
|
||||||
import net.neoforged.fml.ModContainer;
|
import net.neoforged.fml.ModContainer;
|
||||||
import net.neoforged.fml.common.Mod;
|
import net.neoforged.fml.common.Mod;
|
||||||
|
import net.neoforged.fml.config.ModConfig;
|
||||||
import net.neoforged.fml.event.lifecycle.FMLCommonSetupEvent;
|
import net.neoforged.fml.event.lifecycle.FMLCommonSetupEvent;
|
||||||
|
|
||||||
@Mod(DecoFashion.MODID)
|
@Mod(DecoFashion.MODID)
|
||||||
@@ -22,6 +23,11 @@ public class DecoFashion {
|
|||||||
CosmeticAttachments.ATTACHMENT_TYPES.register(modEventBus);
|
CosmeticAttachments.ATTACHMENT_TYPES.register(modEventBus);
|
||||||
SkinAttachments.ATTACHMENT_TYPES.register(modEventBus);
|
SkinAttachments.ATTACHMENT_TYPES.register(modEventBus);
|
||||||
ClosetRegistry.register(modEventBus);
|
ClosetRegistry.register(modEventBus);
|
||||||
|
// SERVER type = loaded on server, auto-synced to connected clients on login.
|
||||||
|
modContainer.registerConfig(ModConfig.Type.SERVER, DecoFashionConfig.SPEC);
|
||||||
|
// Runtime-reload: when an op edits the TOML mid-session, strip shared cosmetics if
|
||||||
|
// the shared-library feature was just disabled. Also fires on initial load.
|
||||||
|
modEventBus.addListener(DecoFashionConfig::onConfigChange);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void commonSetup(FMLCommonSetupEvent event) {
|
private void commonSetup(FMLCommonSetupEvent event) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import com.razz.dfashion.block.ClosetRegistry;
|
|||||||
import com.razz.dfashion.client.ClientSharedCosmeticCache;
|
import com.razz.dfashion.client.ClientSharedCosmeticCache;
|
||||||
import com.razz.dfashion.client.ClientSkinCache;
|
import com.razz.dfashion.client.ClientSkinCache;
|
||||||
import com.razz.dfashion.client.ClosetModelCache;
|
import com.razz.dfashion.client.ClosetModelCache;
|
||||||
|
import com.razz.dfashion.client.ClosetPreferences;
|
||||||
import com.razz.dfashion.client.ClosetRenderer;
|
import com.razz.dfashion.client.ClosetRenderer;
|
||||||
import com.razz.dfashion.client.CosmeticCache;
|
import com.razz.dfashion.client.CosmeticCache;
|
||||||
import com.razz.dfashion.client.CosmeticRenderLayer;
|
import com.razz.dfashion.client.CosmeticRenderLayer;
|
||||||
@@ -58,6 +59,7 @@ public class DecoFashionClient {
|
|||||||
|
|
||||||
@SubscribeEvent
|
@SubscribeEvent
|
||||||
static void onClientSetup(FMLClientSetupEvent event) {
|
static void onClientSetup(FMLClientSetupEvent event) {
|
||||||
|
ClosetPreferences.load();
|
||||||
DecoFashion.LOGGER.info("DecoFashion client setup complete");
|
DecoFashion.LOGGER.info("DecoFashion client setup complete");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package com.razz.dfashion;
|
||||||
|
|
||||||
|
import com.razz.dfashion.cosmetic.CosmeticAttachments;
|
||||||
|
import com.razz.dfashion.cosmetic.CosmeticRef;
|
||||||
|
|
||||||
|
import net.minecraft.server.MinecraftServer;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
import net.neoforged.fml.event.config.ModConfigEvent;
|
||||||
|
import net.neoforged.neoforge.common.ModConfigSpec;
|
||||||
|
import net.neoforged.neoforge.server.ServerLifecycleHooks;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-owner opt-in flags. Lives at {@code config/decofashion-server.toml} and is
|
||||||
|
* automatically synced to connected clients on login (ModConfig.Type.SERVER).
|
||||||
|
*
|
||||||
|
* <p>Both features default to {@code false} so operators must opt in explicitly.
|
||||||
|
*/
|
||||||
|
public final class DecoFashionConfig {
|
||||||
|
|
||||||
|
public static final ModConfigSpec SPEC;
|
||||||
|
public static final ModConfigSpec.BooleanValue SHARED_LIBRARY_ENABLED;
|
||||||
|
public static final ModConfigSpec.BooleanValue BODY_HIDE_ENABLED;
|
||||||
|
public static final ModConfigSpec.IntValue COSMETICS_PER_PLAYER;
|
||||||
|
public static final ModConfigSpec.IntValue SKINS_PER_PLAYER;
|
||||||
|
|
||||||
|
static {
|
||||||
|
ModConfigSpec.Builder b = new ModConfigSpec.Builder();
|
||||||
|
|
||||||
|
b.comment("Decofashion server-owner feature toggles and limits.",
|
||||||
|
"Boolean features default to OFF — set a flag to true to opt in.");
|
||||||
|
|
||||||
|
SHARED_LIBRARY_ENABLED = b
|
||||||
|
.comment(
|
||||||
|
"Allow players to upload and equip shared cosmetics from the Library tab.",
|
||||||
|
"The server rejects upload and assign-shared packets while this is false,",
|
||||||
|
"and shared cosmetics already equipped will unequip on the next join.",
|
||||||
|
"Clients hide the Library tab + Share button when disabled.")
|
||||||
|
.define("sharedLibraryEnabled", false);
|
||||||
|
|
||||||
|
BODY_HIDE_ENABLED = b
|
||||||
|
.comment(
|
||||||
|
"Allow clients to hide their own player body parts (head/body/arms/legs) via",
|
||||||
|
"the closet-screen toggles. Enforcement is good-faith — a modified client can",
|
||||||
|
"still hide parts locally. Clients hide the H/B/A/L toggle buttons when",
|
||||||
|
"disabled and render all body parts as vanilla does.")
|
||||||
|
.define("bodyHideEnabled", false);
|
||||||
|
|
||||||
|
b.push("limits");
|
||||||
|
|
||||||
|
COSMETICS_PER_PLAYER = b
|
||||||
|
.comment(
|
||||||
|
"Maximum number of shared cosmetics a single player can keep in their",
|
||||||
|
"personal library. New uploads past this cap are rejected. Existing entries",
|
||||||
|
"past the cap continue to work until removed.")
|
||||||
|
.defineInRange("cosmeticsPerPlayer", 15, 0, 1000);
|
||||||
|
|
||||||
|
SKINS_PER_PLAYER = b
|
||||||
|
.comment(
|
||||||
|
"Maximum number of skins a single player can keep in their personal skin",
|
||||||
|
"library. Same semantics as cosmeticsPerPlayer.")
|
||||||
|
.defineInRange("skinsPerPlayer", 5, 0, 1000);
|
||||||
|
|
||||||
|
b.pop();
|
||||||
|
|
||||||
|
SPEC = b.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private DecoFashionConfig() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime-reload hook. Registered against the mod event bus in {@link DecoFashion}.
|
||||||
|
* Fires on both Loading (initial startup) and Reloading (operator edited the file).
|
||||||
|
* When SHARED_LIBRARY_ENABLED is now false, strip all online players' equipped Shared
|
||||||
|
* cosmetics on the logical server so players don't see broken refs.
|
||||||
|
*/
|
||||||
|
public static void onConfigChange(ModConfigEvent event) {
|
||||||
|
if (event.getConfig().getSpec() != SPEC) return;
|
||||||
|
// Unloading fires on server shutdown AFTER the config values are torn down —
|
||||||
|
// any .get() here throws. Nothing to do on shutdown anyway.
|
||||||
|
if (event instanceof ModConfigEvent.Unloading) return;
|
||||||
|
DecoFashion.LOGGER.info(
|
||||||
|
"DecoFashion config {}: sharedLibrary={}, bodyHide={}",
|
||||||
|
event instanceof ModConfigEvent.Reloading ? "reloaded" : "loaded",
|
||||||
|
SHARED_LIBRARY_ENABLED.get(), BODY_HIDE_ENABLED.get());
|
||||||
|
|
||||||
|
MinecraftServer server = ServerLifecycleHooks.getCurrentServer();
|
||||||
|
if (server == null) return;
|
||||||
|
if (SHARED_LIBRARY_ENABLED.get()) return;
|
||||||
|
|
||||||
|
// Shared library disabled — strip Shared refs from every online player.
|
||||||
|
for (ServerPlayer p : server.getPlayerList().getPlayers()) {
|
||||||
|
Map<String, CosmeticRef> equipped = p.getData(CosmeticAttachments.EQUIPPED.get());
|
||||||
|
Map<String, CosmeticRef> cleaned = null;
|
||||||
|
for (Map.Entry<String, CosmeticRef> e : equipped.entrySet()) {
|
||||||
|
if (e.getValue() instanceof CosmeticRef.Shared) {
|
||||||
|
if (cleaned == null) cleaned = new HashMap<>(equipped);
|
||||||
|
cleaned.remove(e.getKey());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cleaned != null) {
|
||||||
|
p.setData(CosmeticAttachments.EQUIPPED.get(), cleaned);
|
||||||
|
DecoFashion.LOGGER.info("Runtime unequip shared for {}: feature disabled",
|
||||||
|
p.getUUID());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package com.razz.dfashion.client;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.GsonBuilder;
|
||||||
|
import com.razz.dfashion.DecoFashion;
|
||||||
|
|
||||||
|
import net.neoforged.fml.loading.FMLPaths;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.Reader;
|
||||||
|
import java.io.Writer;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side toggles for hiding player body parts so cosmetics don't clip
|
||||||
|
* through the vanilla skin. Persisted as JSON in
|
||||||
|
* {@code config/decofashion_prefs.json}.
|
||||||
|
*/
|
||||||
|
public class ClosetPreferences {
|
||||||
|
|
||||||
|
public static boolean hideHead = false;
|
||||||
|
public static boolean hideBody = false;
|
||||||
|
public static boolean hideArms = false;
|
||||||
|
public static boolean hideLegs = false;
|
||||||
|
|
||||||
|
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
|
||||||
|
|
||||||
|
private static Path file() {
|
||||||
|
return FMLPaths.CONFIGDIR.get().resolve("decofashion_prefs.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void load() {
|
||||||
|
Path p = file();
|
||||||
|
if (!Files.exists(p)) return;
|
||||||
|
try (Reader r = Files.newBufferedReader(p)) {
|
||||||
|
Data d = GSON.fromJson(r, Data.class);
|
||||||
|
if (d != null) {
|
||||||
|
hideHead = d.hideHead;
|
||||||
|
hideBody = d.hideBody;
|
||||||
|
hideArms = d.hideArms;
|
||||||
|
hideLegs = d.hideLegs;
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
DecoFashion.LOGGER.warn("Failed to load decofashion prefs", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void save() {
|
||||||
|
Path p = file();
|
||||||
|
try {
|
||||||
|
if (p.getParent() != null) Files.createDirectories(p.getParent());
|
||||||
|
try (Writer w = Files.newBufferedWriter(p)) {
|
||||||
|
Data d = new Data();
|
||||||
|
d.hideHead = hideHead;
|
||||||
|
d.hideBody = hideBody;
|
||||||
|
d.hideArms = hideArms;
|
||||||
|
d.hideLegs = hideLegs;
|
||||||
|
GSON.toJson(d, w);
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
DecoFashion.LOGGER.warn("Failed to save decofashion prefs", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class Data {
|
||||||
|
boolean hideHead;
|
||||||
|
boolean hideBody;
|
||||||
|
boolean hideArms;
|
||||||
|
boolean hideLegs;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -91,7 +91,7 @@ public class CosmeticRenderLayer extends RenderLayer<AvatarRenderState, PlayerMo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static CosmeticCache.Baked resolve(
|
static CosmeticCache.Baked resolve(
|
||||||
CosmeticRef ref, Map<Identifier, CosmeticCache.Baked> localCache
|
CosmeticRef ref, Map<Identifier, CosmeticCache.Baked> localCache
|
||||||
) {
|
) {
|
||||||
return switch (ref) {
|
return switch (ref) {
|
||||||
@@ -131,7 +131,7 @@ public class CosmeticRenderLayer extends RenderLayer<AvatarRenderState, PlayerMo
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** PascalCase / camelCase / UPPER -> snake_case lowercase. */
|
/** PascalCase / camelCase / UPPER -> snake_case lowercase. */
|
||||||
private static String normalize(String name) {
|
static String normalize(String name) {
|
||||||
StringBuilder sb = new StringBuilder(name.length() + 4);
|
StringBuilder sb = new StringBuilder(name.length() + 4);
|
||||||
for (int i = 0; i < name.length(); i++) {
|
for (int i = 0; i < name.length(); i++) {
|
||||||
char c = name.charAt(i);
|
char c = name.charAt(i);
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package com.razz.dfashion.client;
|
||||||
|
|
||||||
|
import com.mojang.blaze3d.vertex.PoseStack;
|
||||||
|
import com.razz.dfashion.DecoFashion;
|
||||||
|
import com.razz.dfashion.cosmetic.CosmeticAttachments;
|
||||||
|
import com.razz.dfashion.cosmetic.CosmeticRef;
|
||||||
|
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
|
import net.minecraft.client.model.geom.ModelPart;
|
||||||
|
import net.minecraft.client.model.player.PlayerModel;
|
||||||
|
import net.minecraft.client.player.AbstractClientPlayer;
|
||||||
|
import net.minecraft.client.renderer.SubmitNodeCollector;
|
||||||
|
import net.minecraft.client.renderer.entity.EntityRenderDispatcher;
|
||||||
|
import net.minecraft.client.renderer.entity.EntityRenderer;
|
||||||
|
import net.minecraft.client.renderer.entity.player.AvatarRenderer;
|
||||||
|
import net.minecraft.client.renderer.rendertype.RenderTypes;
|
||||||
|
import net.minecraft.client.renderer.texture.OverlayTexture;
|
||||||
|
import net.minecraft.resources.Identifier;
|
||||||
|
import net.minecraft.world.entity.HumanoidArm;
|
||||||
|
import net.neoforged.api.distmarker.Dist;
|
||||||
|
import net.neoforged.bus.api.SubscribeEvent;
|
||||||
|
import net.neoforged.fml.common.EventBusSubscriber;
|
||||||
|
import net.neoforged.neoforge.client.event.RenderArmEvent;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders arm/sleeve cosmetics on the local player during first-person arm rendering.
|
||||||
|
* Subscribes to {@link RenderArmEvent} (fires before vanilla {@code renderHand} submits
|
||||||
|
* the arm mesh). Non-cancelling so the vanilla arm still draws — cosmetics layer on top.
|
||||||
|
*
|
||||||
|
* <p>Full-body first-person is intentionally out of scope; head/body/legs aren't visible
|
||||||
|
* in first-person anyway. Arms/sleeves are the only bones that show up.
|
||||||
|
*/
|
||||||
|
@EventBusSubscriber(modid = DecoFashion.MODID, value = Dist.CLIENT)
|
||||||
|
public class FirstPersonArmCosmeticRenderer {
|
||||||
|
|
||||||
|
private static final Set<String> RIGHT_ARM_BONES = Set.of("right_arm", "right_sleeve");
|
||||||
|
private static final Set<String> LEFT_ARM_BONES = Set.of("left_arm", "left_sleeve");
|
||||||
|
|
||||||
|
@SubscribeEvent
|
||||||
|
static void onRenderArm(RenderArmEvent event) {
|
||||||
|
Minecraft mc = Minecraft.getInstance();
|
||||||
|
AbstractClientPlayer player = event.getPlayer();
|
||||||
|
if (player != mc.player) return;
|
||||||
|
|
||||||
|
// Vanilla renderHand explicitly sets arm.visible = true before submitting, so the
|
||||||
|
// mixin's hide doesn't carry into first-person. Cancelling the event skips vanilla's
|
||||||
|
// submit entirely; our cosmetic arm parts (below) still render on top.
|
||||||
|
if (com.razz.dfashion.DecoFashionConfig.BODY_HIDE_ENABLED.get()
|
||||||
|
&& com.razz.dfashion.client.ClosetPreferences.hideArms) {
|
||||||
|
event.setCanceled(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, CosmeticRef> equipped = player.getData(CosmeticAttachments.EQUIPPED.get());
|
||||||
|
if (equipped.isEmpty()) return;
|
||||||
|
|
||||||
|
EntityRenderDispatcher dispatcher = mc.getEntityRenderDispatcher();
|
||||||
|
EntityRenderer<?, ?> renderer = dispatcher.getRenderer(player);
|
||||||
|
if (!(renderer instanceof AvatarRenderer<?> avatarRenderer)) return;
|
||||||
|
PlayerModel model = avatarRenderer.getModel();
|
||||||
|
|
||||||
|
HumanoidArm arm = event.getArm();
|
||||||
|
ModelPart bone = arm == HumanoidArm.RIGHT ? model.rightArm : model.leftArm;
|
||||||
|
Set<String> validBones = arm == HumanoidArm.RIGHT ? RIGHT_ARM_BONES : LEFT_ARM_BONES;
|
||||||
|
|
||||||
|
// Match vanilla's first-person arm pose (see AvatarRenderer.renderHand) so cosmetic
|
||||||
|
// attaches to the same arm orientation the arm mesh will render with.
|
||||||
|
bone.resetPose();
|
||||||
|
bone.zRot = arm == HumanoidArm.RIGHT ? 0.1f : -0.1f;
|
||||||
|
|
||||||
|
PoseStack poseStack = event.getPoseStack();
|
||||||
|
SubmitNodeCollector collector = event.getSubmitNodeCollector();
|
||||||
|
int light = event.getPackedLight();
|
||||||
|
Map<Identifier, CosmeticCache.Baked> cache = CosmeticCache.cosmetics;
|
||||||
|
|
||||||
|
for (CosmeticRef ref : equipped.values()) {
|
||||||
|
CosmeticCache.Baked cosmetic = CosmeticRenderLayer.resolve(ref, cache);
|
||||||
|
if (cosmetic == null) continue;
|
||||||
|
|
||||||
|
for (Map.Entry<String, ModelPart> entry : cosmetic.parts().entrySet()) {
|
||||||
|
if (!validBones.contains(CosmeticRenderLayer.normalize(entry.getKey()))) continue;
|
||||||
|
|
||||||
|
poseStack.pushPose();
|
||||||
|
bone.translateAndRotate(poseStack);
|
||||||
|
poseStack.scale(-1f, -1f, 1f);
|
||||||
|
collector.submitModelPart(
|
||||||
|
entry.getValue(),
|
||||||
|
poseStack,
|
||||||
|
RenderTypes.entityTranslucent(cosmetic.texture()),
|
||||||
|
light,
|
||||||
|
OverlayTexture.NO_OVERLAY,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
poseStack.popPose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
package com.razz.dfashion.client.screen;
|
package com.razz.dfashion.client.screen;
|
||||||
|
|
||||||
|
import com.razz.dfashion.DecoFashionConfig;
|
||||||
import com.razz.dfashion.block.ClosetBlockEntity;
|
import com.razz.dfashion.block.ClosetBlockEntity;
|
||||||
import com.razz.dfashion.client.ClientCosmeticInfoCache;
|
import com.razz.dfashion.client.ClientCosmeticInfoCache;
|
||||||
import com.razz.dfashion.client.ClientSharedCosmeticCache;
|
import com.razz.dfashion.client.ClientSharedCosmeticCache;
|
||||||
import com.razz.dfashion.client.ClientSharedCosmeticUploader;
|
import com.razz.dfashion.client.ClientSharedCosmeticUploader;
|
||||||
import com.razz.dfashion.client.ClientSkinCache;
|
import com.razz.dfashion.client.ClientSkinCache;
|
||||||
|
import com.razz.dfashion.client.ClosetPreferences;
|
||||||
import com.razz.dfashion.client.CosmeticCache;
|
import com.razz.dfashion.client.CosmeticCache;
|
||||||
import com.razz.dfashion.client.CosmeticRenderLayer;
|
import com.razz.dfashion.client.CosmeticRenderLayer;
|
||||||
import com.razz.dfashion.client.SkinInfoOverride;
|
import com.razz.dfashion.client.SkinInfoOverride;
|
||||||
@@ -43,6 +45,7 @@ import net.minecraft.client.renderer.entity.EntityRenderDispatcher;
|
|||||||
import net.minecraft.client.renderer.entity.EntityRenderer;
|
import net.minecraft.client.renderer.entity.EntityRenderer;
|
||||||
import net.minecraft.client.renderer.entity.state.AvatarRenderState;
|
import net.minecraft.client.renderer.entity.state.AvatarRenderState;
|
||||||
import net.minecraft.client.renderer.entity.state.EntityRenderState;
|
import net.minecraft.client.renderer.entity.state.EntityRenderState;
|
||||||
|
import net.minecraft.ChatFormatting;
|
||||||
import net.minecraft.network.chat.Component;
|
import net.minecraft.network.chat.Component;
|
||||||
import net.minecraft.resources.Identifier;
|
import net.minecraft.resources.Identifier;
|
||||||
import net.minecraft.world.entity.LivingEntity;
|
import net.minecraft.world.entity.LivingEntity;
|
||||||
@@ -75,15 +78,28 @@ import java.util.Map;
|
|||||||
|
|
||||||
public class ClosetScreen extends Screen {
|
public class ClosetScreen extends Screen {
|
||||||
|
|
||||||
// Tab vocabulary — matches category memory. "skin" is a pseudo-category that drives the
|
// Tab vocabulary — matches category memory. Top-level tabs collapse the old per-category
|
||||||
// per-player skin override UI (upload/reset) rather than the usual cosmetic row.
|
// list into bone-group tabs + three standalones ("particle", "skin", "shared"). Selecting
|
||||||
// "shared" is a pseudo-category for the shared-cosmetic library (vertical scroll grid).
|
// a bone-group tab exposes a sub-filter pill row so the author can narrow within the group.
|
||||||
private static final String[] CATEGORIES = {
|
// "skin" is a pseudo-category that drives the per-player skin override UI rather than the
|
||||||
"hat", "head", "torso", "arms", "legs", "wrist", "feet", "wings", "particle", "skin", "shared"
|
// usual cosmetic row; "shared" is the shared-cosmetic library (vertical scroll grid).
|
||||||
};
|
|
||||||
private static final String SKIN_TAB = "skin";
|
private static final String SKIN_TAB = "skin";
|
||||||
private static final String SHARED_TAB = "shared";
|
private static final String SHARED_TAB = "shared";
|
||||||
|
|
||||||
|
/** Bone-group tab key → sub-categories it shows. Display order = insertion order. */
|
||||||
|
private static final java.util.LinkedHashMap<String, String[]> BONE_GROUPS = new java.util.LinkedHashMap<>();
|
||||||
|
static {
|
||||||
|
BONE_GROUPS.put("head", new String[]{"hat", "head"});
|
||||||
|
BONE_GROUPS.put("torso", new String[]{"torso", "wings"});
|
||||||
|
BONE_GROUPS.put("arms", new String[]{"arms", "wrist"});
|
||||||
|
BONE_GROUPS.put("legs", new String[]{"legs", "feet"});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Top-level tabs in render order: 4 bone groups + particle + skin + shared. */
|
||||||
|
private static final String[] TOP_LEVEL_TABS = {
|
||||||
|
"head", "torso", "arms", "legs", "particle", SKIN_TAB, SHARED_TAB
|
||||||
|
};
|
||||||
|
|
||||||
/** Fallback equip category used when a shared cosmetic has no canonical bones. */
|
/** Fallback equip category used when a shared cosmetic has no canonical bones. */
|
||||||
private static final String FALLBACK_SHARED_CATEGORY = "particle";
|
private static final String FALLBACK_SHARED_CATEGORY = "particle";
|
||||||
|
|
||||||
@@ -121,6 +137,7 @@ public class ClosetScreen extends Screen {
|
|||||||
private CameraType previousCamera;
|
private CameraType previousCamera;
|
||||||
private @Nullable Button leftArrow, rightArrow;
|
private @Nullable Button leftArrow, rightArrow;
|
||||||
private @Nullable Button shareButton;
|
private @Nullable Button shareButton;
|
||||||
|
private @Nullable Button hideHeadBtn, hideBodyBtn, hideArmsBtn, hideLegsBtn;
|
||||||
// Body/head yaw applied every tick. yRot (camera-facing yaw) is left untouched so
|
// 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.
|
// rotating doesn't pan the camera — only the player's visible body/head turn.
|
||||||
private float displayYaw = 0f;
|
private float displayYaw = 0f;
|
||||||
@@ -128,6 +145,10 @@ public class ClosetScreen extends Screen {
|
|||||||
private @Nullable String selectedCategory;
|
private @Nullable String selectedCategory;
|
||||||
private int cosmeticScroll = 0;
|
private int cosmeticScroll = 0;
|
||||||
private final List<Button> cosmeticButtons = new ArrayList<>();
|
private final List<Button> cosmeticButtons = new ArrayList<>();
|
||||||
|
/** Bone-group sub-filter pill buttons. Tracked separately from cosmeticButtons so the
|
||||||
|
* preview renderer, which pairs cosmeticButtons with catalog entries by index, never
|
||||||
|
* accidentally draws a 3D mini-player on top of a pill. Cleaned up on every rebuild. */
|
||||||
|
private final List<Button> filterPillButtons = new ArrayList<>();
|
||||||
private boolean showPreviews = true; // toggled by the bottom-right button
|
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
|
// Current skin-model selection on the skin tab. Drives the Upload button's model arg
|
||||||
@@ -175,6 +196,12 @@ public class ClosetScreen extends Screen {
|
|||||||
// ---- Shared-library tab state ----
|
// ---- Shared-library tab state ----
|
||||||
/** {@code null} = no filter (show all); else one of {@link #SHARED_FILTER_KEYS}. */
|
/** {@code null} = no filter (show all); else one of {@link #SHARED_FILTER_KEYS}. */
|
||||||
private @Nullable String sharedBoneFilter = null;
|
private @Nullable String sharedBoneFilter = null;
|
||||||
|
/** Sub-category pill inside the selected bone-group tab. {@code null} = show all in group. */
|
||||||
|
private @Nullable String selectedSubFilter = null;
|
||||||
|
/** Last observed values of server-synced feature flags; used by {@link #tick()} to
|
||||||
|
* detect runtime config changes and rebuild the closet UI in response. */
|
||||||
|
private boolean lastSharedEnabled;
|
||||||
|
private boolean lastBodyHideEnabled;
|
||||||
private int sharedVerticalScroll = 0;
|
private int sharedVerticalScroll = 0;
|
||||||
private final Map<Button, String> sharedButtonHashes = new HashMap<>();
|
private final Map<Button, String> sharedButtonHashes = new HashMap<>();
|
||||||
private int lastKnownSharedCount = -1;
|
private int lastKnownSharedCount = -1;
|
||||||
@@ -193,7 +220,9 @@ public class ClosetScreen extends Screen {
|
|||||||
private static final int SHARED_FILTER_BTN_GAP = 4;
|
private static final int SHARED_FILTER_BTN_GAP = 4;
|
||||||
|
|
||||||
/** Top of the vertical-scroll grid on the Shared tab (below the filter bar). */
|
/** 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;
|
/** +25 below the filter bar (was +10) so the per-cosmetic {@code ?}/{@code x} overlay
|
||||||
|
* buttons at {@code boxY - 12} don't crowd the filter pills. */
|
||||||
|
private static final int SHARED_GRID_TOP = SHARED_FILTER_Y + SHARED_FILTER_H + 25;
|
||||||
/** Height reserved for bottom buttons; the grid stops above this. */
|
/** Height reserved for bottom buttons; the grid stops above this. */
|
||||||
private static final int SHARED_GRID_BOTTOM_PAD = 50;
|
private static final int SHARED_GRID_BOTTOM_PAD = 50;
|
||||||
|
|
||||||
@@ -220,7 +249,7 @@ public class ClosetScreen extends Screen {
|
|||||||
|
|
||||||
NeoForge.EVENT_BUS.register(this);
|
NeoForge.EVENT_BUS.register(this);
|
||||||
|
|
||||||
if (selectedCategory == null) selectedCategory = CATEGORIES[0];
|
if (selectedCategory == null) selectedCategory = TOP_LEVEL_TABS[0];
|
||||||
|
|
||||||
// Catch any PNGs added to the cache directory since last client setup —
|
// 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.
|
// e.g. user dropped files in manually, or the folder was just populated.
|
||||||
@@ -232,14 +261,25 @@ public class ClosetScreen extends Screen {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lastSharedEnabled = DecoFashionConfig.SHARED_LIBRARY_ENABLED.get();
|
||||||
|
lastBodyHideEnabled = DecoFashionConfig.BODY_HIDE_ENABLED.get();
|
||||||
buildTabs();
|
buildTabs();
|
||||||
|
buildPartToggles();
|
||||||
buildBottomControls();
|
buildBottomControls();
|
||||||
selectCategory(selectedCategory != null ? selectedCategory : CATEGORIES[0]);
|
// If the server disabled shared library mid-session and the player was viewing the
|
||||||
|
// Library tab, fall back to the first available tab.
|
||||||
|
String target = selectedCategory != null ? selectedCategory : TOP_LEVEL_TABS[0];
|
||||||
|
if (SHARED_TAB.equals(target) && !DecoFashionConfig.SHARED_LIBRARY_ENABLED.get()) {
|
||||||
|
target = TOP_LEVEL_TABS[0];
|
||||||
|
}
|
||||||
|
selectCategory(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void buildTabs() {
|
private void buildTabs() {
|
||||||
int x = TAB_X_START;
|
int x = TAB_X_START;
|
||||||
for (String cat : CATEGORIES) {
|
for (String cat : TOP_LEVEL_TABS) {
|
||||||
|
// Server disabled shared library → hide the Library tab entirely.
|
||||||
|
if (SHARED_TAB.equals(cat) && !DecoFashionConfig.SHARED_LIBRARY_ENABLED.get()) continue;
|
||||||
String label = tabLabel(cat);
|
String label = tabLabel(cat);
|
||||||
addRenderableWidget(Button.builder(
|
addRenderableWidget(Button.builder(
|
||||||
Component.literal(label),
|
Component.literal(label),
|
||||||
@@ -249,6 +289,53 @@ public class ClosetScreen extends Screen {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Body-part-hide toggles, placed to the right of the tab row. Each button cycles the
|
||||||
|
* corresponding {@link ClosetPreferences} flag and persists to disk. A struck-through
|
||||||
|
* red label indicates the part is currently hidden. Skipped entirely when the server
|
||||||
|
* disables body-hide via the mod config. */
|
||||||
|
private void buildPartToggles() {
|
||||||
|
if (!DecoFashionConfig.BODY_HIDE_ENABLED.get()) return;
|
||||||
|
int startX = TAB_X_START + TOP_LEVEL_TABS.length * (TAB_W + TAB_SPACING) + 10;
|
||||||
|
int step = TAB_W + TAB_SPACING;
|
||||||
|
|
||||||
|
hideHeadBtn = makeToggle("H", "Head", startX, ClosetPreferences.hideHead,
|
||||||
|
b -> { ClosetPreferences.hideHead = !ClosetPreferences.hideHead;
|
||||||
|
refreshToggle(b, "H", ClosetPreferences.hideHead); });
|
||||||
|
hideBodyBtn = makeToggle("B", "Body", startX + step, ClosetPreferences.hideBody,
|
||||||
|
b -> { ClosetPreferences.hideBody = !ClosetPreferences.hideBody;
|
||||||
|
refreshToggle(b, "B", ClosetPreferences.hideBody); });
|
||||||
|
hideArmsBtn = makeToggle("A", "Arms", startX + 2*step, ClosetPreferences.hideArms,
|
||||||
|
b -> { ClosetPreferences.hideArms = !ClosetPreferences.hideArms;
|
||||||
|
refreshToggle(b, "A", ClosetPreferences.hideArms); });
|
||||||
|
hideLegsBtn = makeToggle("L", "Legs", startX + 3*step, ClosetPreferences.hideLegs,
|
||||||
|
b -> { ClosetPreferences.hideLegs = !ClosetPreferences.hideLegs;
|
||||||
|
refreshToggle(b, "L", ClosetPreferences.hideLegs); });
|
||||||
|
|
||||||
|
addRenderableWidget(hideHeadBtn);
|
||||||
|
addRenderableWidget(hideBodyBtn);
|
||||||
|
addRenderableWidget(hideArmsBtn);
|
||||||
|
addRenderableWidget(hideLegsBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Button makeToggle(String letter, String tooltip, int x, boolean hidden, Button.OnPress onPress) {
|
||||||
|
Button b = Button.builder(partToggleLabel(letter, hidden), onPress)
|
||||||
|
.bounds(x, TAB_Y, TAB_W, TAB_H)
|
||||||
|
.build();
|
||||||
|
b.setTooltip(Tooltip.create(Component.literal("Hide " + tooltip)));
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refreshToggle(Button btn, String letter, boolean hidden) {
|
||||||
|
btn.setMessage(partToggleLabel(letter, hidden));
|
||||||
|
ClosetPreferences.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Component partToggleLabel(String letter, boolean hidden) {
|
||||||
|
return hidden
|
||||||
|
? Component.literal(letter).withStyle(ChatFormatting.STRIKETHROUGH, ChatFormatting.RED)
|
||||||
|
: Component.literal(letter);
|
||||||
|
}
|
||||||
|
|
||||||
/** Single-char tab labels; {@code shared} overridden to "L" so it doesn't collide with {@code skin}. */
|
/** Single-char tab labels; {@code shared} overridden to "L" so it doesn't collide with {@code skin}. */
|
||||||
private static String tabLabel(String cat) {
|
private static String tabLabel(String cat) {
|
||||||
return switch (cat) {
|
return switch (cat) {
|
||||||
@@ -297,15 +384,54 @@ public class ClosetScreen extends Screen {
|
|||||||
|
|
||||||
private void selectCategory(String cat) {
|
private void selectCategory(String cat) {
|
||||||
selectedCategory = cat;
|
selectedCategory = cat;
|
||||||
|
selectedSubFilter = null; // reset sub-filter on top-level tab change
|
||||||
cosmeticScroll = 0;
|
cosmeticScroll = 0;
|
||||||
lastKnownSkinCount = -1; // force the next skin-tab tick to re-evaluate the grid
|
lastKnownSkinCount = -1; // force the next skin-tab tick to re-evaluate the grid
|
||||||
if (shareButton != null) shareButton.visible = SHARED_TAB.equals(cat);
|
if (shareButton != null) shareButton.visible = SHARED_TAB.equals(cat);
|
||||||
rebuildCosmeticRow();
|
rebuildCosmeticRow();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void setBoneGroupSubFilter(@Nullable String sub) {
|
||||||
|
selectedSubFilter = sub;
|
||||||
|
rebuildCosmeticRow();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sub-category pill row for the currently selected bone-group tab. Reuses the
|
||||||
|
* shared-tab filter bar layout constants so the row lines up visually. */
|
||||||
|
private void buildBoneGroupFilterBar(String[] subs) {
|
||||||
|
int fx = COSMETIC_ROW_LEFT;
|
||||||
|
// "All" first — null subFilter means show every sub-category in the group.
|
||||||
|
Button allBtn = Button.builder(
|
||||||
|
Component.literal("All"),
|
||||||
|
b -> setBoneGroupSubFilter(null)
|
||||||
|
).bounds(fx, SHARED_FILTER_Y, SHARED_FILTER_BTN_W, SHARED_FILTER_H).build();
|
||||||
|
allBtn.active = selectedSubFilter != null;
|
||||||
|
addRenderableWidget(allBtn);
|
||||||
|
filterPillButtons.add(allBtn);
|
||||||
|
fx += SHARED_FILTER_BTN_W + SHARED_FILTER_BTN_GAP;
|
||||||
|
|
||||||
|
for (String sub : subs) {
|
||||||
|
final String key = sub;
|
||||||
|
Button pill = Button.builder(
|
||||||
|
Component.literal(capitalize(sub)),
|
||||||
|
b -> setBoneGroupSubFilter(key)
|
||||||
|
).bounds(fx, SHARED_FILTER_Y, SHARED_FILTER_BTN_W, SHARED_FILTER_H).build();
|
||||||
|
pill.active = !sub.equals(selectedSubFilter);
|
||||||
|
addRenderableWidget(pill);
|
||||||
|
filterPillButtons.add(pill);
|
||||||
|
fx += SHARED_FILTER_BTN_W + SHARED_FILTER_BTN_GAP;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String capitalize(String s) {
|
||||||
|
return s.isEmpty() ? s : Character.toUpperCase(s.charAt(0)) + s.substring(1);
|
||||||
|
}
|
||||||
|
|
||||||
private void rebuildCosmeticRow() {
|
private void rebuildCosmeticRow() {
|
||||||
for (Button btn : cosmeticButtons) removeWidget(btn);
|
for (Button btn : cosmeticButtons) removeWidget(btn);
|
||||||
cosmeticButtons.clear();
|
cosmeticButtons.clear();
|
||||||
|
for (Button btn : filterPillButtons) removeWidget(btn);
|
||||||
|
filterPillButtons.clear();
|
||||||
sharedButtonHashes.clear();
|
sharedButtonHashes.clear();
|
||||||
infoButtonHashes.clear();
|
infoButtonHashes.clear();
|
||||||
|
|
||||||
@@ -319,13 +445,30 @@ public class ClosetScreen extends Screen {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bone-group tab? Show sub-filter pills and compute the allowed category set.
|
||||||
|
// Standalone tabs (particle) use a single-category set matching the tab name.
|
||||||
|
String[] groupSubs = BONE_GROUPS.get(selectedCategory);
|
||||||
|
if (groupSubs != null) {
|
||||||
|
buildBoneGroupFilterBar(groupSubs);
|
||||||
|
}
|
||||||
|
java.util.Set<String> allowed = new java.util.HashSet<>();
|
||||||
|
if (groupSubs != null) {
|
||||||
|
if (selectedSubFilter != null) {
|
||||||
|
allowed.add(selectedSubFilter);
|
||||||
|
} else {
|
||||||
|
for (String s : groupSubs) allowed.add(s);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
allowed.add(selectedCategory);
|
||||||
|
}
|
||||||
|
|
||||||
int x = COSMETIC_ROW_LEFT - cosmeticScroll;
|
int x = COSMETIC_ROW_LEFT - cosmeticScroll;
|
||||||
int y = this.height - COSMETIC_ROW_BOTTOM_MARGIN;
|
int y = this.height - COSMETIC_ROW_BOTTOM_MARGIN;
|
||||||
|
|
||||||
// Authored cosmetics for this category.
|
// Authored cosmetics whose category falls in the allowed set.
|
||||||
for (Map.Entry<Identifier, CosmeticDefinition> entry : CosmeticCache.catalog.entrySet()) {
|
for (Map.Entry<Identifier, CosmeticDefinition> entry : CosmeticCache.catalog.entrySet()) {
|
||||||
CosmeticDefinition def = entry.getValue();
|
CosmeticDefinition def = entry.getValue();
|
||||||
if (!selectedCategory.equals(def.category())) continue;
|
if (!allowed.contains(def.category())) continue;
|
||||||
|
|
||||||
Identifier id = entry.getKey();
|
Identifier id = entry.getKey();
|
||||||
// No text label — the mini-player preview + green-border-when-equipped already
|
// No text label — the mini-player preview + green-border-when-equipped already
|
||||||
@@ -355,7 +498,10 @@ public class ClosetScreen extends Screen {
|
|||||||
: CosmeticLibrary.EMPTY;
|
: CosmeticLibrary.EMPTY;
|
||||||
|
|
||||||
for (CosmeticLibraryEntry libEntry : lib.entries()) {
|
for (CosmeticLibraryEntry libEntry : lib.entries()) {
|
||||||
if (!BoneExtraction.categoriesFor(libEntry.bones()).contains(selectedCategory)) continue;
|
java.util.Set<String> entryCats = BoneExtraction.categoriesFor(libEntry.bones());
|
||||||
|
boolean overlap = false;
|
||||||
|
for (String c : entryCats) { if (allowed.contains(c)) { overlap = true; break; } }
|
||||||
|
if (!overlap) continue;
|
||||||
|
|
||||||
// Pull from server if the blob hasn't materialized locally yet.
|
// Pull from server if the blob hasn't materialized locally yet.
|
||||||
ClientSharedCosmeticCache.requestIfMissing(libEntry.hash());
|
ClientSharedCosmeticCache.requestIfMissing(libEntry.hash());
|
||||||
@@ -597,7 +743,7 @@ public class ClosetScreen extends Screen {
|
|||||||
* Derive the equip category from the cosmetic's primary bone and toggle equip in
|
* 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
|
* 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
|
* on the wire tells the server to clear the slot (see
|
||||||
* {@link com.razz.dfashion.cosmetic.share.CosmeticShareNetwork#onAssignShared}).
|
* {@code CosmeticShareNetwork.onAssignShared}).
|
||||||
*/
|
*/
|
||||||
private void equipShared(CosmeticLibraryEntry entry) {
|
private void equipShared(CosmeticLibraryEntry entry) {
|
||||||
String category = BoneExtraction.primaryCategory(entry.bones());
|
String category = BoneExtraction.primaryCategory(entry.bones());
|
||||||
@@ -1090,6 +1236,15 @@ public class ClosetScreen extends Screen {
|
|||||||
LocalPlayer player = mc.player;
|
LocalPlayer player = mc.player;
|
||||||
if (player == null) return;
|
if (player == null) return;
|
||||||
|
|
||||||
|
// Server-synced config may have flipped mid-session (op edited the TOML). Rebuild
|
||||||
|
// the entire screen so tabs/toggles appear or disappear to match.
|
||||||
|
boolean sharedNow = DecoFashionConfig.SHARED_LIBRARY_ENABLED.get();
|
||||||
|
boolean hideNow = DecoFashionConfig.BODY_HIDE_ENABLED.get();
|
||||||
|
if (sharedNow != lastSharedEnabled || hideNow != lastBodyHideEnabled) {
|
||||||
|
this.rebuildWidgets();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// When on the skin tab, rebuild the grid when the cached-skin count changes — this
|
// 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)
|
// covers uploads (which add a new entry asynchronously after the server echo arrives)
|
||||||
// and any new skin received from another player.
|
// and any new skin received from another player.
|
||||||
@@ -1375,12 +1530,25 @@ public class ClosetScreen extends Screen {
|
|||||||
Map<String, com.razz.dfashion.cosmetic.CosmeticRef> liveEquipped =
|
Map<String, com.razz.dfashion.cosmetic.CosmeticRef> liveEquipped =
|
||||||
player.getData(CosmeticAttachments.EQUIPPED.get());
|
player.getData(CosmeticAttachments.EQUIPPED.get());
|
||||||
|
|
||||||
|
// Must match the allowed-set logic in rebuildCosmeticRow so button indices line up.
|
||||||
|
String[] groupSubs = BONE_GROUPS.get(selectedCategory);
|
||||||
|
java.util.Set<String> allowedCats = new java.util.HashSet<>();
|
||||||
|
if (groupSubs != null) {
|
||||||
|
if (selectedSubFilter != null) {
|
||||||
|
allowedCats.add(selectedSubFilter);
|
||||||
|
} else {
|
||||||
|
for (String s : groupSubs) allowedCats.add(s);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
allowedCats.add(selectedCategory);
|
||||||
|
}
|
||||||
|
|
||||||
// Authored loop first. Shared entries appended to this category tab render via
|
// Authored loop first. Shared entries appended to this category tab render via
|
||||||
// renderSharedPreviews below — they're not matched by the catalog iteration.
|
// renderSharedPreviews below — they're not matched by the catalog iteration.
|
||||||
int idx = 0;
|
int idx = 0;
|
||||||
for (Map.Entry<Identifier, CosmeticDefinition> entry : CosmeticCache.catalog.entrySet()) {
|
for (Map.Entry<Identifier, CosmeticDefinition> entry : CosmeticCache.catalog.entrySet()) {
|
||||||
CosmeticDefinition def = entry.getValue();
|
CosmeticDefinition def = entry.getValue();
|
||||||
if (!selectedCategory.equals(def.category())) continue;
|
if (!allowedCats.contains(def.category())) continue;
|
||||||
if (idx >= cosmeticButtons.size()) break;
|
if (idx >= cosmeticButtons.size()) break;
|
||||||
|
|
||||||
Button btn = cosmeticButtons.get(idx);
|
Button btn = cosmeticButtons.get(idx);
|
||||||
@@ -1514,7 +1682,7 @@ public class ClosetScreen extends Screen {
|
|||||||
/**
|
/**
|
||||||
* Same 3D-mini-player preview pattern as cosmetics, but per-box we swap the render state's
|
* 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.
|
* {@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
|
* {@link SkinInfoOverride} runs at tick-level on {@link net.minecraft.client.multiplayer.PlayerInfo}, not on these
|
||||||
* preview states, so our per-box swap here isn't clobbered.
|
* preview states, so our per-box swap here isn't clobbered.
|
||||||
*/
|
*/
|
||||||
private void renderSkinPreviews(GuiGraphicsExtractor graphics, LocalPlayer player) {
|
private void renderSkinPreviews(GuiGraphicsExtractor graphics, LocalPlayer player) {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package com.razz.dfashion.cosmetic;
|
package com.razz.dfashion.cosmetic;
|
||||||
|
|
||||||
import com.mojang.brigadier.CommandDispatcher;
|
import com.mojang.brigadier.CommandDispatcher;
|
||||||
|
import com.mojang.brigadier.arguments.BoolArgumentType;
|
||||||
import com.mojang.brigadier.arguments.StringArgumentType;
|
import com.mojang.brigadier.arguments.StringArgumentType;
|
||||||
import com.mojang.brigadier.context.CommandContext;
|
import com.mojang.brigadier.context.CommandContext;
|
||||||
import com.razz.dfashion.DecoFashion;
|
import com.razz.dfashion.DecoFashion;
|
||||||
|
import com.razz.dfashion.DecoFashionConfig;
|
||||||
|
|
||||||
import net.minecraft.commands.CommandSourceStack;
|
import net.minecraft.commands.CommandSourceStack;
|
||||||
import net.minecraft.commands.Commands;
|
import net.minecraft.commands.Commands;
|
||||||
@@ -35,9 +37,52 @@ public final class CosmeticCommands {
|
|||||||
.executes(CosmeticCommands::unequip)))
|
.executes(CosmeticCommands::unequip)))
|
||||||
.then(Commands.literal("list")
|
.then(Commands.literal("list")
|
||||||
.executes(CosmeticCommands::list))
|
.executes(CosmeticCommands::list))
|
||||||
|
.then(Commands.literal("config")
|
||||||
|
.requires(s -> {
|
||||||
|
ServerPlayer p = s.getPlayer();
|
||||||
|
// Console source has no player — allow. Player source must be op.
|
||||||
|
return p == null || p.permissions().hasPermission(
|
||||||
|
net.minecraft.server.permissions.Permissions.COMMANDS_GAMEMASTER);
|
||||||
|
})
|
||||||
|
.then(Commands.literal("get")
|
||||||
|
.executes(CosmeticCommands::configGet))
|
||||||
|
.then(Commands.literal("sharedLibrary")
|
||||||
|
.then(Commands.argument("value", BoolArgumentType.bool())
|
||||||
|
.executes(ctx -> setConfig(ctx, "sharedLibrary",
|
||||||
|
BoolArgumentType.getBool(ctx, "value")))))
|
||||||
|
.then(Commands.literal("bodyHide")
|
||||||
|
.then(Commands.argument("value", BoolArgumentType.bool())
|
||||||
|
.executes(ctx -> setConfig(ctx, "bodyHide",
|
||||||
|
BoolArgumentType.getBool(ctx, "value"))))))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static int configGet(CommandContext<CommandSourceStack> ctx) {
|
||||||
|
ctx.getSource().sendSuccess(() -> Component.literal(
|
||||||
|
"sharedLibrary = " + DecoFashionConfig.SHARED_LIBRARY_ENABLED.get()
|
||||||
|
+ ", bodyHide = " + DecoFashionConfig.BODY_HIDE_ENABLED.get()
|
||||||
|
), false);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int setConfig(CommandContext<CommandSourceStack> ctx, String key, boolean value) {
|
||||||
|
switch (key) {
|
||||||
|
case "sharedLibrary" -> DecoFashionConfig.SHARED_LIBRARY_ENABLED.set(value);
|
||||||
|
case "bodyHide" -> DecoFashionConfig.BODY_HIDE_ENABLED.set(value);
|
||||||
|
default -> {
|
||||||
|
ctx.getSource().sendFailure(Component.literal("Unknown config key: " + key));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DecoFashionConfig.SPEC.save();
|
||||||
|
ctx.getSource().sendSuccess(
|
||||||
|
() -> Component.literal("Set " + key + " = " + value
|
||||||
|
+ " (change applied immediately)"),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
private static int equip(CommandContext<CommandSourceStack> ctx) {
|
private static int equip(CommandContext<CommandSourceStack> ctx) {
|
||||||
ServerPlayer player = ctx.getSource().getPlayer();
|
ServerPlayer player = ctx.getSource().getPlayer();
|
||||||
if (player == null) {
|
if (player == null) {
|
||||||
|
|||||||
@@ -18,9 +18,20 @@ import java.util.List;
|
|||||||
*/
|
*/
|
||||||
public record CosmeticLibrary(List<CosmeticLibraryEntry> entries) {
|
public record CosmeticLibrary(List<CosmeticLibraryEntry> entries) {
|
||||||
|
|
||||||
/** Per-player cap. Cosmetics have multiple slots and benefit from more variants than skins. */
|
/** Fallback cap, used when the server config hasn't been loaded yet. The authoritative
|
||||||
|
* per-player cap is {@link com.razz.dfashion.DecoFashionConfig#COSMETICS_PER_PLAYER}. */
|
||||||
public static final int MAX_ENTRIES = 15;
|
public static final int MAX_ENTRIES = 15;
|
||||||
|
|
||||||
|
/** Returns the server-configured per-player cap, falling back to {@link #MAX_ENTRIES}
|
||||||
|
* if the config isn't loaded yet (which happens during early client setup). */
|
||||||
|
public static int maxEntries() {
|
||||||
|
try {
|
||||||
|
return com.razz.dfashion.DecoFashionConfig.COSMETICS_PER_PLAYER.get();
|
||||||
|
} catch (IllegalStateException e) {
|
||||||
|
return MAX_ENTRIES;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static final CosmeticLibrary EMPTY = new CosmeticLibrary(List.of());
|
public static final CosmeticLibrary EMPTY = new CosmeticLibrary(List.of());
|
||||||
|
|
||||||
public static final Codec<CosmeticLibrary> CODEC =
|
public static final Codec<CosmeticLibrary> CODEC =
|
||||||
@@ -39,7 +50,7 @@ public record CosmeticLibrary(List<CosmeticLibraryEntry> entries) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public boolean isFull() {
|
public boolean isFull() {
|
||||||
return entries.size() >= MAX_ENTRIES;
|
return entries.size() >= maxEntries();
|
||||||
}
|
}
|
||||||
|
|
||||||
public CosmeticLibrary add(CosmeticLibraryEntry entry) {
|
public CosmeticLibrary add(CosmeticLibraryEntry entry) {
|
||||||
|
|||||||
@@ -102,6 +102,27 @@ public final class CosmeticShareNetwork {
|
|||||||
MinecraftServer server = player.level().getServer();
|
MinecraftServer server = player.level().getServer();
|
||||||
if (server == null) return;
|
if (server == null) return;
|
||||||
|
|
||||||
|
// Shared library disabled by server config: unequip any currently-equipped Shared
|
||||||
|
// refs so the player doesn't see a broken cosmetic. Keep the library attachment
|
||||||
|
// itself (preserves the player's personal collection if the op re-enables the
|
||||||
|
// feature later).
|
||||||
|
if (!com.razz.dfashion.DecoFashionConfig.SHARED_LIBRARY_ENABLED.get()) {
|
||||||
|
Map<String, CosmeticRef> equipped = player.getData(CosmeticAttachments.EQUIPPED.get());
|
||||||
|
Map<String, CosmeticRef> withoutShared = null;
|
||||||
|
for (Map.Entry<String, CosmeticRef> e : equipped.entrySet()) {
|
||||||
|
if (e.getValue() instanceof CosmeticRef.Shared) {
|
||||||
|
if (withoutShared == null) withoutShared = new HashMap<>(equipped);
|
||||||
|
withoutShared.remove(e.getKey());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (withoutShared != null) {
|
||||||
|
player.setData(CosmeticAttachments.EQUIPPED.get(), withoutShared);
|
||||||
|
DecoFashion.LOGGER.info("Unequipped shared cosmetics for {} (feature disabled)",
|
||||||
|
player.getUUID());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get());
|
CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get());
|
||||||
if (lib.entries().isEmpty()) return;
|
if (lib.entries().isEmpty()) return;
|
||||||
|
|
||||||
@@ -192,6 +213,11 @@ public final class CosmeticShareNetwork {
|
|||||||
private static void onUploadChunk(UploadCosmeticChunk msg, IPayloadContext ctx) {
|
private static void onUploadChunk(UploadCosmeticChunk msg, IPayloadContext ctx) {
|
||||||
ctx.enqueueWork(() -> {
|
ctx.enqueueWork(() -> {
|
||||||
if (!(ctx.player() instanceof ServerPlayer player)) return;
|
if (!(ctx.player() instanceof ServerPlayer player)) return;
|
||||||
|
if (!com.razz.dfashion.DecoFashionConfig.SHARED_LIBRARY_ENABLED.get()) {
|
||||||
|
DecoFashion.LOGGER.debug("Rejecting upload from {}: shared library disabled by server config",
|
||||||
|
player.getUUID());
|
||||||
|
return;
|
||||||
|
}
|
||||||
MinecraftServer server = player.level().getServer();
|
MinecraftServer server = player.level().getServer();
|
||||||
if (server == null) return;
|
if (server == null) return;
|
||||||
|
|
||||||
@@ -278,6 +304,11 @@ public final class CosmeticShareNetwork {
|
|||||||
private static void onAssignShared(AssignSharedCosmetic msg, IPayloadContext ctx) {
|
private static void onAssignShared(AssignSharedCosmetic msg, IPayloadContext ctx) {
|
||||||
ctx.enqueueWork(() -> {
|
ctx.enqueueWork(() -> {
|
||||||
if (!(ctx.player() instanceof ServerPlayer player)) return;
|
if (!(ctx.player() instanceof ServerPlayer player)) return;
|
||||||
|
if (!com.razz.dfashion.DecoFashionConfig.SHARED_LIBRARY_ENABLED.get()) {
|
||||||
|
DecoFashion.LOGGER.debug("Rejecting assign-shared from {}: shared library disabled",
|
||||||
|
player.getUUID());
|
||||||
|
return;
|
||||||
|
}
|
||||||
String slot = msg.slot();
|
String slot = msg.slot();
|
||||||
if (slot == null || slot.isEmpty() || slot.length() > 64) {
|
if (slot == null || slot.isEmpty() || slot.length() > 64) {
|
||||||
DecoFashion.LOGGER.warn("AssignSharedCosmetic from {}: bad slot", player.getUUID());
|
DecoFashion.LOGGER.warn("AssignSharedCosmetic from {}: bad slot", player.getUUID());
|
||||||
|
|||||||
@@ -390,7 +390,7 @@ public final class SharedCosmeticCache {
|
|||||||
CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get());
|
CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get());
|
||||||
if (!lib.contains(hash) && lib.isFull()) {
|
if (!lib.contains(hash) && lib.isFull()) {
|
||||||
DecoFashion.LOGGER.warn("Cosmetic upload from {}: library full ({}); rejecting {}",
|
DecoFashion.LOGGER.warn("Cosmetic upload from {}: library full ({}); rejecting {}",
|
||||||
playerId, CosmeticLibrary.MAX_ENTRIES, hash);
|
playerId, CosmeticLibrary.maxEntries(), hash);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package com.razz.dfashion.mixin;
|
||||||
|
|
||||||
|
import com.razz.dfashion.DecoFashionConfig;
|
||||||
|
import com.razz.dfashion.client.ClosetPreferences;
|
||||||
|
|
||||||
|
import net.minecraft.client.Minecraft;
|
||||||
|
import net.minecraft.client.model.HumanoidModel;
|
||||||
|
import net.minecraft.client.model.geom.ModelPart;
|
||||||
|
import net.minecraft.client.model.player.PlayerModel;
|
||||||
|
import net.minecraft.client.renderer.entity.state.AvatarRenderState;
|
||||||
|
|
||||||
|
import org.spongepowered.asm.mixin.Mixin;
|
||||||
|
import org.spongepowered.asm.mixin.Shadow;
|
||||||
|
import org.spongepowered.asm.mixin.injection.At;
|
||||||
|
import org.spongepowered.asm.mixin.injection.Inject;
|
||||||
|
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends local-player body-part hiding to the end of {@code PlayerModel.setupAnim}.
|
||||||
|
*
|
||||||
|
* <p>Using {@code @Inject} at {@code @At("TAIL")} with no cancellation and a
|
||||||
|
* mod-namespaced method name is the most compatible mixin form we can use:
|
||||||
|
* <ul>
|
||||||
|
* <li>Other mods that inject at HEAD/MIDDLE still see their invocations
|
||||||
|
* unchanged; we only run after {@code super.setupAnim}.</li>
|
||||||
|
* <li>Other mods that inject at TAIL run alongside ours, ordered by Mixin
|
||||||
|
* priority. Since we only <em>set</em> visibility to false (never true),
|
||||||
|
* we're purely subtractive — we can't override another mod's decision to
|
||||||
|
* hide a part.</li>
|
||||||
|
* <li>The injected method name is {@code decofashion$...} so it can't
|
||||||
|
* collide with other mods' injections on the same target.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Only applies when the render state's entity id matches the local player,
|
||||||
|
* so remote players are unaffected even though they share this PlayerModel
|
||||||
|
* instance.
|
||||||
|
*
|
||||||
|
* <p>We extend {@link HumanoidModel} so inherited fields ({@code head},
|
||||||
|
* {@code body}, {@code leftArm}, etc.) resolve without requiring {@code @Shadow}
|
||||||
|
* on each. The constructor body is never executed — Mixin discards it — but
|
||||||
|
* it must exist for the Java compiler.
|
||||||
|
*/
|
||||||
|
@Mixin(PlayerModel.class)
|
||||||
|
public abstract class PlayerModelMixin extends HumanoidModel<AvatarRenderState> {
|
||||||
|
|
||||||
|
private PlayerModelMixin(ModelPart root) {
|
||||||
|
super(root);
|
||||||
|
throw new AssertionError();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Shadow public ModelPart jacket;
|
||||||
|
@Shadow public ModelPart leftSleeve;
|
||||||
|
@Shadow public ModelPart rightSleeve;
|
||||||
|
@Shadow public ModelPart leftPants;
|
||||||
|
@Shadow public ModelPart rightPants;
|
||||||
|
|
||||||
|
@Inject(
|
||||||
|
method = "setupAnim(Lnet/minecraft/client/renderer/entity/state/AvatarRenderState;)V",
|
||||||
|
at = @At("TAIL")
|
||||||
|
)
|
||||||
|
private void decofashion$applyHideToggles(AvatarRenderState state, CallbackInfo ci) {
|
||||||
|
Minecraft mc = Minecraft.getInstance();
|
||||||
|
boolean isLocal = mc.player != null && state.id == mc.player.getId();
|
||||||
|
boolean featureOn = DecoFashionConfig.BODY_HIDE_ENABLED.get();
|
||||||
|
|
||||||
|
// head.visible MUST be set every call because:
|
||||||
|
// - vanilla setupAnim doesn't touch it, and
|
||||||
|
// - the PlayerModel instance is shared across every player of the same skin type.
|
||||||
|
// If we only set it "sometimes", a prior hide leaks into every subsequent render
|
||||||
|
// (local OR remote). Always resolve to the correct value for this specific state.
|
||||||
|
this.head.visible = !(isLocal && featureOn && ClosetPreferences.hideHead);
|
||||||
|
|
||||||
|
// Everything below is a pure-subtractive override scoped to the local player
|
||||||
|
// when the feature is enabled. vanilla setupAnim resets body/arms/legs and the
|
||||||
|
// overlay bones per-frame based on the state, so we don't need to restore those
|
||||||
|
// for the "off" case — they correct themselves on the next render.
|
||||||
|
if (!isLocal || !featureOn) return;
|
||||||
|
|
||||||
|
if (ClosetPreferences.hideHead) {
|
||||||
|
this.hat.visible = false;
|
||||||
|
}
|
||||||
|
if (ClosetPreferences.hideBody) {
|
||||||
|
this.body.visible = false;
|
||||||
|
this.jacket.visible = false;
|
||||||
|
}
|
||||||
|
if (ClosetPreferences.hideArms) {
|
||||||
|
this.rightArm.visible = false;
|
||||||
|
this.leftArm.visible = false;
|
||||||
|
this.rightSleeve.visible = false;
|
||||||
|
this.leftSleeve.visible = false;
|
||||||
|
}
|
||||||
|
if (ClosetPreferences.hideLegs) {
|
||||||
|
this.rightLeg.visible = false;
|
||||||
|
this.leftLeg.visible = false;
|
||||||
|
this.rightPants.visible = false;
|
||||||
|
this.leftPants.visible = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -218,7 +218,7 @@ public final class SkinCache {
|
|||||||
SkinLibrary lib = player.getData(SkinAttachments.LIBRARY.get());
|
SkinLibrary lib = player.getData(SkinAttachments.LIBRARY.get());
|
||||||
if (!lib.contains(hash) && lib.isFull()) {
|
if (!lib.contains(hash) && lib.isFull()) {
|
||||||
DecoFashion.LOGGER.warn("Skin upload from {}: library full ({}); rejecting {}",
|
DecoFashion.LOGGER.warn("Skin upload from {}: library full ({}); rejecting {}",
|
||||||
playerId, SkinLibrary.MAX_ENTRIES, hash);
|
playerId, SkinLibrary.maxEntries(), hash);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,18 @@ import java.util.List;
|
|||||||
*/
|
*/
|
||||||
public record SkinLibrary(List<SkinLibraryEntry> entries) {
|
public record SkinLibrary(List<SkinLibraryEntry> entries) {
|
||||||
|
|
||||||
|
/** Fallback cap. Authoritative per-player cap is
|
||||||
|
* {@link com.razz.dfashion.DecoFashionConfig#SKINS_PER_PLAYER}. */
|
||||||
public static final int MAX_ENTRIES = 5;
|
public static final int MAX_ENTRIES = 5;
|
||||||
|
|
||||||
|
public static int maxEntries() {
|
||||||
|
try {
|
||||||
|
return com.razz.dfashion.DecoFashionConfig.SKINS_PER_PLAYER.get();
|
||||||
|
} catch (IllegalStateException e) {
|
||||||
|
return MAX_ENTRIES;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static final SkinLibrary EMPTY = new SkinLibrary(List.of());
|
public static final SkinLibrary EMPTY = new SkinLibrary(List.of());
|
||||||
|
|
||||||
public static final Codec<SkinLibrary> CODEC =
|
public static final Codec<SkinLibrary> CODEC =
|
||||||
@@ -39,7 +50,7 @@ public record SkinLibrary(List<SkinLibraryEntry> entries) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public boolean isFull() {
|
public boolean isFull() {
|
||||||
return entries.size() >= MAX_ENTRIES;
|
return entries.size() >= maxEntries();
|
||||||
}
|
}
|
||||||
|
|
||||||
public SkinLibrary add(SkinLibraryEntry entry) {
|
public SkinLibrary add(SkinLibraryEntry entry) {
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"required": true,
|
||||||
|
"minVersion": "0.8",
|
||||||
|
"package": "com.razz.dfashion.mixin",
|
||||||
|
"compatibilityLevel": "JAVA_21",
|
||||||
|
"client": [
|
||||||
|
"PlayerModelMixin"
|
||||||
|
],
|
||||||
|
"injectors": {
|
||||||
|
"defaultRequire": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,8 +44,8 @@ Example mod description.
|
|||||||
'''
|
'''
|
||||||
|
|
||||||
# The [[mixins]] block allows you to declare your mixin config to FML so that it gets loaded.
|
# The [[mixins]] block allows you to declare your mixin config to FML so that it gets loaded.
|
||||||
#[[mixins]]
|
[[mixins]]
|
||||||
#config="${mod_id}.mixins.json"
|
config="${mod_id}.mixins.json"
|
||||||
|
|
||||||
# The [[accessTransformers]] block allows you to declare where your AT file is.
|
# The [[accessTransformers]] block allows you to declare where your AT file is.
|
||||||
# If this block is omitted, a fallback attempt will be made to load an AT from META-INF/accesstransformer.cfg
|
# If this block is omitted, a fallback attempt will be made to load an AT from META-INF/accesstransformer.cfg
|
||||||
|
|||||||
Reference in New Issue
Block a user