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)
main
MomokoKoigakubo 1 month ago
parent 82414b21c1
commit 42994b15a3

@ -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": []
}

@ -3,6 +3,8 @@ package com.razz.dfashion;
import org.slf4j.Logger; import org.slf4j.Logger;
import com.mojang.logging.LogUtils; 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.bus.api.IEventBus;
import net.neoforged.fml.ModContainer; import net.neoforged.fml.ModContainer;
@ -16,6 +18,8 @@ public class DecoFashion {
public DecoFashion(IEventBus modEventBus, ModContainer modContainer) { public DecoFashion(IEventBus modEventBus, ModContainer modContainer) {
modEventBus.addListener(this::commonSetup); modEventBus.addListener(this::commonSetup);
CosmeticAttachments.ATTACHMENT_TYPES.register(modEventBus);
ClosetRegistry.register(modEventBus);
} }
private void commonSetup(FMLCommonSetupEvent event) { private void commonSetup(FMLCommonSetupEvent event) {

@ -1,10 +1,40 @@
package com.razz.dfashion; 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.api.distmarker.Dist;
import net.neoforged.bus.api.SubscribeEvent; import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.fml.common.EventBusSubscriber; import net.neoforged.fml.common.EventBusSubscriber;
import net.neoforged.fml.common.Mod; import net.neoforged.fml.common.Mod;
import net.neoforged.fml.event.lifecycle.FMLClientSetupEvent; 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) @Mod(value = DecoFashion.MODID, dist = Dist.CLIENT)
@EventBusSubscriber(modid = DecoFashion.MODID, value = Dist.CLIENT) @EventBusSubscriber(modid = DecoFashion.MODID, value = Dist.CLIENT)
@ -14,4 +44,184 @@ public class DecoFashionClient {
static void onClientSetup(FMLClientSetupEvent event) { static void onClientSetup(FMLClientSetupEvent event) {
DecoFashion.LOGGER.info("DecoFashion client setup complete"); 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<AbstractClientPlayer> 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<Identifier, CosmeticDefinition> catalog = CosmeticCatalog.loadAll(rm);
Map<Identifier, CosmeticCache.Baked> baked = new HashMap<>();
for (Map.Entry<Identifier, CosmeticDefinition> 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<String, org.joml.Vector3f> resolveLocators(Bbmodel model) {
Map<String, BbGroup> groupById = new HashMap<>();
for (BbGroup g : model.groups()) groupById.put(g.uuid(), g);
Map<String, BbLocator> locatorById = new HashMap<>();
for (BbLocator loc : model.locators()) locatorById.put(loc.uuid(), loc);
Map<String, org.joml.Vector3f> out = new HashMap<>();
// parentChain tracks the groups from root → current, so we can replay their rotations.
java.util.ArrayDeque<BbGroup> 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<BbGroup> parentChain,
Map<String, BbGroup> groupById,
Map<String, BbLocator> locatorById,
Map<String, org.joml.Vector3f> 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<BbGroup> 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<Resource> 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<String, ModelPart> parts = BbmodelBaker.bake(
model, model.resolutionWidth(), model.resolutionHeight(), true);
Map<String, org.joml.Vector3f> 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<Identifier, CosmeticCache.Baked> out
) {
Optional<Resource> 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<String, ModelPart> 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);
}
}
} }

@ -1,4 +1,4 @@
package com.razz.dfashion.bbmodel; package com.razz.dfashion.bbmodel;
public record BbFace (float[] uv, int texture, int rotation){ public record BbFace (float[] uv, Integer texture, int rotation){
} }

@ -19,6 +19,20 @@ public class BbModelParser {
public static Bbmodel parse(Reader in) { public static Bbmodel parse(Reader in) {
JsonObject root = JsonParser.parseReader(in).getAsJsonObject(); 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<BbCube> cubes = new ArrayList<>(); List<BbCube> cubes = new ArrayList<>();
List<BbLocator> locators = new ArrayList<>(); List<BbLocator> locators = new ArrayList<>();
@ -42,7 +56,7 @@ public class BbModelParser {
new TypeToken<List<BbOutlinerNode>>(){}.getType() new TypeToken<List<BbOutlinerNode>>(){}.getType()
); );
return new Bbmodel(cubes, locators, groups, outliner); return new Bbmodel(resW, resH, cubes, locators, groups, outliner);
} }

@ -3,6 +3,8 @@ package com.razz.dfashion.bbmodel;
import java.util.List; import java.util.List;
public record Bbmodel( public record Bbmodel(
int resolutionWidth,
int resolutionHeight,
List<BbCube> elements, List<BbCube> elements,
List<BbLocator> locators, List<BbLocator> locators,
List<BbGroup> groups, List<BbGroup> groups,

@ -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<String, ModelPart> bake(Bbmodel model, int texWidth, int texHeight, boolean mirrorX) {
Map<String, BbCube> cubeIndex = new HashMap<>();
for (BbCube c : model.elements()) cubeIndex.put(c.uuid(), c);
Map<String, BbGroup> groupIndex = new HashMap<>();
for (BbGroup g : model.groups()) groupIndex.put(g.uuid(), g);
Map<String, ModelPart> 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<String, BbCube> cubeIndex,
Map<String, BbGroup> groupIndex,
int texWidth, int texHeight,
boolean mirrorX
) {
List<ModelPart.Cube> cubes = new ArrayList<>();
Map<String, ModelPart> 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<Direction> 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;
};
}
}

@ -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<ClosetBlock> CODEC = simpleCodec(ClosetBlock::new);
public static final EnumProperty<Direction> FACING = HorizontalDirectionalBlock.FACING;
public ClosetBlock(Properties properties) {
super(properties);
registerDefaultState(defaultBlockState().setValue(FACING, Direction.NORTH));
}
@Override
protected MapCodec<? extends BaseEntityBlock> codec() {
return CODEC;
}
@Override
protected void createBlockStateDefinition(StateDefinition.Builder<Block, BlockState> 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;
}
}

@ -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<ClientGamePacketListener> getUpdatePacket() {
return ClientboundBlockEntityDataPacket.create(this);
}
}

