From 42994b15a33c6d13fc177171b53ee66388d658fa Mon Sep 17 00:00:00 2001 From: MomokoKoigakubo Date: Sat, 18 Apr 2026 05:01:43 -0400 Subject: [PATCH] Closet block + wardrobe UI + bbmodel cosmetic pipeline - Closet BlockEntity with open/closed state; BER renders bbmodels via CosmeticRenderLayer - bbmodel parser: format_version 5.0 gate, - ClosetBlock right-click: teleport player to 'player_stand_location' locator, force THIRD_PERSON_FRONT, open wardrobe Screen - ClosetScreen: horizontal tabs, scrollable cosmetic row with drag to scroll, mini player previews via graphics.entity + per State override map in CosmeticRenderLayer, left/right rotate buttons (yaw-only, camera fixed), Toggle button hides previews - Click on player navigation: world to screen project equipped cosmetic bone anchors, jump to matching tab + scroll to that item - Placed test_hat, pike + mock entries to exercise overflow/scroll (clean this up later) --- authoring/player_rig.bbmodel | 47 ++ .../java/com/razz/dfashion/DecoFashion.java | 4 + .../com/razz/dfashion/DecoFashionClient.java | 210 +++++++ .../com/razz/dfashion/bbmodel/BbFace.java | 2 +- .../razz/dfashion/bbmodel/BbModelParser.java | 16 +- .../com/razz/dfashion/bbmodel/Bbmodel.java | 2 + .../razz/dfashion/bbmodel/BbmodelBaker.java | 171 ++++++ .../com/razz/dfashion/block/ClosetBlock.java | 79 +++ .../dfashion/block/ClosetBlockEntity.java | 58 ++ .../razz/dfashion/block/ClosetRegistry.java | 54 ++ .../dfashion/client/ClosetClientHandler.java | 81 +++ .../dfashion/client/ClosetModelCache.java | 22 + .../dfashion/client/ClosetRenderState.java | 9 + .../razz/dfashion/client/ClosetRenderer.java | 73 +++ .../razz/dfashion/client/CosmeticCache.java | 20 + .../dfashion/client/CosmeticRenderLayer.java | 129 +++++ .../dfashion/client/screen/ClosetScreen.java | 514 ++++++++++++++++++ .../cosmetic/CosmeticAttachments.java | 41 ++ .../dfashion/cosmetic/CosmeticCatalog.java | 70 +++ .../dfashion/cosmetic/CosmeticCommands.java | 101 ++++ .../dfashion/cosmetic/CosmeticDefinition.java | 10 + .../decofashion/blockstates/closet.json | 8 + .../decofashion/closet/closet_7.bbmodel | 1 + .../decofashion/closet/closet_7_open.bbmodel | 1 + .../assets/decofashion/cosmetic/pike.bbmodel | 1 + .../decofashion/cosmetic/test_hat.bbmodel | 1 + .../assets/decofashion/cosmetics.json | 57 ++ .../decofashion/models/block/closet.json | 5 + .../decofashion/models/item/closet.json | 3 + .../textures/closet/closet_base_spruce.png | Bin 0 -> 33394 bytes .../decofashion/textures/cosmetic/pike.png | Bin 0 -> 622 bytes .../textures/cosmetic/test_hat.png | Bin 0 -> 320 bytes 32 files changed, 1788 insertions(+), 2 deletions(-) create mode 100644 authoring/player_rig.bbmodel create mode 100644 src/main/java/com/razz/dfashion/bbmodel/BbmodelBaker.java create mode 100644 src/main/java/com/razz/dfashion/block/ClosetBlock.java create mode 100644 src/main/java/com/razz/dfashion/block/ClosetBlockEntity.java create mode 100644 src/main/java/com/razz/dfashion/block/ClosetRegistry.java create mode 100644 src/main/java/com/razz/dfashion/client/ClosetClientHandler.java create mode 100644 src/main/java/com/razz/dfashion/client/ClosetModelCache.java create mode 100644 src/main/java/com/razz/dfashion/client/ClosetRenderState.java create mode 100644 src/main/java/com/razz/dfashion/client/ClosetRenderer.java create mode 100644 src/main/java/com/razz/dfashion/client/CosmeticCache.java create mode 100644 src/main/java/com/razz/dfashion/client/CosmeticRenderLayer.java create mode 100644 src/main/java/com/razz/dfashion/client/screen/ClosetScreen.java create mode 100644 src/main/java/com/razz/dfashion/cosmetic/CosmeticAttachments.java create mode 100644 src/main/java/com/razz/dfashion/cosmetic/CosmeticCatalog.java create mode 100644 src/main/java/com/razz/dfashion/cosmetic/CosmeticCommands.java create mode 100644 src/main/java/com/razz/dfashion/cosmetic/CosmeticDefinition.java create mode 100644 src/main/resources/assets/decofashion/blockstates/closet.json create mode 100644 src/main/resources/assets/decofashion/closet/closet_7.bbmodel create mode 100644 src/main/resources/assets/decofashion/closet/closet_7_open.bbmodel create mode 100644 src/main/resources/assets/decofashion/cosmetic/pike.bbmodel create mode 100644 src/main/resources/assets/decofashion/cosmetic/test_hat.bbmodel create mode 100644 src/main/resources/assets/decofashion/cosmetics.json create mode 100644 src/main/resources/assets/decofashion/models/block/closet.json create mode 100644 src/main/resources/assets/decofashion/models/item/closet.json create mode 100644 src/main/resources/assets/decofashion/textures/closet/closet_base_spruce.png create mode 100644 src/main/resources/assets/decofashion/textures/cosmetic/pike.png create mode 100644 src/main/resources/assets/decofashion/textures/cosmetic/test_hat.png diff --git a/authoring/player_rig.bbmodel b/authoring/player_rig.bbmodel new file mode 100644 index 0000000..d5232a7 --- /dev/null +++ b/authoring/player_rig.bbmodel @@ -0,0 +1,47 @@ +{ + "meta": { + "format_version": "5.0", + "model_format": "bedrock", + "box_uv": true + }, + "name": "player_rig", + "model_identifier": "player", + "visible_box": [2, 2, 0], + "variable_placeholders": "", + "variable_placeholder_buttons": [], + "bedrock_animation_mode": "entity", + "timeline_setups": [], + "unhandled_root_fields": {}, + "resolution": {"width": 64, "height": 64}, + "elements": [ + {"name":"head", "box_uv":true, "from":[-4,24,-4], "to":[4,32,4], "origin":[0,24,0], "uv_offset":[0,0], "type":"cube", "uuid":"a1111111-0000-0000-0000-000000000001"}, + {"name":"hat", "box_uv":true, "from":[-4,24,-4], "to":[4,32,4], "origin":[0,24,0], "uv_offset":[32,0], "inflate":0.5, "type":"cube", "uuid":"a1111111-0000-0000-0000-000000000002"}, + {"name":"body", "box_uv":true, "from":[-4,12,-2], "to":[4,24,2], "origin":[0,24,0], "uv_offset":[16,16], "type":"cube", "uuid":"a1111111-0000-0000-0000-000000000003"}, + {"name":"jacket", "box_uv":true, "from":[-4,12,-2], "to":[4,24,2], "origin":[0,24,0], "uv_offset":[16,32], "inflate":0.25,"type":"cube", "uuid":"a1111111-0000-0000-0000-000000000004"}, + {"name":"right_arm", "box_uv":true, "from":[-8,12,-2], "to":[-4,24,2], "origin":[-5,22,0], "uv_offset":[40,16], "type":"cube", "uuid":"a1111111-0000-0000-0000-000000000005"}, + {"name":"right_sleeve", "box_uv":true, "from":[-8,12,-2], "to":[-4,24,2], "origin":[-5,22,0], "uv_offset":[40,32], "inflate":0.25,"type":"cube", "uuid":"a1111111-0000-0000-0000-000000000006"}, + {"name":"left_arm", "box_uv":true, "from":[4,12,-2], "to":[8,24,2], "origin":[5,22,0], "uv_offset":[32,48], "type":"cube", "uuid":"a1111111-0000-0000-0000-000000000007"}, + {"name":"left_sleeve", "box_uv":true, "from":[4,12,-2], "to":[8,24,2], "origin":[5,22,0], "uv_offset":[48,48], "inflate":0.25,"type":"cube", "uuid":"a1111111-0000-0000-0000-000000000008"}, + {"name":"right_leg", "box_uv":true, "from":[-3.9,0,-2],"to":[0.1,12,2], "origin":[-1.9,12,0], "uv_offset":[0,16], "type":"cube", "uuid":"a1111111-0000-0000-0000-000000000009"}, + {"name":"right_pants", "box_uv":true, "from":[-3.9,0,-2],"to":[0.1,12,2], "origin":[-1.9,12,0], "uv_offset":[0,32], "inflate":0.25,"type":"cube", "uuid":"a1111111-0000-0000-0000-00000000000a"}, + {"name":"left_leg", "box_uv":true, "from":[-0.1,0,-2],"to":[3.9,12,2], "origin":[1.9,12,0], "uv_offset":[16,48], "type":"cube", "uuid":"a1111111-0000-0000-0000-00000000000b"}, + {"name":"left_pants", "box_uv":true, "from":[-0.1,0,-2],"to":[3.9,12,2], "origin":[1.9,12,0], "uv_offset":[0,48], "inflate":0.25,"type":"cube", "uuid":"a1111111-0000-0000-0000-00000000000c"} + ], + "groups": [ + {"name":"Head", "uuid":"b2222222-0000-0000-0000-000000000001","origin":[0,24,0], "rotation":[0,0,0],"visibility":true,"color":0,"export":true,"isOpen":true,"children":[]}, + {"name":"Body", "uuid":"b2222222-0000-0000-0000-000000000002","origin":[0,24,0], "rotation":[0,0,0],"visibility":true,"color":0,"export":true,"isOpen":true,"children":[]}, + {"name":"RightArm", "uuid":"b2222222-0000-0000-0000-000000000003","origin":[-5,22,0], "rotation":[0,0,0],"visibility":true,"color":0,"export":true,"isOpen":true,"children":[]}, + {"name":"LeftArm", "uuid":"b2222222-0000-0000-0000-000000000004","origin":[5,22,0], "rotation":[0,0,0],"visibility":true,"color":0,"export":true,"isOpen":true,"children":[]}, + {"name":"RightLeg", "uuid":"b2222222-0000-0000-0000-000000000005","origin":[-1.9,12,0], "rotation":[0,0,0],"visibility":true,"color":0,"export":true,"isOpen":true,"children":[]}, + {"name":"LeftLeg", "uuid":"b2222222-0000-0000-0000-000000000006","origin":[1.9,12,0], "rotation":[0,0,0],"visibility":true,"color":0,"export":true,"isOpen":true,"children":[]} + ], + "outliner": [ + {"uuid":"b2222222-0000-0000-0000-000000000001","name":"Head","isOpen":true,"children":["a1111111-0000-0000-0000-000000000001","a1111111-0000-0000-0000-000000000002"]}, + {"uuid":"b2222222-0000-0000-0000-000000000002","name":"Body","isOpen":true,"children":["a1111111-0000-0000-0000-000000000003","a1111111-0000-0000-0000-000000000004"]}, + {"uuid":"b2222222-0000-0000-0000-000000000003","name":"RightArm","isOpen":true,"children":["a1111111-0000-0000-0000-000000000005","a1111111-0000-0000-0000-000000000006"]}, + {"uuid":"b2222222-0000-0000-0000-000000000004","name":"LeftArm","isOpen":true,"children":["a1111111-0000-0000-0000-000000000007","a1111111-0000-0000-0000-000000000008"]}, + {"uuid":"b2222222-0000-0000-0000-000000000005","name":"RightLeg","isOpen":true,"children":["a1111111-0000-0000-0000-000000000009","a1111111-0000-0000-0000-00000000000a"]}, + {"uuid":"b2222222-0000-0000-0000-000000000006","name":"LeftLeg","isOpen":true,"children":["a1111111-0000-0000-0000-00000000000b","a1111111-0000-0000-0000-00000000000c"]} + ], + "textures": [] +} diff --git a/src/main/java/com/razz/dfashion/DecoFashion.java b/src/main/java/com/razz/dfashion/DecoFashion.java index 505302c..e5dd1b6 100644 --- a/src/main/java/com/razz/dfashion/DecoFashion.java +++ b/src/main/java/com/razz/dfashion/DecoFashion.java @@ -3,6 +3,8 @@ package com.razz.dfashion; import org.slf4j.Logger; import com.mojang.logging.LogUtils; +import com.razz.dfashion.block.ClosetRegistry; +import com.razz.dfashion.cosmetic.CosmeticAttachments; import net.neoforged.bus.api.IEventBus; import net.neoforged.fml.ModContainer; @@ -16,6 +18,8 @@ public class DecoFashion { public DecoFashion(IEventBus modEventBus, ModContainer modContainer) { modEventBus.addListener(this::commonSetup); + CosmeticAttachments.ATTACHMENT_TYPES.register(modEventBus); + ClosetRegistry.register(modEventBus); } 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 546544d..5abe5de 100644 --- a/src/main/java/com/razz/dfashion/DecoFashionClient.java +++ b/src/main/java/com/razz/dfashion/DecoFashionClient.java @@ -1,10 +1,40 @@ package com.razz.dfashion; +import com.razz.dfashion.bbmodel.BbGroup; +import com.razz.dfashion.bbmodel.BbLocator; +import com.razz.dfashion.bbmodel.BbModelParser; +import com.razz.dfashion.bbmodel.BbOutlinerNode; +import com.razz.dfashion.bbmodel.Bbmodel; +import com.razz.dfashion.bbmodel.BbmodelBaker; +import com.razz.dfashion.block.ClosetRegistry; +import com.razz.dfashion.client.ClosetModelCache; +import com.razz.dfashion.client.ClosetRenderer; +import com.razz.dfashion.client.CosmeticCache; +import com.razz.dfashion.client.CosmeticRenderLayer; +import com.razz.dfashion.cosmetic.CosmeticCatalog; +import com.razz.dfashion.cosmetic.CosmeticDefinition; + +import net.minecraft.client.model.geom.ModelPart; +import net.minecraft.client.player.AbstractClientPlayer; +import net.minecraft.client.renderer.entity.player.AvatarRenderer; +import net.minecraft.resources.Identifier; +import net.minecraft.server.packs.resources.Resource; +import net.minecraft.server.packs.resources.ResourceManager; +import net.minecraft.server.packs.resources.ResourceManagerReloadListener; +import net.minecraft.world.entity.player.PlayerModelType; import net.neoforged.api.distmarker.Dist; import net.neoforged.bus.api.SubscribeEvent; import net.neoforged.fml.common.EventBusSubscriber; import net.neoforged.fml.common.Mod; import net.neoforged.fml.event.lifecycle.FMLClientSetupEvent; +import net.neoforged.neoforge.client.event.AddClientReloadListenersEvent; +import net.neoforged.neoforge.client.event.EntityRenderersEvent; + +import java.io.IOException; +import java.io.Reader; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; @Mod(value = DecoFashion.MODID, dist = Dist.CLIENT) @EventBusSubscriber(modid = DecoFashion.MODID, value = Dist.CLIENT) @@ -14,4 +44,184 @@ public class DecoFashionClient { static void onClientSetup(FMLClientSetupEvent event) { DecoFashion.LOGGER.info("DecoFashion client setup complete"); } + + @SubscribeEvent + static void onAddClientReloadListeners(AddClientReloadListenersEvent event) { + event.addListener( + Identifier.fromNamespaceAndPath(DecoFashion.MODID, "bbmodel_debug"), + (ResourceManagerReloadListener) DecoFashionClient::load + ); + } + + @SubscribeEvent + static void onAddLayers(EntityRenderersEvent.AddLayers event) { + for (PlayerModelType type : event.getSkins()) { + AvatarRenderer renderer = event.getPlayerRenderer(type); + if (renderer != null) { + renderer.addLayer(new CosmeticRenderLayer(renderer)); + } + } + } + + @SubscribeEvent + static void onRegisterRenderers(EntityRenderersEvent.RegisterRenderers event) { + event.registerBlockEntityRenderer(ClosetRegistry.CLOSET_BE.get(), ClosetRenderer::new); + } + + static void load(ResourceManager rm) { + Map catalog = CosmeticCatalog.loadAll(rm); + Map baked = new HashMap<>(); + for (Map.Entry entry : catalog.entrySet()) { + loadOne(rm, entry.getKey(), entry.getValue(), baked); + } + CosmeticCache.cosmetics = baked; + CosmeticCache.catalog = catalog; + DecoFashion.LOGGER.info("DecoFashion: {} cosmetic(s) loaded", baked.size()); + + loadCloset(rm); + } + + /** + * Resolve each locator's absolute bbmodel-space position by walking its parent chain. + * Locator {@code position} is already absolute bbmodel coords; the walk exists to apply + * any ancestor group's rotation around that group's origin (pivot). Matches the algorithm + * in decocraft's DecoSeatBlock.getNodePosition(). + */ + private static Map resolveLocators(Bbmodel model) { + Map groupById = new HashMap<>(); + for (BbGroup g : model.groups()) groupById.put(g.uuid(), g); + + Map locatorById = new HashMap<>(); + for (BbLocator loc : model.locators()) locatorById.put(loc.uuid(), loc); + + Map out = new HashMap<>(); + // parentChain tracks the groups from root → current, so we can replay their rotations. + java.util.ArrayDeque parentChain = new java.util.ArrayDeque<>(); + for (BbOutlinerNode node : model.outliner()) { + walkForLocators(node, parentChain, groupById, locatorById, out); + } + return out; + } + + private static void walkForLocators(BbOutlinerNode node, + java.util.ArrayDeque parentChain, + Map groupById, + Map locatorById, + Map out) { + switch (node) { + case BbOutlinerNode.ElementRef er -> { + BbLocator loc = locatorById.get(er.uuid()); + if (loc != null) out.put(loc.name(), applyParentRotations(loc.position(), parentChain)); + } + case BbOutlinerNode.GroupRef gr -> { + BbGroup g = groupById.get(gr.uuid()); + if (g == null) return; + parentChain.push(g); + for (BbOutlinerNode child : gr.children()) { + walkForLocators(child, parentChain, groupById, locatorById, out); + } + parentChain.pop(); + } + } + } + + private static org.joml.Vector3f applyParentRotations(org.joml.Vector3f absolute, + java.util.ArrayDeque parentChain) { + org.joml.Vector3f p = new org.joml.Vector3f(absolute); + // Walk from immediate parent up to root, rotating the point around each parent's pivot. + for (BbGroup parent : parentChain) { + org.joml.Vector3f r = parent.rotation(); + if (r == null || (r.x == 0f && r.y == 0f && r.z == 0f)) continue; + org.joml.Vector3f o = parent.origin(); + p.sub(o); + if (r.x != 0f) rotateX(p, (float) Math.toRadians(r.x)); + if (r.y != 0f) rotateY(p, (float) Math.toRadians(r.y)); + if (r.z != 0f) rotateZ(p, (float) Math.toRadians(r.z)); + p.add(o); + } + return p; + } + + private static void rotateX(org.joml.Vector3f v, float a) { + float c = (float) Math.cos(a), s = (float) Math.sin(a); + float y = v.y * c - v.z * s, z = v.y * s + v.z * c; + v.y = y; v.z = z; + } + + private static void rotateY(org.joml.Vector3f v, float a) { + float c = (float) Math.cos(a), s = (float) Math.sin(a); + float x = v.x * c + v.z * s, z = -v.x * s + v.z * c; + v.x = x; v.z = z; + } + + private static void rotateZ(org.joml.Vector3f v, float a) { + float c = (float) Math.cos(a), s = (float) Math.sin(a); + float x = v.x * c - v.y * s, y = v.x * s + v.y * c; + v.x = x; v.y = y; + } + + private static void loadCloset(ResourceManager rm) { + Identifier closedModel = Identifier.fromNamespaceAndPath( + DecoFashion.MODID, "closet/closet_7.bbmodel"); + Identifier openModel = Identifier.fromNamespaceAndPath( + DecoFashion.MODID, "closet/closet_7_open.bbmodel"); + Identifier texture = Identifier.fromNamespaceAndPath( + DecoFashion.MODID, "textures/closet/closet_base_spruce.png"); + + ClosetModelCache.closed = bakeCloset(rm, closedModel, texture); + ClosetModelCache.open = bakeCloset(rm, openModel, texture); + } + + private static ClosetModelCache.Baked bakeCloset( + ResourceManager rm, Identifier modelPath, Identifier texturePath + ) { + Optional res = rm.getResource(modelPath); + if (res.isEmpty()) { + DecoFashion.LOGGER.warn("Closet: no bbmodel at {}", modelPath); + return null; + } + try (Reader reader = res.get().openAsReader()) { + Bbmodel model = BbModelParser.parse(reader); + Map parts = BbmodelBaker.bake( + model, model.resolutionWidth(), model.resolutionHeight(), true); + Map locators = resolveLocators(model); + DecoFashion.LOGGER.info( + "Loaded closet model {} (res {}x{}): parts={}, locators={}", + modelPath, model.resolutionWidth(), model.resolutionHeight(), + parts.keySet(), locators + ); + return new ClosetModelCache.Baked(parts, locators, texturePath); + } catch (IOException ex) { + DecoFashion.LOGGER.error("Failed to read closet model {}", modelPath, ex); + return null; + } + } + + private static void loadOne( + ResourceManager rm, + Identifier cosmeticId, + CosmeticDefinition def, + Map out + ) { + Optional res = rm.getResource(def.model()); + if (res.isEmpty()) { + DecoFashion.LOGGER.warn("Cosmetic {}: no bbmodel at {}", cosmeticId, def.model()); + return; + } + + try (Reader reader = res.get().openAsReader()) { + Bbmodel model = BbModelParser.parse(reader); + Map parts = BbmodelBaker.bake( + model, model.resolutionWidth(), model.resolutionHeight(), true); + out.put(cosmeticId, new CosmeticCache.Baked(parts, def.texture())); + DecoFashion.LOGGER.info( + "Loaded cosmetic {} [{}] (res {}x{}): parts={}", + cosmeticId, def.displayName(), + model.resolutionWidth(), model.resolutionHeight(), parts.keySet() + ); + } catch (IOException ex) { + DecoFashion.LOGGER.error("Failed to read {}", def.model(), ex); + } + } + } diff --git a/src/main/java/com/razz/dfashion/bbmodel/BbFace.java b/src/main/java/com/razz/dfashion/bbmodel/BbFace.java index 0391dcd..fbdbdf6 100644 --- a/src/main/java/com/razz/dfashion/bbmodel/BbFace.java +++ b/src/main/java/com/razz/dfashion/bbmodel/BbFace.java @@ -1,4 +1,4 @@ package com.razz.dfashion.bbmodel; -public record BbFace (float[] uv, int texture, int rotation){ +public record BbFace (float[] uv, Integer texture, int rotation){ } diff --git a/src/main/java/com/razz/dfashion/bbmodel/BbModelParser.java b/src/main/java/com/razz/dfashion/bbmodel/BbModelParser.java index f5d1ef5..73b174e 100644 --- a/src/main/java/com/razz/dfashion/bbmodel/BbModelParser.java +++ b/src/main/java/com/razz/dfashion/bbmodel/BbModelParser.java @@ -19,6 +19,20 @@ public class BbModelParser { public static Bbmodel parse(Reader in) { JsonObject root = JsonParser.parseReader(in).getAsJsonObject(); + JsonObject meta = root.has("meta") ? root.getAsJsonObject("meta") : null; + String formatVersion = meta != null && meta.has("format_version") + ? meta.get("format_version").getAsString() : "unknown"; + if (!formatVersion.startsWith("5.")) { + throw new JsonParseException( + "Unsupported bbmodel format_version '" + formatVersion + + "' — re-save in Blockbench as 5.0+." + ); + } + + JsonObject res = root.has("resolution") ? root.getAsJsonObject("resolution") : null; + int resW = res != null && res.has("width") ? res.get("width").getAsInt() : 64; + int resH = res != null && res.has("height") ? res.get("height").getAsInt() : 64; + List cubes = new ArrayList<>(); List locators = new ArrayList<>(); @@ -42,7 +56,7 @@ public class BbModelParser { new TypeToken>(){}.getType() ); - return new Bbmodel(cubes, locators, groups, outliner); + return new Bbmodel(resW, resH, cubes, locators, groups, outliner); } diff --git a/src/main/java/com/razz/dfashion/bbmodel/Bbmodel.java b/src/main/java/com/razz/dfashion/bbmodel/Bbmodel.java index 96a4785..20b74c5 100644 --- a/src/main/java/com/razz/dfashion/bbmodel/Bbmodel.java +++ b/src/main/java/com/razz/dfashion/bbmodel/Bbmodel.java @@ -3,6 +3,8 @@ package com.razz.dfashion.bbmodel; import java.util.List; public record Bbmodel( + int resolutionWidth, + int resolutionHeight, List elements, List locators, List groups, diff --git a/src/main/java/com/razz/dfashion/bbmodel/BbmodelBaker.java b/src/main/java/com/razz/dfashion/bbmodel/BbmodelBaker.java new file mode 100644 index 0000000..3d548b9 --- /dev/null +++ b/src/main/java/com/razz/dfashion/bbmodel/BbmodelBaker.java @@ -0,0 +1,171 @@ +package com.razz.dfashion.bbmodel; + +import net.minecraft.client.model.geom.ModelPart; +import net.minecraft.core.Direction; +import org.joml.Vector3f; + +import java.util.*; + +public class BbmodelBaker { + + public static Map bake(Bbmodel model, int texWidth, int texHeight, boolean mirrorX) { + Map cubeIndex = new HashMap<>(); + for (BbCube c : model.elements()) cubeIndex.put(c.uuid(), c); + + Map groupIndex = new HashMap<>(); + for (BbGroup g : model.groups()) groupIndex.put(g.uuid(), g); + + Map result = new HashMap<>(); + for (BbOutlinerNode node : model.outliner()) { + if (node instanceof BbOutlinerNode.GroupRef gref) { + BbGroup g = groupIndex.get(gref.uuid()); + // Top-level: parentOrigin = the group's own origin so the ModelPart's + // position resolves to (0, 0, 0). bone.translateAndRotate in the render + // layer already places us at the bone, so the ModelPart itself doesn't + // need to translate again. + result.put(g.name(), + buildPart(gref, g, g.origin(), cubeIndex, groupIndex, texWidth, texHeight, mirrorX)); + } + // ElementRef at root = loose cube, skip (no bone to attach to) + } + return result; + } + + private static ModelPart buildPart( + BbOutlinerNode.GroupRef gref, + BbGroup group, + Vector3f parentOrigin, + Map cubeIndex, + Map groupIndex, + int texWidth, int texHeight, + boolean mirrorX + ) { + List cubes = new ArrayList<>(); + Map children = new LinkedHashMap<>(); + + for (BbOutlinerNode child : gref.children()) { + switch (child) { + case BbOutlinerNode.ElementRef er -> { + BbCube bb = cubeIndex.get(er.uuid()); + if (bb == null) continue; // locator ref, skip + + if (isRotated(bb)) { + // ModelPart.Cube is axis-aligned, so per-cube rotation is done + // by wrapping the cube in its own one-cube ModelPart whose pose + // carries the rotation. + ModelPart.Cube inner = bbCubeToCube(bb, bb.origin(), texWidth, texHeight, mirrorX); + ModelPart wrapper = new ModelPart(List.of(inner), Map.of()); + wrapper.setPos( + bb.origin().x - group.origin().x, + bb.origin().y - group.origin().y, + bb.origin().z - group.origin().z + ); + wrapper.setRotation( + (float) Math.toRadians(bb.rotation().x), + (float) Math.toRadians(bb.rotation().y), + (float) Math.toRadians(bb.rotation().z) + ); + children.put("cube_" + bb.uuid(), wrapper); + } else { + cubes.add(bbCubeToCube(bb, group.origin(), texWidth, texHeight, mirrorX)); + } + } + case BbOutlinerNode.GroupRef nested -> { + BbGroup ng = groupIndex.get(nested.uuid()); + children.put(ng.name(), + buildPart(nested, ng, group.origin(), cubeIndex, groupIndex, texWidth, texHeight, mirrorX)); + } + } + } + + ModelPart part = new ModelPart(cubes, children); + part.setPos( + group.origin().x - parentOrigin.x, + group.origin().y - parentOrigin.y, + group.origin().z - parentOrigin.z + ); + part.setRotation( + (float) Math.toRadians(group.rotation().x), + (float) Math.toRadians(group.rotation().y), + (float) Math.toRadians(group.rotation().z) + ); + return part; + } + + private static boolean isRotated(BbCube bb) { + Vector3f r = bb.rotation(); + return r != null && (r.x != 0f || r.y != 0f || r.z != 0f); + } + + private static ModelPart.Cube bbCubeToCube( + BbCube bb, Vector3f referenceOrigin, int texWidth, int texHeight, boolean mirrorX + ) { + float x = bb.from().x - referenceOrigin.x; + float y = bb.from().y - referenceOrigin.y; + float z = bb.from().z - referenceOrigin.z; + + float w = bb.to().x - bb.from().x; + float h = bb.to().y - bb.from().y; + float d = bb.to().z - bb.from().z; + + // Which faces are visible — texture == -1 means hidden in bbmodel. + Set visibleFaces = EnumSet.noneOf(Direction.class); + if (bb.faces() != null) { + bb.faces().forEach((key, face) -> { + if (face.texture() != null && face.texture() >= 0) { + Direction dir = directionFromName(key); + if (dir != null) visibleFaces.add(dir); + } + }); + } + + // Vanilla ctor gives us vertex geometry + placeholder box-UV polygons. + // mirrorX=true flips the cube on X so faces orient correctly after the + // player render-layer's scale(-1, -1, 1) un-flip. Block-entity renderers + // don't apply that pre-flip, so they pass mirrorX=false. + ModelPart.Cube cube = new ModelPart.Cube( + 0, 0, x, y, z, w, h, d, 0, 0, 0, mirrorX, texWidth, texHeight, visibleFaces + ); + + // Vanilla writes polygons[] in this order, skipping any direction not in visibleFaces. + Direction[] order = { + Direction.DOWN, Direction.UP, Direction.WEST, + Direction.NORTH, Direction.EAST, Direction.SOUTH + }; + + int idx = 0; + for (Direction dir : order) { + if (!visibleFaces.contains(dir)) continue; + BbFace bbFace = bb.faces().get(nameOf(dir)); + if (bbFace != null && bbFace.uv() != null && bbFace.uv().length >= 4) { + cube.polygons[idx] = new ModelPart.Polygon( + cube.polygons[idx].vertices(), + bbFace.uv()[0], bbFace.uv()[1], + bbFace.uv()[2], bbFace.uv()[3], + texWidth, texHeight, + mirrorX, + dir + ); + } + idx++; + } + + return cube; + } + + private static String nameOf(Direction d) { + return d.getName(); + } + + private static Direction directionFromName(String name) { + return switch (name) { + case "down" -> Direction.DOWN; + case "up" -> Direction.UP; + case "north" -> Direction.NORTH; + case "south" -> Direction.SOUTH; + case "west" -> Direction.WEST; + case "east" -> Direction.EAST; + default -> null; + }; + } +} diff --git a/src/main/java/com/razz/dfashion/block/ClosetBlock.java b/src/main/java/com/razz/dfashion/block/ClosetBlock.java new file mode 100644 index 0000000..b678a17 --- /dev/null +++ b/src/main/java/com/razz/dfashion/block/ClosetBlock.java @@ -0,0 +1,79 @@ +package com.razz.dfashion.block; + +import com.mojang.serialization.MapCodec; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.context.BlockPlaceContext; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.BaseEntityBlock; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.HorizontalDirectionalBlock; +import net.minecraft.world.level.block.Mirror; +import net.minecraft.world.level.block.Rotation; +import net.minecraft.world.level.block.RenderShape; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.StateDefinition; +import net.minecraft.world.level.block.state.properties.EnumProperty; +import net.minecraft.world.phys.BlockHitResult; + +public class ClosetBlock extends BaseEntityBlock { + + public static final MapCodec CODEC = simpleCodec(ClosetBlock::new); + public static final EnumProperty FACING = HorizontalDirectionalBlock.FACING; + + public ClosetBlock(Properties properties) { + super(properties); + registerDefaultState(defaultBlockState().setValue(FACING, Direction.NORTH)); + } + + @Override + protected MapCodec codec() { + return CODEC; + } + + @Override + protected void createBlockStateDefinition(StateDefinition.Builder builder) { + builder.add(FACING); + } + + @Override + public BlockState getStateForPlacement(BlockPlaceContext ctx) { + return defaultBlockState().setValue(FACING, ctx.getHorizontalDirection().getOpposite()); + } + + @Override + protected BlockState rotate(BlockState state, Rotation rotation) { + return state.setValue(FACING, rotation.rotate(state.getValue(FACING))); + } + + @Override + protected BlockState mirror(BlockState state, Mirror mirror) { + return state.rotate(mirror.getRotation(state.getValue(FACING))); + } + + @Override + public BlockEntity newBlockEntity(BlockPos pos, BlockState state) { + return new ClosetBlockEntity(pos, state); + } + + // Hides the default block model so only the BlockEntityRenderer draws. + @Override + protected RenderShape getRenderShape(BlockState state) { + return RenderShape.INVISIBLE; + } + + @Override + protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos, + Player player, BlockHitResult hit) { + if (level.isClientSide()) { + com.razz.dfashion.client.ClosetClientHandler.onRightClick(pos, state); + } + // Server does nothing — open/closed is a client-side visual tied to the wardrobe + // Screen's lifetime (open on Screen init, close on Screen close). + return InteractionResult.SUCCESS; + } +} diff --git a/src/main/java/com/razz/dfashion/block/ClosetBlockEntity.java b/src/main/java/com/razz/dfashion/block/ClosetBlockEntity.java new file mode 100644 index 0000000..7a88bea --- /dev/null +++ b/src/main/java/com/razz/dfashion/block/ClosetBlockEntity.java @@ -0,0 +1,58 @@ +package com.razz.dfashion.block; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.HolderLookup; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.protocol.Packet; +import net.minecraft.network.protocol.game.ClientGamePacketListener; +import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; + +import org.jspecify.annotations.Nullable; + +public class ClosetBlockEntity extends BlockEntity { + + private boolean open = false; + + public ClosetBlockEntity(BlockPos pos, BlockState state) { + super(ClosetRegistry.CLOSET_BE.get(), pos, state); + } + + public boolean isOpen() { + return open; + } + + public void setOpen(boolean open) { + if (this.open == open) return; + this.open = open; + setChanged(); + if (level != null && !level.isClientSide()) { + level.sendBlockUpdated(worldPosition, getBlockState(), getBlockState(), 3); + } + } + + @Override + protected void saveAdditional(ValueOutput output) { + super.saveAdditional(output); + output.putBoolean("open", open); + } + + @Override + protected void loadAdditional(ValueInput input) { + super.loadAdditional(input); + this.open = input.getBooleanOr("open", false); + } + + @Override + public CompoundTag getUpdateTag(HolderLookup.Provider registries) { + return saveWithoutMetadata(registries); + } + + @Override + public @Nullable Packet getUpdatePacket() { + return ClientboundBlockEntityDataPacket.create(this); + } +} diff --git a/src/main/java/com/razz/dfashion/block/ClosetRegistry.java b/src/main/java/com/razz/dfashion/block/ClosetRegistry.java new file mode 100644 index 0000000..31bbd3a --- /dev/null +++ b/src/main/java/com/razz/dfashion/block/ClosetRegistry.java @@ -0,0 +1,54 @@ +package com.razz.dfashion.block; + +import com.razz.dfashion.DecoFashion; + +import net.minecraft.core.registries.Registries; +import net.minecraft.world.item.BlockItem; +import net.minecraft.world.item.Item; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.entity.BlockEntityType; +import net.minecraft.world.level.block.state.BlockBehaviour; +import net.minecraft.world.level.material.MapColor; +import net.neoforged.bus.api.IEventBus; +import net.neoforged.neoforge.registries.DeferredBlock; +import net.neoforged.neoforge.registries.DeferredHolder; +import net.neoforged.neoforge.registries.DeferredItem; +import net.neoforged.neoforge.registries.DeferredRegister; + +public final class ClosetRegistry { + + public static final DeferredRegister.Blocks BLOCKS = + DeferredRegister.createBlocks(DecoFashion.MODID); + + public static final DeferredRegister.Items ITEMS = + DeferredRegister.createItems(DecoFashion.MODID); + + public static final DeferredRegister> BLOCK_ENTITIES = + DeferredRegister.create(Registries.BLOCK_ENTITY_TYPE, DecoFashion.MODID); + + @SuppressWarnings("removal") + public static final DeferredBlock CLOSET = BLOCKS.registerBlock( + "closet", + ClosetBlock::new, + BlockBehaviour.Properties.of() + .mapColor(MapColor.WOOD) + .strength(2.0f) + .noOcclusion() + ); + + public static final DeferredItem CLOSET_ITEM = ITEMS.registerSimpleBlockItem(CLOSET); + + public static final DeferredHolder, BlockEntityType> CLOSET_BE = + BLOCK_ENTITIES.register( + "closet", + () -> new BlockEntityType<>(ClosetBlockEntity::new, CLOSET.get()) + ); + + public static void register(IEventBus bus) { + BLOCKS.register(bus); + ITEMS.register(bus); + BLOCK_ENTITIES.register(bus); + } + + private ClosetRegistry() {} +} diff --git a/src/main/java/com/razz/dfashion/client/ClosetClientHandler.java b/src/main/java/com/razz/dfashion/client/ClosetClientHandler.java new file mode 100644 index 0000000..d611032 --- /dev/null +++ b/src/main/java/com/razz/dfashion/client/ClosetClientHandler.java @@ -0,0 +1,81 @@ +package com.razz.dfashion.client; + +import com.razz.dfashion.DecoFashion; +import com.razz.dfashion.block.ClosetBlock; +import com.razz.dfashion.block.ClosetBlockEntity; +import com.razz.dfashion.client.screen.ClosetScreen; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.world.level.block.state.BlockState; + +import org.joml.Vector3f; + +public final class ClosetClientHandler { + + public static void onRightClick(BlockPos pos, BlockState state) { + Minecraft mc = Minecraft.getInstance(); + LocalPlayer player = mc.player; + if (player == null || mc.level == null) return; + + ClosetModelCache.Baked closed = ClosetModelCache.closed; + if (closed != null) { + Vector3f anchor = closed.locators().get("player_stand_location"); + if (anchor != null) { + teleportToAnchor(player, pos, state.getValue(ClosetBlock.FACING), anchor); + } + } + + ClosetBlockEntity be = (mc.level.getBlockEntity(pos) instanceof ClosetBlockEntity cbe) ? cbe : null; + mc.setScreen(new ClosetScreen(be)); + } + + // Must mirror ClosetRenderer's net transform for the point to line up with the + // locator's visual position. The renderer does `rotateY(180° - facing.toYRot())` + // then `scale(-1, 1, 1)`, which applied to a bbmodel (x, z) point yields: + // NORTH → (-x, z) + // SOUTH → ( x, -z) + // EAST → (-z, -x) + // WEST → ( z, x) + // These are NOT the same as decocraft's rotatePosition — decocraft doesn't bake + // its cubes with mirrorX=true, so it has no compensating scale flip to account for. + private static void teleportToAnchor(LocalPlayer player, BlockPos blockPos, + Direction facing, Vector3f anchor) { + float lx = anchor.x / 16f; + float ly = anchor.y / 16f; + float lz = anchor.z / 16f; + + double rx, rz; + switch (facing) { + case NORTH -> { rx = -lx; rz = lz; } + case SOUTH -> { rx = lx; rz = -lz; } + case EAST -> { rx = -lz; rz = -lx; } + case WEST -> { rx = lz; rz = lx; } + default -> { rx = -lx; rz = lz; } + } + + double worldX = blockPos.getX() + 0.5 + rx; + double worldY = blockPos.getY() + ly; + double worldZ = blockPos.getZ() + 0.5 + rz; + + DecoFashion.LOGGER.info( + "Closet teleport: block={} facing={} anchor(px)={} scaled(X-zeroed)={} rotated=({},{},{}) world=({},{},{})", + blockPos, facing, anchor, + String.format("(%.3f, %.3f, %.3f)", lx, ly, lz), + String.format("%.3f", rx), String.format("%.3f", ly), String.format("%.3f", rz), + String.format("%.3f", worldX), String.format("%.3f", worldY), String.format("%.3f", worldZ) + ); + + player.setPos(worldX, worldY, worldZ); + // FACING = direction the doors face. Player stands on that side and faces + // the same direction (away from the block interior), so THIRD_PERSON_FRONT + // lands the camera out in front of the doors, looking back at the player + // with the closet visible behind them. + player.setYRot(facing.toYRot()); + player.setXRot(0f); + } + + private ClosetClientHandler() {} +} diff --git a/src/main/java/com/razz/dfashion/client/ClosetModelCache.java b/src/main/java/com/razz/dfashion/client/ClosetModelCache.java new file mode 100644 index 0000000..d386593 --- /dev/null +++ b/src/main/java/com/razz/dfashion/client/ClosetModelCache.java @@ -0,0 +1,22 @@ +package com.razz.dfashion.client; + +import net.minecraft.client.model.geom.ModelPart; +import net.minecraft.resources.Identifier; + +import org.joml.Vector3f; + +import java.util.Map; + +public final class ClosetModelCache { + + public record Baked( + Map parts, + Map locators, + Identifier texture + ) {} + + public static volatile Baked closed = null; + public static volatile Baked open = null; + + private ClosetModelCache() {} +} diff --git a/src/main/java/com/razz/dfashion/client/ClosetRenderState.java b/src/main/java/com/razz/dfashion/client/ClosetRenderState.java new file mode 100644 index 0000000..d818f64 --- /dev/null +++ b/src/main/java/com/razz/dfashion/client/ClosetRenderState.java @@ -0,0 +1,9 @@ +package com.razz.dfashion.client; + +import net.minecraft.client.renderer.blockentity.state.BlockEntityRenderState; +import net.minecraft.core.Direction; + +public class ClosetRenderState extends BlockEntityRenderState { + public boolean open = false; + public Direction facing = Direction.NORTH; +} diff --git a/src/main/java/com/razz/dfashion/client/ClosetRenderer.java b/src/main/java/com/razz/dfashion/client/ClosetRenderer.java new file mode 100644 index 0000000..8310491 --- /dev/null +++ b/src/main/java/com/razz/dfashion/client/ClosetRenderer.java @@ -0,0 +1,73 @@ +package com.razz.dfashion.client; + +import com.mojang.blaze3d.vertex.PoseStack; +import com.razz.dfashion.block.ClosetBlock; +import com.razz.dfashion.block.ClosetBlockEntity; + +import net.minecraft.client.model.geom.ModelPart; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.blockentity.BlockEntityRenderer; +import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider; +import net.minecraft.client.renderer.blockentity.state.BlockEntityRenderState; +import net.minecraft.client.renderer.feature.ModelFeatureRenderer; +import net.minecraft.client.renderer.rendertype.RenderTypes; +import net.minecraft.client.renderer.state.level.CameraRenderState; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.world.phys.Vec3; + +import org.jspecify.annotations.Nullable; + +public class ClosetRenderer implements BlockEntityRenderer { + + public ClosetRenderer(BlockEntityRendererProvider.Context ctx) {} + + @Override + public ClosetRenderState createRenderState() { + return new ClosetRenderState(); + } + + @Override + public void extractRenderState(ClosetBlockEntity be, ClosetRenderState state, + float partialTicks, Vec3 cameraPosition, + ModelFeatureRenderer.@Nullable CrumblingOverlay breakProgress) { + BlockEntityRenderState.extractBase(be, state, breakProgress); + state.open = be.isOpen(); + state.facing = be.getBlockState().getValue(ClosetBlock.FACING); + } + + @Override + public void submit(ClosetRenderState state, PoseStack poseStack, + SubmitNodeCollector collector, CameraRenderState camera) { + + ClosetModelCache.Baked baked = state.open ? ClosetModelCache.open : ClosetModelCache.closed; + if (baked == null) return; + + poseStack.pushPose(); + // Center on the block, then rotate to face the placed direction. + // Our bbmodels author the closet's door/front on -Z (not Blockbench's + // canonical +Z), so rotation = 180° - facing.toYRot() takes bbmodel -Z + // to world FACING. ModelPart.Cube already stores vertices pre-scaled by + // 1/16, so no additional pixel→block scaling is needed here. + poseStack.translate(0.5f, 0.0f, 0.5f); + poseStack.mulPose(new org.joml.Quaternionf().rotationY( + (float) Math.toRadians(180f - state.facing.toYRot()) + )); + // Cubes baked with mirrorX=true have X-flipped vertex winding (matches the + // cosmetic pipeline). BEs don't get the entity-renderer pre-flip, so we + // apply a compensating X-flip here to keep UVs landing on the right faces. + poseStack.scale(-1.0f, 1.0f, 1.0f); + + for (ModelPart part : baked.parts().values()) { + collector.submitModelPart( + part, + poseStack, + RenderTypes.entityCutout(baked.texture()), + state.lightCoords, + OverlayTexture.NO_OVERLAY, + null + ); + } + + poseStack.popPose(); + } +} diff --git a/src/main/java/com/razz/dfashion/client/CosmeticCache.java b/src/main/java/com/razz/dfashion/client/CosmeticCache.java new file mode 100644 index 0000000..3b88f7e --- /dev/null +++ b/src/main/java/com/razz/dfashion/client/CosmeticCache.java @@ -0,0 +1,20 @@ +package com.razz.dfashion.client; + +import com.razz.dfashion.cosmetic.CosmeticDefinition; + +import net.minecraft.client.model.geom.ModelPart; +import net.minecraft.resources.Identifier; + +import java.util.Map; + +public final class CosmeticCache { + public record Baked(Map parts, Identifier texture) {} + + /** Keyed by cosmetic id (e.g. {@code decofashion:test_hat}). */ + public static volatile Map cosmetics = Map.of(); + + /** Same keys as {@link #cosmetics} — kept alongside so UI can read display names / categories. */ + public static volatile Map catalog = Map.of(); + + private CosmeticCache() {} +} diff --git a/src/main/java/com/razz/dfashion/client/CosmeticRenderLayer.java b/src/main/java/com/razz/dfashion/client/CosmeticRenderLayer.java new file mode 100644 index 0000000..8aee56f --- /dev/null +++ b/src/main/java/com/razz/dfashion/client/CosmeticRenderLayer.java @@ -0,0 +1,129 @@ +package com.razz.dfashion.client; + +import com.mojang.blaze3d.vertex.PoseStack; +import com.razz.dfashion.cosmetic.CosmeticAttachments; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.model.geom.ModelPart; +import net.minecraft.client.model.player.PlayerModel; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.entity.RenderLayerParent; +import net.minecraft.client.renderer.entity.layers.RenderLayer; +import net.minecraft.client.renderer.entity.state.AvatarRenderState; +import net.minecraft.client.renderer.rendertype.RenderTypes; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.Identifier; +import net.minecraft.world.entity.Entity; + +import java.util.Collections; +import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.Map; +import java.util.Set; + +public class CosmeticRenderLayer extends RenderLayer { + + private static final Set WARNED_UNKNOWN_NAMES = new HashSet<>(); + + /** + * Per-render-state equipped-cosmetic overrides. When a GUI preview (e.g. the wardrobe + * Screen) wants to render the player with a specific cosmetic set rather than whatever + * is live on the entity, it puts its map here keyed by the render state it's about to + * hand to {@code graphics.entity(...)}. This layer checks this map first; if missing, + * falls back to the live entity's {@link CosmeticAttachments#EQUIPPED} attachment. + * Use {@link IdentityHashMap} semantics so collisions can't happen across states. + */ + public static final Map> RENDER_OVERRIDES = + Collections.synchronizedMap(new IdentityHashMap<>()); + + public CosmeticRenderLayer(RenderLayerParent parent) { + super(parent); + } + + @Override + public void submit(PoseStack poseStack, SubmitNodeCollector collector, int lightCoords, + AvatarRenderState state, float yRot, float xRot) { + + Map cache = CosmeticCache.cosmetics; + if (cache.isEmpty()) return; + + Minecraft mc = Minecraft.getInstance(); + + // GUI preview override takes precedence: the wardrobe Screen sets this to display + // a specific cosmetic in a mini-player box without touching the live attachment. + Map equipped = RENDER_OVERRIDES.get(state); + if (equipped == null) { + if (mc.level == null) return; + Entity entity = mc.level.getEntity(state.id); + if (entity == null) return; + equipped = entity.getData(CosmeticAttachments.EQUIPPED.get()); + } + if (equipped.isEmpty()) return; + + PlayerModel model = getParentModel(); + + for (Identifier cosmeticId : equipped.values()) { + CosmeticCache.Baked cosmetic = cache.get(cosmeticId); + if (cosmetic == null) continue; // unknown cosmetic id, skip + + for (Map.Entry entry : cosmetic.parts().entrySet()) { + ModelPart bone = findBone(model, entry.getKey()); + if (bone == null) continue; + + poseStack.pushPose(); + bone.translateAndRotate(poseStack); + // LivingEntityRenderer applied scale(-1, -1, 1) before calling layers. + // Un-flip so Blockbench visual-upright coords render correctly. + poseStack.scale(-1.0F, -1.0F, 1.0F); + collector.submitModelPart( + entry.getValue(), + poseStack, + RenderTypes.entityCutout(cosmetic.texture()), + lightCoords, + OverlayTexture.NO_OVERLAY, + null + ); + poseStack.popPose(); + } + } + } + + private static ModelPart findBone(PlayerModel model, String rawName) { + String name = normalize(rawName); + ModelPart bone = switch (name) { + case "head" -> model.head; + case "body", "waist", "torso" -> model.body; + case "right_arm" -> model.rightArm; + case "left_arm" -> model.leftArm; + case "right_leg" -> model.rightLeg; + case "left_leg" -> model.leftLeg; + case "hat" -> model.hat; + case "jacket" -> model.jacket; + case "right_sleeve" -> model.rightSleeve; + case "left_sleeve" -> model.leftSleeve; + case "right_pants" -> model.rightPants; + case "left_pants" -> model.leftPants; + // No dedicated cape bone on PlayerModel in 26.1 (the cape is a separate + // render layer). Cape cosmetics should attach to the body bone. + default -> null; + }; + if (bone == null && WARNED_UNKNOWN_NAMES.add(rawName)) { + System.out.println("[DecoFashion] Cosmetic group '" + rawName + + "' (normalized: '" + name + "') doesn't match any player bone; skipping."); + } + return bone; + } + + /** PascalCase / camelCase / UPPER -> snake_case lowercase. */ + private 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); + if (i > 0 && Character.isUpperCase(c) && !Character.isUpperCase(name.charAt(i - 1))) { + sb.append('_'); + } + sb.append(Character.toLowerCase(c)); + } + return sb.toString(); + } +} diff --git a/src/main/java/com/razz/dfashion/client/screen/ClosetScreen.java b/src/main/java/com/razz/dfashion/client/screen/ClosetScreen.java new file mode 100644 index 0000000..384f060 --- /dev/null +++ b/src/main/java/com/razz/dfashion/client/screen/ClosetScreen.java @@ -0,0 +1,514 @@ +package com.razz.dfashion.client.screen; + +import com.razz.dfashion.block.ClosetBlockEntity; +import com.razz.dfashion.client.CosmeticCache; +import com.razz.dfashion.client.CosmeticRenderLayer; +import com.razz.dfashion.cosmetic.CosmeticAttachments; +import com.razz.dfashion.cosmetic.CosmeticDefinition; + +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.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.jspecify.annotations.Nullable; +import org.joml.Matrix4f; +import org.joml.Quaternionf; +import org.joml.Vector3f; +import org.lwjgl.glfw.GLFW; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public class ClosetScreen extends Screen { + + // Tab vocabulary — matches category memory. + private static final String[] CATEGORIES = { + "hat", "head", "torso", "arms", "legs", "wrist", "feet", "wings", "particle" + }; + + // 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 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