diff --git a/.gitignore b/.gitignore index fee2f7b..95f6130 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ run/ **/src/generated/**/.cache/ repo/ !**/src/**/repo/ +.claude/ diff --git a/src/main/java/com/razz/dfashion/DecoFashion.java b/src/main/java/com/razz/dfashion/DecoFashion.java index a0e1230..accca66 100644 --- a/src/main/java/com/razz/dfashion/DecoFashion.java +++ b/src/main/java/com/razz/dfashion/DecoFashion.java @@ -10,6 +10,7 @@ import com.razz.dfashion.skin.SkinAttachments; import net.neoforged.bus.api.IEventBus; import net.neoforged.fml.ModContainer; import net.neoforged.fml.common.Mod; +import net.neoforged.fml.config.ModConfig; import net.neoforged.fml.event.lifecycle.FMLCommonSetupEvent; @Mod(DecoFashion.MODID) @@ -22,6 +23,11 @@ public class DecoFashion { CosmeticAttachments.ATTACHMENT_TYPES.register(modEventBus); SkinAttachments.ATTACHMENT_TYPES.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) { diff --git a/src/main/java/com/razz/dfashion/DecoFashionClient.java b/src/main/java/com/razz/dfashion/DecoFashionClient.java index 71b4e7f..ca7dafe 100644 --- a/src/main/java/com/razz/dfashion/DecoFashionClient.java +++ b/src/main/java/com/razz/dfashion/DecoFashionClient.java @@ -10,6 +10,7 @@ import com.razz.dfashion.block.ClosetRegistry; import com.razz.dfashion.client.ClientSharedCosmeticCache; import com.razz.dfashion.client.ClientSkinCache; import com.razz.dfashion.client.ClosetModelCache; +import com.razz.dfashion.client.ClosetPreferences; import com.razz.dfashion.client.ClosetRenderer; import com.razz.dfashion.client.CosmeticCache; import com.razz.dfashion.client.CosmeticRenderLayer; @@ -58,6 +59,7 @@ public class DecoFashionClient { @SubscribeEvent static void onClientSetup(FMLClientSetupEvent event) { + ClosetPreferences.load(); DecoFashion.LOGGER.info("DecoFashion client setup complete"); } diff --git a/src/main/java/com/razz/dfashion/DecoFashionConfig.java b/src/main/java/com/razz/dfashion/DecoFashionConfig.java new file mode 100644 index 0000000..54a7713 --- /dev/null +++ b/src/main/java/com/razz/dfashion/DecoFashionConfig.java @@ -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). + * + *

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 equipped = p.getData(CosmeticAttachments.EQUIPPED.get()); + Map cleaned = null; + for (Map.Entry 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()); + } + } + } +} diff --git a/src/main/java/com/razz/dfashion/client/ClosetPreferences.java b/src/main/java/com/razz/dfashion/client/ClosetPreferences.java new file mode 100644 index 0000000..be2d808 --- /dev/null +++ b/src/main/java/com/razz/dfashion/client/ClosetPreferences.java @@ -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; + } +} diff --git a/src/main/java/com/razz/dfashion/client/CosmeticRenderLayer.java b/src/main/java/com/razz/dfashion/client/CosmeticRenderLayer.java index 99d30f6..4526dde 100644 --- a/src/main/java/com/razz/dfashion/client/CosmeticRenderLayer.java +++ b/src/main/java/com/razz/dfashion/client/CosmeticRenderLayer.java @@ -91,7 +91,7 @@ public class CosmeticRenderLayer extends RenderLayer localCache ) { return switch (ref) { @@ -131,7 +131,7 @@ public class CosmeticRenderLayer extends RenderLayer snake_case lowercase. */ - private static String normalize(String name) { + static String normalize(String name) { StringBuilder sb = new StringBuilder(name.length() + 4); for (int i = 0; i < name.length(); i++) { char c = name.charAt(i); diff --git a/src/main/java/com/razz/dfashion/client/FirstPersonArmCosmeticRenderer.java b/src/main/java/com/razz/dfashion/client/FirstPersonArmCosmeticRenderer.java new file mode 100644 index 0000000..7e903b6 --- /dev/null +++ b/src/main/java/com/razz/dfashion/client/FirstPersonArmCosmeticRenderer.java @@ -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. + * + *

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 RIGHT_ARM_BONES = Set.of("right_arm", "right_sleeve"); + private static final Set 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 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 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 cache = CosmeticCache.cosmetics; + + for (CosmeticRef ref : equipped.values()) { + CosmeticCache.Baked cosmetic = CosmeticRenderLayer.resolve(ref, cache); + if (cosmetic == null) continue; + + for (Map.Entry 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(); + } + } + } +} diff --git a/src/main/java/com/razz/dfashion/client/screen/ClosetScreen.java b/src/main/java/com/razz/dfashion/client/screen/ClosetScreen.java index 6063051..37c5c77 100644 --- a/src/main/java/com/razz/dfashion/client/screen/ClosetScreen.java +++ b/src/main/java/com/razz/dfashion/client/screen/ClosetScreen.java @@ -1,10 +1,12 @@ package com.razz.dfashion.client.screen; +import com.razz.dfashion.DecoFashionConfig; import com.razz.dfashion.block.ClosetBlockEntity; import com.razz.dfashion.client.ClientCosmeticInfoCache; import com.razz.dfashion.client.ClientSharedCosmeticCache; import com.razz.dfashion.client.ClientSharedCosmeticUploader; import com.razz.dfashion.client.ClientSkinCache; +import com.razz.dfashion.client.ClosetPreferences; import com.razz.dfashion.client.CosmeticCache; import com.razz.dfashion.client.CosmeticRenderLayer; 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.state.AvatarRenderState; import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.ChatFormatting; import net.minecraft.network.chat.Component; import net.minecraft.resources.Identifier; import net.minecraft.world.entity.LivingEntity; @@ -75,15 +78,28 @@ 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" - }; + // Tab vocabulary — matches category memory. Top-level tabs collapse the old per-category + // list into bone-group tabs + three standalones ("particle", "skin", "shared"). Selecting + // a bone-group tab exposes a sub-filter pill row so the author can narrow within the group. + // "skin" is a pseudo-category that drives the per-player skin override UI rather than the + // usual cosmetic row; "shared" is the shared-cosmetic library (vertical scroll grid). private static final String SKIN_TAB = "skin"; 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 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. */ private static final String FALLBACK_SHARED_CATEGORY = "particle"; @@ -121,6 +137,7 @@ public class ClosetScreen extends Screen { private CameraType previousCamera; private @Nullable Button leftArrow, rightArrow; 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 // rotating doesn't pan the camera — only the player's visible body/head turn. private float displayYaw = 0f; @@ -128,6 +145,10 @@ public class ClosetScreen extends Screen { private @Nullable String selectedCategory; private int cosmeticScroll = 0; private final List