@ -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<BlockEntityType<?>> BLOCK_ENTITIES =
DeferredRegister.create(Registries.BLOCK_ENTITY_TYPE, DecoFashion.MODID);
@SuppressWarnings("removal")
public static final DeferredBlock<ClosetBlock> CLOSET = BLOCKS.registerBlock(
"closet",
ClosetBlock::new,
BlockBehaviour.Properties.of()
.mapColor(MapColor.WOOD)
.strength(2.0f)
.noOcclusion()
);
public static final DeferredItem<BlockItem> CLOSET_ITEM = ITEMS.registerSimpleBlockItem(CLOSET);
public static final DeferredHolder<BlockEntityType<?>, BlockEntityType<ClosetBlockEntity>> 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() {}
}

@ -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() {}
}

@ -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<String, ModelPart> parts,
Map<String, Vector3f> locators,
Identifier texture
) {}
public static volatile Baked closed = null;
public static volatile Baked open = null;
private ClosetModelCache() {}
}

@ -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;
}

@ -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<ClosetBlockEntity, ClosetRenderState> {
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();
}
}

@ -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<String, ModelPart> parts, Identifier texture) {}
/** Keyed by cosmetic id (e.g. {@code decofashion:test_hat}). */
public static volatile Map<Identifier, Baked> cosmetics = Map.of();
/** Same keys as {@link #cosmetics} — kept alongside so UI can read display names / categories. */
public static volatile Map<Identifier, CosmeticDefinition> catalog = Map.of();
private CosmeticCache() {}
}

@ -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<AvatarRenderState, PlayerModel> {
private static final Set<String> 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<AvatarRenderState, Map<String, net.minecraft.resources.Identifier>> RENDER_OVERRIDES =
Collections.synchronizedMap(new IdentityHashMap<>());
public CosmeticRenderLayer(RenderLayerParent<AvatarRenderState, PlayerModel> parent) {
super(parent);
}
@Override
public void submit(PoseStack poseStack, SubmitNodeCollector collector, int lightCoords,
AvatarRenderState state, float yRot, float xRot) {
Map<Identifier, CosmeticCache.Baked> 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<String, Identifier> 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<String, ModelPart> 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();
}
}

@ -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<Button> cosmeticButtons = new ArrayList<>();
private boolean showPreviews = true; // toggled by the bottom-right button
// True once one-time setup (event-bus registration, initial displayYaw, etc.) has run.
// Guards against init() re-runs triggered by setScreen(this) after equip commands.
private boolean oneTimeInitDone = false;
// Edge-detect for left-mouse-down so we only fire player-zone navigation on press.
private boolean prevMouseLeftDown = false;
// Drag-to-scroll state for the cosmetic row.
private static final double DRAG_THRESHOLD_PX = 3;
private boolean pressStartedInRow = false;
private double dragAnchorX = 0;
private double dragLastX = 0;
private boolean isDraggingRow = false;
public ClosetScreen(@Nullable ClosetBlockEntity closet) {
super(Component.literal("Wardrobe"));
this.closet = closet;
}
@Override
protected void init() {
Minecraft mc = Minecraft.getInstance();
// One-time setup — guarded against re-init triggered by setScreen(this) after
// `sendUnattendedCommand` completes and MC re-runs init via rebuildWidgets().
if (!oneTimeInitDone) {
oneTimeInitDone = true;
previousCamera = mc.options.getCameraType();
mc.options.setCameraType(CameraType.THIRD_PERSON_FRONT);
if (closet != null) closet.setOpen(true);
LocalPlayer player = mc.player;
if (player != null) displayYaw = player.getYRot();
NeoForge.EVENT_BUS.register(this);
if (selectedCategory == null) selectedCategory = CATEGORIES[0];
}
buildTabs();
buildBottomControls();
selectCategory(selectedCategory != null ? selectedCategory : CATEGORIES[0]);
}
private void buildTabs() {
int x = TAB_X_START;
for (String cat : CATEGORIES) {
String label = cat.substring(0, 1).toUpperCase();
addRenderableWidget(Button.builder(
Component.literal(label),
b -> selectCategory(cat)
).bounds(x, TAB_Y, TAB_W, TAB_H).build());
x += TAB_W + TAB_SPACING;
}
}
private void buildBottomControls() {
int centerX = this.width / 2;
int y = this.height - 40;
leftArrow = Button.builder(Component.literal("<"), b -> {})
.bounds(centerX - 90, y, 20, 20).build();
rightArrow = Button.builder(Component.literal(">"), b -> {})
.bounds(centerX + 70, y, 20, 20).build();
// Arrows render but don't receive events/focus — we drive them via GLFW polling
// in tick(), so they shouldn't get "stuck focused" by MC's widget system.
addRenderableOnly(leftArrow);
addRenderableWidget(Button.builder(
Component.literal("Done"),
b -> this.onClose()
).bounds(centerX - 40, y, 80, 20).build());
addRenderableOnly(rightArrow);
// Bottom-right: toggle the whole cosmetic row (boxes + 3D renders) on/off.
addRenderableWidget(Button.builder(
Component.literal("Toggle"),
b -> {
showPreviews = !showPreviews;
rebuildCosmeticRow(); // re-apply visibility to the cosmetic buttons
}
).bounds(this.width - 70, y, 60, 20).build());
}
private void selectCategory(String cat) {
selectedCategory = cat;
cosmeticScroll = 0;
rebuildCosmeticRow();
}
private void rebuildCosmeticRow() {
for (Button btn : cosmeticButtons) removeWidget(btn);
cosmeticButtons.clear();
if (selectedCategory == null) return;
int x = COSMETIC_ROW_LEFT - cosmeticScroll;
int y = this.height - COSMETIC_ROW_BOTTOM_MARGIN;
for (Map.Entry<Identifier, CosmeticDefinition> entry : CosmeticCache.catalog.entrySet()) {
CosmeticDefinition def = entry.getValue();
if (!selectedCategory.equals(def.category())) continue;
Identifier id = entry.getKey();
// No text label — the mini-player preview + green-border-when-equipped already
// identify the cosmetic. Label text would render at a different stratum than
// the entity preview and poke through behind the player.
Button btn = Button.builder(
Component.empty(),
b -> equip(def.category(), id)
).bounds(x, y, COSMETIC_SIZE, COSMETIC_SIZE).build();
// Hide buttons that would intrude into the tab column or sit off-screen right,
// or when the user has toggled the whole preview row off.
btn.visible = showPreviews && (x >= COSMETIC_ROW_LEFT) && (x < this.width);
btn.active = btn.visible;
cosmeticButtons.add(btn);
addRenderableWidget(btn);
x += COSMETIC_SIZE + COSMETIC_SPACING;
}
}
private void equip(String category, Identifier cosmeticId) {
Minecraft mc = Minecraft.getInstance();
LocalPlayer player = mc.player;
if (player == null || player.connection == null) return;
// Toggle: if this cosmetic is already equipped in that category, unequip.
Map<String, Identifier> currentEquipped = player.getData(CosmeticAttachments.EQUIPPED.get());
String cmd = cosmeticId.equals(currentEquipped.get(category))
? "decofashion unequip " + category
: "decofashion equip " + category + " " + cosmeticId;
player.connection.sendUnattendedCommand(cmd, this);
}
private void scrollToCosmetic(String category, Identifier cosmeticId) {
int idx = 0;
for (Map.Entry<Identifier, CosmeticDefinition> entry : CosmeticCache.catalog.entrySet()) {
if (!category.equals(entry.getValue().category())) continue;
if (entry.getKey().equals(cosmeticId)) {
int targetX = idx * (COSMETIC_SIZE + COSMETIC_SPACING);
cosmeticScroll = targetX; // place the match at the leftmost visible slot
rebuildCosmeticRow();
return;
}
idx++;
}
}
@Override
public boolean mouseScrolled(double mx, double my, double deltaX, double deltaY) {
if (my > this.height - COSMETIC_ROW_BOTTOM_MARGIN - 10
&& my < this.height - COSMETIC_ROW_BOTTOM_MARGIN + COSMETIC_SIZE + 10) {
cosmeticScroll -= (int) (deltaY * 20);
clampScroll();
rebuildCosmeticRow();
return true;
}
return super.mouseScrolled(mx, my, deltaX, deltaY);
}
@Override
public void tick() {
super.tick();
Minecraft mc = Minecraft.getInstance();
LocalPlayer player = mc.player;
if (player == null) return;
long handle = mc.getWindow().handle();
boolean leftDown = GLFW.glfwGetMouseButton(handle, GLFW.GLFW_MOUSE_BUTTON_LEFT) == GLFW.GLFW_PRESS;
double scale = mc.getWindow().getGuiScale();
double mx = mc.mouseHandler.xpos() / scale;
double my = mc.mouseHandler.ypos() / scale;
if (leftDown) {
if (leftArrow != null && leftArrow.isMouseOver(mx, my)) {
displayYaw -= ROTATE_PER_TICK;
} else if (rightArrow != null && rightArrow.isMouseOver(mx, my)) {
displayYaw += ROTATE_PER_TICK;
}
}
// Mouse-press edge: decide whether this press starts a drag on the cosmetic row,
// hits a widget, or lands on one of the player's equipped cosmetics.
if (leftDown && !prevMouseLeftDown) {
if (isInCosmeticRow(mx, my)) {
pressStartedInRow = true;
dragAnchorX = mx;
dragLastX = mx;
isDraggingRow = false;
} else {
pressStartedInRow = false;
if (!isOverAnyWidget(mx, my)) {
String hit = findClickedEquippedCategory(player, mx, my);
if (hit != null) {
selectCategory(hit);
Identifier current = player.getData(CosmeticAttachments.EQUIPPED.get()).get(hit);
if (current != null) scrollToCosmetic(hit, current);
}
}
}
}
// While held inside the row: promote to drag once the anchor-delta clears threshold.
if (leftDown && pressStartedInRow) {
if (!isDraggingRow && Math.abs(mx - dragAnchorX) > DRAG_THRESHOLD_PX) {
isDraggingRow = true;
}
if (isDraggingRow) {
cosmeticScroll -= (int) Math.round(mx - dragLastX);
clampScroll();
dragLastX = mx;
rebuildCosmeticRow();
}
}
if (!leftDown) {
pressStartedInRow = false;
isDraggingRow = false;
}
prevMouseLeftDown = leftDown;
}
private boolean isInCosmeticRow(double mx, double my) {
int y0 = this.height - COSMETIC_ROW_BOTTOM_MARGIN;
int y1 = y0 + COSMETIC_SIZE;
return my >= y0 && my <= y1 && mx >= COSMETIC_ROW_LEFT;
}
private boolean isOverAnyWidget(double mx, double my) {
for (var child : this.children()) {
if (child instanceof Button b && b.isMouseOver(mx, my)) return true;
}
// Arrows are render-only (outside the children list) but still shouldn't trigger
// player-zone clicks when pressed.
if (leftArrow != null && leftArrow.isMouseOver(mx, my)) return true;
if (rightArrow != null && rightArrow.isMouseOver(mx, my)) return true;
return false;
}
// Maximum screen-pixel distance between a click and a cosmetic's projected anchor.
private static final double PLAYER_CLICK_THRESHOLD_PX = 35.0;
/**
* Worldscreen project each equipped cosmetic's bone anchor and pick the one closest
* to the click. Returns {@code null} if no equipped cosmetic's projected point is
* within the threshold.
*
* Uses the camera's own {@code viewRotation × projection} matrix (so FOV, zoom,
* underwater tweaks etc. all match the actually-rendered view). Points must be in
* camera-local space (subtract {@code camera.position()}).
*/
private @Nullable String findClickedEquippedCategory(LocalPlayer player, double clickX, double clickY) {
Map<String, Identifier> equipped = player.getData(CosmeticAttachments.EQUIPPED.get());
if (equipped.isEmpty()) return null;
Camera camera = Minecraft.getInstance().gameRenderer.getMainCamera();
if (!camera.isInitialized()) return null;
Matrix4f viewProj = new Matrix4f();
camera.getViewRotationProjectionMatrix(viewProj);
Vec3 camPos = camera.position();
String best = null;
double bestDistSq = PLAYER_CLICK_THRESHOLD_PX * PLAYER_CLICK_THRESHOLD_PX;
for (Map.Entry<String, Identifier> entry : equipped.entrySet()) {
String category = entry.getKey();
Vec3 bonePos = approximateBoneWorldPos(player, category);
// Camera-local coords; apply the view-rot × projection matrix.
Vector3f screen = new Vector3f(
(float) (bonePos.x - camPos.x),
(float) (bonePos.y - camPos.y),
(float) (bonePos.z - camPos.z));
viewProj.transformProject(screen);
// transformProject yields NDC [-1..1]. z outside = behind camera / past far plane.
if (screen.z < -1f || screen.z > 1f) continue;
double sx = (screen.x + 1.0) * 0.5 * this.width;
double sy = (1.0 - screen.y) * 0.5 * this.height; // flip Y (screen is y-down)
double dx = clickX - sx;
double dy = clickY - sy;
double distSq = dx * dx + dy * dy;
if (distSq < bestDistSq) {
bestDistSq = distSq;
best = category;
}
}
return best;
}
/**
* Anchor points for each category on a standing player (relative to player.position(),
* which is at the feet). Approximations tuned for vanilla player proportions.
*/
private static Vec3 approximateBoneWorldPos(LocalPlayer player, String category) {
Vec3 base = player.position();
double dy = switch (category) {
case "hat", "head" -> 1.65; // top of head
case "torso", "arms", "wrist"-> 1.15; // chest height
case "wings" -> 1.00;
case "legs" -> 0.55;
case "feet" -> 0.10;
case "particle" -> 1.10;
default -> 1.00;
};
return base.add(0, dy, 0);
}
private void clampScroll() {
int count = 0;
if (selectedCategory != null) {
for (Map.Entry<Identifier, CosmeticDefinition> entry : CosmeticCache.catalog.entrySet()) {
if (selectedCategory.equals(entry.getValue().category())) count++;
}
}
int totalWidth = count * (COSMETIC_SIZE + COSMETIC_SPACING);
int visibleWidth = Math.max(0, this.width - COSMETIC_ROW_LEFT - 10);
int maxScroll = Math.max(0, totalWidth - visibleWidth);
cosmeticScroll = Math.max(0, Math.min(cosmeticScroll, maxScroll));
}
@SubscribeEvent
public void onClientTickPost(ClientTickEvent.Post event) {
LocalPlayer player = Minecraft.getInstance().player;
if (player == null) return;
player.setYBodyRot(displayYaw);
player.setYHeadRot(displayYaw);
player.yBodyRotO = displayYaw;
player.yHeadRotO = displayYaw;
}
@Override
public boolean isPauseScreen() {
return false;
}
@Override
public void extractBackground(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
// transparent — keep the 3D world visible behind the UI
}
@Override
public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
// Clear previous frame's preview overrides so they don't accumulate. Last frame's
// entries have already been consumed by the deferred render pass before we get here.
CosmeticRenderLayer.RENDER_OVERRIDES.clear();
super.extractRenderState(graphics, mouseX, mouseY, partialTick);
renderCosmeticPreviews(graphics);
}
private void renderCosmeticPreviews(GuiGraphicsExtractor graphics) {
// Toggle off → render nothing (no frames, no entities). Buttons are already
// hidden + inactive (handled in rebuildCosmeticRow).
if (!showPreviews) return;
Minecraft mc = Minecraft.getInstance();
LocalPlayer player = mc.player;
if (player == null || selectedCategory == null || cosmeticButtons.isEmpty()) return;
EntityRenderDispatcher dispatcher = mc.getEntityRenderDispatcher();
EntityRenderer<? super LivingEntity, ?> renderer = dispatcher.getRenderer(player);
Map<String, Identifier> liveEquipped = player.getData(CosmeticAttachments.EQUIPPED.get());
int idx = 0;
for (Map.Entry<Identifier, CosmeticDefinition> entry : CosmeticCache.catalog.entrySet()) {
CosmeticDefinition def = entry.getValue();
if (!selectedCategory.equals(def.category())) continue;
if (idx >= cosmeticButtons.size()) break;
Button btn = cosmeticButtons.get(idx);
idx++;
// Button bounds (the visible frame).
int fx0 = btn.getX();
int fy0 = btn.getY();
int fx1 = fx0 + btn.getWidth();
int fy1 = fy0 + btn.getHeight();
// Skip off-screen or encroaching-on-tabs buttons (match btn.visible logic).
if (fx0 < COSMETIC_ROW_LEFT || fx0 >= this.width) continue;
boolean isEquipped = entry.getKey().equals(liveEquipped.get(def.category()));
int borderColor = isEquipped ? COSMETIC_BORDER_EQUIPPED_COLOR : COSMETIC_BORDER_COLOR;
// Frame (green if currently equipped).
graphics.outline(fx0, fy0, fx1 - fx0, fy1 - fy0, borderColor);
// Inset where the entity actually renders (adds visual padding).
int x0 = fx0 + COSMETIC_INNER_PADDING;
int y0 = fy0 + COSMETIC_INNER_PADDING;
int x1 = fx1 - COSMETIC_INNER_PADDING;
int y1 = fy1 - COSMETIC_INNER_PADDING;
EntityRenderState state = renderer.createRenderState(player, 1.0F);
if (!(state instanceof AvatarRenderState avatar)) continue;
// Clean up vanilla bits we don't want in the preview.
avatar.shadowPieces.clear();
avatar.outlineColor = 0; // outline framebuffer doesn't work in GUI (produced a white blob)
// Slight Y rotation so the player is angled, making side-attached cosmetics
// (shoulder pieces, side details) visible rather than head-on flat.
avatar.bodyRot = 180f + COSMETIC_PREVIEW_YAW;
avatar.yRot = COSMETIC_PREVIEW_YAW;
avatar.xRot = 0f;
// Register the override — layer will read this instead of the live player's
// equipped attachment when rendering this specific render state.
CosmeticRenderLayer.RENDER_OVERRIDES.put(avatar,
Map.of(def.category(), entry.getKey()));
Vector3f translation = new Vector3f(0f, avatar.boundingBoxHeight / 2f + 0.0625f, 0f);
Quaternionf rotation = new Quaternionf().rotateZ((float) Math.PI);
Quaternionf xRotation = new Quaternionf();
graphics.entity(avatar, COSMETIC_PREVIEW_SIZE, translation, rotation, xRotation,
x0, y0, x1, y1);
}
}
@Override
public void onClose() {
NeoForge.EVENT_BUS.unregister(this);
CosmeticRenderLayer.RENDER_OVERRIDES.clear();
if (closet != null) closet.setOpen(false);
if (previousCamera != null) {
Minecraft.getInstance().options.setCameraType(previousCamera);
}
super.onClose();
}
}

@ -0,0 +1,41 @@
package com.razz.dfashion.cosmetic;
import com.mojang.serialization.Codec;
import com.razz.dfashion.DecoFashion;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.resources.Identifier;
import net.neoforged.neoforge.attachment.AttachmentType;
import net.neoforged.neoforge.registries.DeferredHolder;
import net.neoforged.neoforge.registries.DeferredRegister;
import net.neoforged.neoforge.registries.NeoForgeRegistries;
import java.util.HashMap;
import java.util.Map;
public final class CosmeticAttachments {
public static final DeferredRegister<AttachmentType<?>> ATTACHMENT_TYPES =
DeferredRegister.create(NeoForgeRegistries.ATTACHMENT_TYPES, DecoFashion.MODID);
private static final Codec<Map<String, Identifier>> MAP_CODEC =
Codec.unboundedMap(Codec.STRING, Identifier.CODEC);
private static final StreamCodec<RegistryFriendlyByteBuf, Map<String, Identifier>> STREAM_CODEC =
ByteBufCodecs.map(HashMap::new, ByteBufCodecs.STRING_UTF8, Identifier.STREAM_CODEC);
/** Per-player map of category -> equipped cosmetic id. */
public static final DeferredHolder<AttachmentType<?>, AttachmentType<Map<String, Identifier>>> EQUIPPED =
ATTACHMENT_TYPES.register(
"equipped_cosmetics",
() -> AttachmentType.<Map<String, Identifier>>builder(() -> new HashMap<>())
.serialize(MAP_CODEC.fieldOf("equipped"))
.sync(STREAM_CODEC)
.copyOnDeath()
.build()
);
private CosmeticAttachments() {}
}

@ -0,0 +1,70 @@
package com.razz.dfashion.cosmetic;
import com.google.gson.*;
import com.google.gson.reflect.TypeToken;
import com.razz.dfashion.DecoFashion;
import net.minecraft.resources.Identifier;
import net.minecraft.server.packs.resources.Resource;
import net.minecraft.server.packs.resources.ResourceManager;
import java.io.IOException;
import java.io.Reader;
import java.lang.reflect.Type;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
public final class CosmeticCatalog {
/** Single catalog file: {@code assets/<ns>/cosmetics.json}. */
private static final String CATALOG_PATH = "cosmetics.json";
private static final Gson GSON = new GsonBuilder()
.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
.registerTypeAdapter(Identifier.class, new IdentifierDeserializer())
.create();
private CosmeticCatalog() {}
/**
* Loads {@code assets/<ns>/cosmetics.json} a top-level JSON object where each key is
* a cosmetic id (path portion, implicit {@code decofashion:} namespace) and each value
* is its {@link CosmeticDefinition}.
*/
public static Map<Identifier, CosmeticDefinition> loadAll(ResourceManager rm) {
Map<Identifier, CosmeticDefinition> result = new HashMap<>();
Identifier fileId = Identifier.fromNamespaceAndPath(DecoFashion.MODID, CATALOG_PATH);
Optional<Resource> res = rm.getResource(fileId);
if (res.isEmpty()) {
DecoFashion.LOGGER.warn("No cosmetics catalog at {}", fileId);
return result;
}
try (Reader reader = res.get().openAsReader()) {
Type mapType = new TypeToken<Map<String, CosmeticDefinition>>(){}.getType();
Map<String, CosmeticDefinition> defs = GSON.fromJson(reader, mapType);
if (defs == null) {
DecoFashion.LOGGER.warn("Empty cosmetics catalog: {}", fileId);
return result;
}
for (Map.Entry<String, CosmeticDefinition> entry : defs.entrySet()) {
if (entry.getValue() == null) continue;
Identifier id = Identifier.fromNamespaceAndPath(DecoFashion.MODID, entry.getKey());
result.put(id, entry.getValue());
}
} catch (IOException | JsonParseException ex) {
DecoFashion.LOGGER.error("Failed to parse cosmetics catalog {}", fileId, ex);
}
return result;
}
private static final class IdentifierDeserializer implements JsonDeserializer<Identifier> {
@Override
public Identifier deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
throws JsonParseException {
return Identifier.parse(json.getAsString());
}
}
}

@ -0,0 +1,101 @@
package com.razz.dfashion.cosmetic;
import com.mojang.brigadier.CommandDispatcher;
import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.brigadier.context.CommandContext;
import com.razz.dfashion.DecoFashion;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.server.level.ServerPlayer;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.fml.common.EventBusSubscriber;
import net.neoforged.neoforge.event.RegisterCommandsEvent;
import java.util.HashMap;
import java.util.Map;
@EventBusSubscriber(modid = DecoFashion.MODID)
public final class CosmeticCommands {
@SubscribeEvent
static void onRegisterCommands(RegisterCommandsEvent event) {
CommandDispatcher<CommandSourceStack> dispatcher = event.getDispatcher();
dispatcher.register(
Commands.literal(DecoFashion.MODID)
.then(Commands.literal("equip")
.then(Commands.argument("category", StringArgumentType.word())
.then(Commands.argument("id", StringArgumentType.greedyString())
.executes(CosmeticCommands::equip))))
.then(Commands.literal("unequip")
.then(Commands.argument("category", StringArgumentType.word())
.executes(CosmeticCommands::unequip)))
.then(Commands.literal("list")
.executes(CosmeticCommands::list))
);
}
private static int equip(CommandContext<CommandSourceStack> ctx) {
ServerPlayer player = ctx.getSource().getPlayer();
if (player == null) {
ctx.getSource().sendFailure(Component.literal("Must be run by a player"));
return 0;
}
String category = StringArgumentType.getString(ctx, "category");
String idStr = StringArgumentType.getString(ctx, "id");
Identifier id = Identifier.parse(idStr);
Map<String, Identifier> equipped = new HashMap<>(player.getData(CosmeticAttachments.EQUIPPED.get()));
equipped.put(category, id);
player.setData(CosmeticAttachments.EQUIPPED.get(), equipped);
ctx.getSource().sendSuccess(
() -> Component.literal("Equipped " + id + " in category '" + category + "'"),
false
);
return 1;
}
private static int unequip(CommandContext<CommandSourceStack> ctx) {
ServerPlayer player = ctx.getSource().getPlayer();
if (player == null) {
ctx.getSource().sendFailure(Component.literal("Must be run by a player"));
return 0;
}
String category = StringArgumentType.getString(ctx, "category");
Map<String, Identifier> equipped = new HashMap<>(player.getData(CosmeticAttachments.EQUIPPED.get()));
Identifier removed = equipped.remove(category);
player.setData(CosmeticAttachments.EQUIPPED.get(), equipped);
ctx.getSource().sendSuccess(
() -> Component.literal(removed != null
? "Unequipped " + removed + " from '" + category + "'"
: "Nothing was equipped in '" + category + "'"),
false
);
return 1;
}
private static int list(CommandContext<CommandSourceStack> ctx) {
ServerPlayer player = ctx.getSource().getPlayer();
if (player == null) {
ctx.getSource().sendFailure(Component.literal("Must be run by a player"));
return 0;
}
Map<String, Identifier> equipped = player.getData(CosmeticAttachments.EQUIPPED.get());
if (equipped.isEmpty()) {
ctx.getSource().sendSuccess(() -> Component.literal("No cosmetics equipped"), false);
} else {
equipped.forEach((cat, id) ->
ctx.getSource().sendSuccess(() -> Component.literal(cat + " = " + id), false)
);
}
return 1;
}
private CosmeticCommands() {}
}

@ -0,0 +1,10 @@
package com.razz.dfashion.cosmetic;
import net.minecraft.resources.Identifier;
public record CosmeticDefinition(
String displayName,
String category,
Identifier model,
Identifier texture
) {}

@ -0,0 +1,8 @@
{
"variants": {
"facing=south": { "model": "decofashion:block/closet", "y": 0 },
"facing=west": { "model": "decofashion:block/closet", "y": 90 },
"facing=north": { "model": "decofashion:block/closet", "y": 180 },
"facing=east": { "model": "decofashion:block/closet", "y": 270 }
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -0,0 +1,57 @@
{
"test_hat": {
"display_name": "Test Hat",
"category": "hat",
"model": "decofashion:cosmetic/test_hat.bbmodel",
"texture": "decofashion:textures/cosmetic/test_hat.png"
},
"pike": {
"display_name": "Pike",
"category": "torso",
"model": "decofashion:cosmetic/pike.bbmodel",
"texture": "decofashion:textures/cosmetic/pike.png"
},
"mock_hat_01": {"display_name":"Bowler", "category":"hat","model":"decofashion:cosmetic/test_hat.bbmodel","texture":"decofashion:textures/cosmetic/test_hat.png"},
"mock_hat_02": {"display_name":"Top Hat", "category":"hat","model":"decofashion:cosmetic/test_hat.bbmodel","texture":"decofashion:textures/cosmetic/test_hat.png"},
"mock_hat_03": {"display_name":"Beanie", "category":"hat","model":"decofashion:cosmetic/test_hat.bbmodel","texture":"decofashion:textures/cosmetic/test_hat.png"},
"mock_hat_04": {"display_name":"Fedora", "category":"hat","model":"decofashion:cosmetic/test_hat.bbmodel","texture":"decofashion:textures/cosmetic/test_hat.png"},
"mock_hat_05": {"display_name":"Cap", "category":"hat","model":"decofashion:cosmetic/test_hat.bbmodel","texture":"decofashion:textures/cosmetic/test_hat.png"},
"mock_hat_06": {"display_name":"Crown", "category":"hat","model":"decofashion:cosmetic/test_hat.bbmodel","texture":"decofashion:textures/cosmetic/test_hat.png"},
"mock_hat_07": {"display_name":"Tiara", "category":"hat","model":"decofashion:cosmetic/test_hat.bbmodel","texture":"decofashion:textures/cosmetic/test_hat.png"},
"mock_hat_08": {"display_name":"Helmet", "category":"hat","model":"decofashion:cosmetic/test_hat.bbmodel","texture":"decofashion:textures/cosmetic/test_hat.png"},
"mock_hat_09": {"display_name":"Sombrero", "category":"hat","model":"decofashion:cosmetic/test_hat.bbmodel","texture":"decofashion:textures/cosmetic/test_hat.png"},
"mock_hat_10": {"display_name":"Beret", "category":"hat","model":"decofashion:cosmetic/test_hat.bbmodel","texture":"decofashion:textures/cosmetic/test_hat.png"},
"mock_hat_11": {"display_name":"Straw", "category":"hat","model":"decofashion:cosmetic/test_hat.bbmodel","texture":"decofashion:textures/cosmetic/test_hat.png"},
"mock_hat_12": {"display_name":"Pirate", "category":"hat","model":"decofashion:cosmetic/test_hat.bbmodel","texture":"decofashion:textures/cosmetic/test_hat.png"},
"mock_hat_13": {"display_name":"Witch", "category":"hat","model":"decofashion:cosmetic/test_hat.bbmodel","texture":"decofashion:textures/cosmetic/test_hat.png"},
"mock_hat_14": {"display_name":"Cowboy", "category":"hat","model":"decofashion:cosmetic/test_hat.bbmodel","texture":"decofashion:textures/cosmetic/test_hat.png"},
"mock_hat_15": {"display_name":"Santa", "category":"hat","model":"decofashion:cosmetic/test_hat.bbmodel","texture":"decofashion:textures/cosmetic/test_hat.png"},
"mock_hat_16": {"display_name":"Hood", "category":"hat","model":"decofashion:cosmetic/test_hat.bbmodel","texture":"decofashion:textures/cosmetic/test_hat.png"},
"mock_hat_17": {"display_name":"Headband", "category":"hat","model":"decofashion:cosmetic/test_hat.bbmodel","texture":"decofashion:textures/cosmetic/test_hat.png"},
"mock_hat_18": {"display_name":"Visor", "category":"hat","model":"decofashion:cosmetic/test_hat.bbmodel","texture":"decofashion:textures/cosmetic/test_hat.png"},
"mock_hat_19": {"display_name":"Flower", "category":"hat","model":"decofashion:cosmetic/test_hat.bbmodel","texture":"decofashion:textures/cosmetic/test_hat.png"},
"mock_hat_20": {"display_name":"Halo", "category":"hat","model":"decofashion:cosmetic/test_hat.bbmodel","texture":"decofashion:textures/cosmetic/test_hat.png"},
"mock_torso_01": {"display_name":"Jacket", "category":"torso","model":"decofashion:cosmetic/pike.bbmodel","texture":"decofashion:textures/cosmetic/pike.png"},
"mock_torso_02": {"display_name":"Robe", "category":"torso","model":"decofashion:cosmetic/pike.bbmodel","texture":"decofashion:textures/cosmetic/pike.png"},
"mock_torso_03": {"display_name":"Vest", "category":"torso","model":"decofashion:cosmetic/pike.bbmodel","texture":"decofashion:textures/cosmetic/pike.png"},
"mock_torso_04": {"display_name":"Coat", "category":"torso","model":"decofashion:cosmetic/pike.bbmodel","texture":"decofashion:textures/cosmetic/pike.png"},
"mock_torso_05": {"display_name":"Shirt", "category":"torso","model":"decofashion:cosmetic/pike.bbmodel","texture":"decofashion:textures/cosmetic/pike.png"},
"mock_torso_06": {"display_name":"Cape", "category":"torso","model":"decofashion:cosmetic/pike.bbmodel","texture":"decofashion:textures/cosmetic/pike.png"},
"mock_torso_07": {"display_name":"Armor", "category":"torso","model":"decofashion:cosmetic/pike.bbmodel","texture":"decofashion:textures/cosmetic/pike.png"},
"mock_torso_08": {"display_name":"Dress", "category":"torso","model":"decofashion:cosmetic/pike.bbmodel","texture":"decofashion:textures/cosmetic/pike.png"},
"mock_torso_09": {"display_name":"Hoodie", "category":"torso","model":"decofashion:cosmetic/pike.bbmodel","texture":"decofashion:textures/cosmetic/pike.png"},
"mock_torso_10": {"display_name":"Tunic", "category":"torso","model":"decofashion:cosmetic/pike.bbmodel","texture":"decofashion:textures/cosmetic/pike.png"},
"mock_head_01": {"display_name":"Mask", "category":"head","model":"decofashion:cosmetic/test_hat.bbmodel","texture":"decofashion:textures/cosmetic/test_hat.png"},
"mock_head_02": {"display_name":"Glasses", "category":"head","model":"decofashion:cosmetic/test_hat.bbmodel","texture":"decofashion:textures/cosmetic/test_hat.png"},
"mock_head_03": {"display_name":"Bandana", "category":"head","model":"decofashion:cosmetic/test_hat.bbmodel","texture":"decofashion:textures/cosmetic/test_hat.png"},
"mock_legs_01": {"display_name":"Pants", "category":"legs","model":"decofashion:cosmetic/pike.bbmodel","texture":"decofashion:textures/cosmetic/pike.png"},
"mock_legs_02": {"display_name":"Shorts", "category":"legs","model":"decofashion:cosmetic/pike.bbmodel","texture":"decofashion:textures/cosmetic/pike.png"},
"mock_legs_03": {"display_name":"Skirt", "category":"legs","model":"decofashion:cosmetic/pike.bbmodel","texture":"decofashion:textures/cosmetic/pike.png"},
"mock_feet_01": {"display_name":"Boots", "category":"feet","model":"decofashion:cosmetic/pike.bbmodel","texture":"decofashion:textures/cosmetic/pike.png"},
"mock_feet_02": {"display_name":"Sandals", "category":"feet","model":"decofashion:cosmetic/pike.bbmodel","texture":"decofashion:textures/cosmetic/pike.png"}
}

@ -0,0 +1,5 @@
{
"textures": {
"particle": "decofashion:closet/closet_base_spruce"
}
}

@ -0,0 +1,3 @@
{
"parent": "decofashion:block/closet"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 622 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 B

Loading…
Cancel
Save