add wardrobe variant catalog with virtual pack and naturalUv bake, cosmetic + skin flipbook with hold-frame field, raise raw/deflated caps for flipbook strips, drop user folder cosmetic loader
parent
640dcae487
commit
9779a140db
@ -0,0 +1,84 @@
|
||||
package com.razz.dfashion.block;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParseException;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.razz.dfashion.DecoFashion;
|
||||
|
||||
import net.minecraft.resources.Identifier;
|
||||
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Closet variants are read from {@code decofashion/closets.json} on the mod's classpath
|
||||
* at mod-construction time — vanilla's {@link net.minecraft.server.packs.resources.ResourceManager}
|
||||
* isn't built yet when {@link net.neoforged.neoforge.registries.DeferredRegister} fires, so we
|
||||
* read directly from the jar instead.
|
||||
*
|
||||
* <p>Each catalog entry registers its own {@link ClosetBlock}; adding a new wardrobe means
|
||||
* dropping bbmodels + texture, adding a JSON entry, and restarting the game.
|
||||
*/
|
||||
public final class ClosetCatalog {
|
||||
|
||||
private static final String CLASSPATH_FILE = "/decofashion/closets.json";
|
||||
|
||||
/** Author-supplied flipbook spec. {@code frames} is the number of vertically-stacked
|
||||
* frames in the source PNG; {@code frametime} is ticks per frame (vanilla mcmeta-style).
|
||||
* No interpolate field — flicker between frames, no blending. */
|
||||
public record Flipbook(int frametime, int frames) {}
|
||||
|
||||
public record Entry(
|
||||
Identifier variantId,
|
||||
Identifier closedModel,
|
||||
Identifier openModel,
|
||||
Identifier texture,
|
||||
@Nullable Flipbook flipbook
|
||||
) {}
|
||||
|
||||
private ClosetCatalog() {}
|
||||
|
||||
public static Map<Identifier, Entry> loadFromClasspath() {
|
||||
Map<Identifier, Entry> result = new LinkedHashMap<>();
|
||||
|
||||
try (InputStream in = ClosetCatalog.class.getResourceAsStream(CLASSPATH_FILE)) {
|
||||
if (in == null) {
|
||||
DecoFashion.LOGGER.warn("Closet catalog not found at {}", CLASSPATH_FILE);
|
||||
return result;
|
||||
}
|
||||
JsonObject root = JsonParser.parseReader(
|
||||
new InputStreamReader(in, StandardCharsets.UTF_8)
|
||||
).getAsJsonObject();
|
||||
|
||||
for (Map.Entry<String, com.google.gson.JsonElement> e : root.entrySet()) {
|
||||
String idPath = e.getKey();
|
||||
JsonObject entry = e.getValue().getAsJsonObject();
|
||||
Identifier variantId = Identifier.fromNamespaceAndPath(DecoFashion.MODID, idPath);
|
||||
Identifier closed = Identifier.parse(entry.get("closed").getAsString());
|
||||
Identifier open = Identifier.parse(entry.get("open").getAsString());
|
||||
Identifier tex = Identifier.parse(entry.get("texture").getAsString());
|
||||
Flipbook flipbook = parseFlipbook(entry);
|
||||
result.put(variantId, new Entry(variantId, closed, open, tex, flipbook));
|
||||
}
|
||||
} catch (IOException | JsonParseException | IllegalStateException ex) {
|
||||
DecoFashion.LOGGER.error("Failed to parse closet catalog {}", CLASSPATH_FILE, ex);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static @Nullable Flipbook parseFlipbook(JsonObject entry) {
|
||||
if (!entry.has("flipbook")) return null;
|
||||
JsonObject fb = entry.getAsJsonObject("flipbook");
|
||||
int frametime = fb.has("frametime") ? fb.get("frametime").getAsInt() : 1;
|
||||
int frames = fb.has("frames") ? fb.get("frames").getAsInt() : 1;
|
||||
if (frametime <= 0 || frames <= 1) return null;
|
||||
return new Flipbook(frametime, frames);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,249 @@
|
||||
package com.razz.dfashion.block;
|
||||
|
||||
import com.razz.dfashion.DecoFashion;
|
||||
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.server.packs.PackLocationInfo;
|
||||
import net.minecraft.server.packs.PackResources;
|
||||
import net.minecraft.server.packs.PackSelectionConfig;
|
||||
import net.minecraft.server.packs.PackType;
|
||||
import net.minecraft.server.packs.metadata.MetadataSectionType;
|
||||
import net.minecraft.server.packs.repository.Pack;
|
||||
import net.minecraft.server.packs.repository.PackCompatibility;
|
||||
import net.minecraft.server.packs.repository.PackSource;
|
||||
import net.minecraft.server.packs.resources.IoSupplier;
|
||||
import net.minecraft.world.flag.FeatureFlagSet;
|
||||
import net.neoforged.neoforge.event.AddPackFindersEvent;
|
||||
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Synthesizes blockstate/block-model/item-model/item-definition resources for every
|
||||
* closet catalog entry, so adding a new wardrobe is one entry in {@code closets.json}
|
||||
* + the bbmodels and texture — no boilerplate JSON per variant.
|
||||
*
|
||||
* <p>Pattern adapted from decocraft's DynamicDecocraftPack. The `.png.mcmeta` proxy +
|
||||
* standalone-texture flipbook used elsewhere in that file is deliberately not mirrored
|
||||
* here: closet textures are rendered via {@code RenderTypes.entityTranslucent(textureId)}
|
||||
* (not the block atlas), so vanilla's mcmeta animation never fires for them. Flipbook
|
||||
* animation is handled by {@link com.razz.dfashion.client.FlipbookTicker} — it
|
||||
* blits frames into a {@link net.minecraft.client.renderer.texture.DynamicTexture} each
|
||||
* tick and re-uploads. Keeping that in code instead of mcmeta avoids needing a separate
|
||||
* atlas pipeline just for closets.
|
||||
*/
|
||||
public final class ClosetVirtualPack implements PackResources {
|
||||
|
||||
private static final String PACK_ID = DecoFashion.MODID + "_dynamic_closets";
|
||||
private static final byte[] PACK_MCMETA =
|
||||
"{\"pack\":{\"pack_format\":34,\"description\":\"DecoFashion dynamic closet assets\"}}"
|
||||
.getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
private final PackLocationInfo locationInfo;
|
||||
|
||||
public ClosetVirtualPack() {
|
||||
this.locationInfo = new PackLocationInfo(
|
||||
PACK_ID,
|
||||
Component.literal("DecoFashion Dynamic Closets"),
|
||||
PackSource.BUILT_IN,
|
||||
Optional.empty()
|
||||
);
|
||||
}
|
||||
|
||||
public static void onAddPackFinders(AddPackFindersEvent event) {
|
||||
event.addRepositorySource(consumer -> {
|
||||
PackLocationInfo info = new ClosetVirtualPack().location();
|
||||
Pack.ResourcesSupplier supplier = new Pack.ResourcesSupplier() {
|
||||
@Override
|
||||
public PackResources openPrimary(PackLocationInfo info) {
|
||||
return new ClosetVirtualPack();
|
||||
}
|
||||
|
||||
@Override
|
||||
public PackResources openFull(PackLocationInfo info, Pack.Metadata metadata) {
|
||||
return new ClosetVirtualPack();
|
||||
}
|
||||
};
|
||||
Pack.Metadata metadata = new Pack.Metadata(
|
||||
Component.literal("DecoFashion dynamic closet assets"),
|
||||
PackCompatibility.COMPATIBLE,
|
||||
FeatureFlagSet.of(),
|
||||
List.of()
|
||||
);
|
||||
consumer.accept(new Pack(
|
||||
info,
|
||||
supplier,
|
||||
metadata,
|
||||
new PackSelectionConfig(true, Pack.Position.TOP, false)
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
// --- Synthesizers ---
|
||||
|
||||
private static String generateBlockstateJson(String id) {
|
||||
String model = DecoFashion.MODID + ":block/" + id;
|
||||
return "{\"variants\":{"
|
||||
+ "\"facing=south\":{\"model\":\"" + model + "\",\"y\":0},"
|
||||
+ "\"facing=west\":{\"model\":\"" + model + "\",\"y\":90},"
|
||||
+ "\"facing=north\":{\"model\":\"" + model + "\",\"y\":180},"
|
||||
+ "\"facing=east\":{\"model\":\"" + model + "\",\"y\":270}"
|
||||
+ "}}";
|
||||
}
|
||||
|
||||
/** Particle texture only — block geometry is INVISIBLE; the BlockEntityRenderer draws it. */
|
||||
private static String generateBlockModelJson(Identifier texture) {
|
||||
return "{\"textures\":{\"particle\":\""
|
||||
+ texture.getNamespace() + ":"
|
||||
+ stripTexturesPrefixAndExt(texture.getPath())
|
||||
+ "\"}}";
|
||||
}
|
||||
|
||||
private static String generateItemDefinitionJson(String id) {
|
||||
return "{\"model\":{\"type\":\"minecraft:model\",\"model\":\""
|
||||
+ DecoFashion.MODID + ":item/" + id + "\"}}";
|
||||
}
|
||||
|
||||
private static String generateItemModelJson(String id) {
|
||||
return "{\"parent\":\"" + DecoFashion.MODID + ":block/" + id + "\"}";
|
||||
}
|
||||
|
||||
/** Catalog stores the texture as a full asset path like {@code decofashion:textures/closet/wardrobe_1.png};
|
||||
* model JSONs reference textures as {@code <ns>:<path>} where {@code <path>} is the texture path
|
||||
* without the leading {@code textures/} or trailing {@code .png}. */
|
||||
private static String stripTexturesPrefixAndExt(String path) {
|
||||
String p = path;
|
||||
if (p.startsWith("textures/")) p = p.substring("textures/".length());
|
||||
if (p.endsWith(".png")) p = p.substring(0, p.length() - ".png".length());
|
||||
return p;
|
||||
}
|
||||
|
||||
// --- PackResources implementation ---
|
||||
|
||||
@Override
|
||||
public @Nullable IoSupplier<InputStream> getRootResource(String... path) {
|
||||
if (path.length == 1 && "pack.mcmeta".equals(path[0])) {
|
||||
return () -> new ByteArrayInputStream(PACK_MCMETA);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable IoSupplier<InputStream> getResource(PackType packType, Identifier location) {
|
||||
if (packType != PackType.CLIENT_RESOURCES) return null;
|
||||
if (!DecoFashion.MODID.equals(location.getNamespace())) return null;
|
||||
|
||||
String path = location.getPath();
|
||||
Map<Identifier, ClosetCatalog.Entry> catalog = ClosetRegistry.catalog();
|
||||
|
||||
if (path.startsWith("blockstates/") && path.endsWith(".json")) {
|
||||
String id = extract(path, "blockstates/");
|
||||
if (id != null && catalog.containsKey(idOf(id))) {
|
||||
return supplier(generateBlockstateJson(id));
|
||||
}
|
||||
}
|
||||
if (path.startsWith("models/block/") && path.endsWith(".json")) {
|
||||
String id = extract(path, "models/block/");
|
||||
ClosetCatalog.Entry entry = id == null ? null : catalog.get(idOf(id));
|
||||
if (entry != null) {
|
||||
return supplier(generateBlockModelJson(entry.texture()));
|
||||
}
|
||||
}
|
||||
if (path.startsWith("models/item/") && path.endsWith(".json")) {
|
||||
String id = extract(path, "models/item/");
|
||||
if (id != null && catalog.containsKey(idOf(id))) {
|
||||
return supplier(generateItemModelJson(id));
|
||||
}
|
||||
}
|
||||
if (path.startsWith("items/") && path.endsWith(".json")) {
|
||||
String id = extract(path, "items/");
|
||||
if (id != null && catalog.containsKey(idOf(id))) {
|
||||
return supplier(generateItemDefinitionJson(id));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void listResources(PackType packType, String namespace, String path,
|
||||
PackResources.ResourceOutput output) {
|
||||
if (packType != PackType.CLIENT_RESOURCES) return;
|
||||
if (!DecoFashion.MODID.equals(namespace)) return;
|
||||
|
||||
Map<Identifier, ClosetCatalog.Entry> catalog = ClosetRegistry.catalog();
|
||||
|
||||
if ("blockstates".equals(path) || path.startsWith("blockstates/")) {
|
||||
for (Map.Entry<Identifier, ClosetCatalog.Entry> e : catalog.entrySet()) {
|
||||
emit(output, "blockstates/" + e.getKey().getPath() + ".json",
|
||||
generateBlockstateJson(e.getKey().getPath()));
|
||||
}
|
||||
}
|
||||
if ("models".equals(path) || "models/block".equals(path) || path.startsWith("models/block/")) {
|
||||
for (Map.Entry<Identifier, ClosetCatalog.Entry> e : catalog.entrySet()) {
|
||||
emit(output, "models/block/" + e.getKey().getPath() + ".json",
|
||||
generateBlockModelJson(e.getValue().texture()));
|
||||
}
|
||||
}
|
||||
if ("models".equals(path) || "models/item".equals(path) || path.startsWith("models/item/")) {
|
||||
for (Map.Entry<Identifier, ClosetCatalog.Entry> e : catalog.entrySet()) {
|
||||
emit(output, "models/item/" + e.getKey().getPath() + ".json",
|
||||
generateItemModelJson(e.getKey().getPath()));
|
||||
}
|
||||
}
|
||||
if ("items".equals(path) || path.startsWith("items/")) {
|
||||
for (Map.Entry<Identifier, ClosetCatalog.Entry> e : catalog.entrySet()) {
|
||||
emit(output, "items/" + e.getKey().getPath() + ".json",
|
||||
generateItemDefinitionJson(e.getKey().getPath()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> getNamespaces(PackType packType) {
|
||||
return packType == PackType.CLIENT_RESOURCES ? Set.of(DecoFashion.MODID) : Set.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable <T> T getMetadataSection(MetadataSectionType<T> type) throws IOException {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PackLocationInfo location() {
|
||||
return locationInfo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
private static @Nullable String extract(String path, String prefix) {
|
||||
String name = path.substring(prefix.length(), path.length() - ".json".length());
|
||||
return name.isEmpty() ? null : name;
|
||||
}
|
||||
|
||||
private static Identifier idOf(String path) {
|
||||
return Identifier.fromNamespaceAndPath(DecoFashion.MODID, path);
|
||||
}
|
||||
|
||||
private static IoSupplier<InputStream> supplier(String json) {
|
||||
byte[] bytes = json.getBytes(StandardCharsets.UTF_8);
|
||||
return () -> new ByteArrayInputStream(bytes);
|
||||
}
|
||||
|
||||
private static void emit(PackResources.ResourceOutput output, String path, String json) {
|
||||
Identifier id = Identifier.fromNamespaceAndPath(DecoFashion.MODID, path);
|
||||
byte[] bytes = json.getBytes(StandardCharsets.UTF_8);
|
||||
output.accept(id, () -> new ByteArrayInputStream(bytes));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,115 @@
|
||||
package com.razz.dfashion.client;
|
||||
|
||||
import com.mojang.blaze3d.platform.NativeImage;
|
||||
|
||||
import net.minecraft.client.renderer.texture.DynamicTexture;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.neoforged.api.distmarker.Dist;
|
||||
import net.neoforged.bus.api.SubscribeEvent;
|
||||
import net.neoforged.fml.common.EventBusSubscriber;
|
||||
import net.neoforged.neoforge.client.event.ClientTickEvent;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Per-tick frame advancer for animated cosmetic / closet textures. Each registered entry owns:
|
||||
* - a source {@link NativeImage} (the full vertical flipbook strip)
|
||||
* - the live single-frame {@link DynamicTexture} bound to the renderer's texture id
|
||||
* - a {@code frametime} (ticks/frame) and {@code frames} count
|
||||
*
|
||||
* <p>On each client tick, every entry's elapsed-tick counter advances. When it crosses the
|
||||
* frametime threshold, the next frame's pixel rows are blitted into the live texture and
|
||||
* {@link DynamicTexture#upload()} pushes them to the GPU.
|
||||
*
|
||||
* <p>Generic over the texture id — keys are arbitrary {@link Identifier}s. Closets use
|
||||
* {@code decofashion_closet:<variant>}; built-in cosmetics use {@code decofashion_cosmetic:<id>};
|
||||
* shared cosmetics use {@code decofashion_shared:<hash>}. Re-registering under the same id
|
||||
* replaces the previous entry and closes its source image.
|
||||
*/
|
||||
@EventBusSubscriber(modid = com.razz.dfashion.DecoFashion.MODID, value = Dist.CLIENT)
|
||||
public final class FlipbookTicker {
|
||||
|
||||
public static final class Entry {
|
||||
final NativeImage source;
|
||||
final DynamicTexture target;
|
||||
final int frametime;
|
||||
final int frames;
|
||||
final int frameWidth;
|
||||
final int frameHeight;
|
||||
/** Frame index whose display gets extended to {@link #holdFrametime} ticks. Ignored
|
||||
* when {@code holdFrametime == 0} (no hold). For a blink-style cycle, this is the
|
||||
* "eyes open" frame and {@code holdFrametime} is the gap between blinks. */
|
||||
final int holdFrame;
|
||||
/** Display time in ticks for {@link #holdFrame}. {@code 0} = use the regular
|
||||
* {@link #frametime} for every frame (no hold). */
|
||||
final int holdFrametime;
|
||||
int frameIndex = 0;
|
||||
int tickInFrame = 0;
|
||||
|
||||
public Entry(NativeImage source, DynamicTexture target,
|
||||
int frametime, int frames, int frameWidth, int frameHeight) {
|
||||
this(source, target, frametime, frames, frameWidth, frameHeight, 0, 0);
|
||||
}
|
||||
|
||||
public Entry(NativeImage source, DynamicTexture target,
|
||||
int frametime, int frames, int frameWidth, int frameHeight,
|
||||
int holdFrame, int holdFrametime) {
|
||||
this.source = source;
|
||||
this.target = target;
|
||||
this.frametime = frametime;
|
||||
this.frames = frames;
|
||||
this.frameWidth = frameWidth;
|
||||
this.frameHeight = frameHeight;
|
||||
this.holdFrame = holdFrame;
|
||||
this.holdFrametime = holdFrametime;
|
||||
}
|
||||
}
|
||||
|
||||
/** Keyed by the texture id so re-registering on resource reload replaces the old entry. */
|
||||
private static final Map<Identifier, Entry> ENTRIES = new ConcurrentHashMap<>();
|
||||
|
||||
private FlipbookTicker() {}
|
||||
|
||||
public static void register(Identifier textureId, Entry entry) {
|
||||
Entry old = ENTRIES.put(textureId, entry);
|
||||
if (old != null && old.source != entry.source) {
|
||||
old.source.close();
|
||||
}
|
||||
}
|
||||
|
||||
public static void unregister(Identifier textureId) {
|
||||
Entry old = ENTRIES.remove(textureId);
|
||||
if (old != null) old.source.close();
|
||||
}
|
||||
|
||||
public static void clear() {
|
||||
for (Entry e : ENTRIES.values()) {
|
||||
e.source.close();
|
||||
}
|
||||
ENTRIES.clear();
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
static void onClientTick(ClientTickEvent.Pre event) {
|
||||
if (ENTRIES.isEmpty()) return;
|
||||
for (Entry e : ENTRIES.values()) {
|
||||
e.tickInFrame++;
|
||||
int currentFrametime = (e.holdFrametime > 0 && e.frameIndex == e.holdFrame)
|
||||
? e.holdFrametime
|
||||
: e.frametime;
|
||||
if (e.tickInFrame < currentFrametime) continue;
|
||||
e.tickInFrame = 0;
|
||||
e.frameIndex = (e.frameIndex + 1) % e.frames;
|
||||
blitFrame(e);
|
||||
e.target.upload();
|
||||
}
|
||||
}
|
||||
|
||||
private static void blitFrame(Entry e) {
|
||||
NativeImage dst = e.target.getPixels();
|
||||
if (dst == null) return;
|
||||
int srcYOffset = e.frameIndex * e.frameHeight;
|
||||
e.source.copyRect(dst, 0, srcYOffset, 0, 0, e.frameWidth, e.frameHeight, false, false);
|
||||
}
|
||||
}
|
||||
@ -1,158 +0,0 @@
|
||||
package com.razz.dfashion.cosmetic;
|
||||
|
||||
import com.razz.dfashion.DecoFashion;
|
||||
|
||||
import net.minecraft.resources.Identifier;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.DirectoryStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Scans {@code <gameDir>/decofashion/cosmetics/<category>/<folder>/} for user-authored
|
||||
* cosmetics. One folder per cosmetic; category = parent dir name; variants = extra PNGs.
|
||||
*/
|
||||
public final class UserCosmeticLoader {
|
||||
|
||||
public static final String USER_NAMESPACE = "decofashion_user";
|
||||
private static final String ROOT_DIR = "decofashion/cosmetics";
|
||||
|
||||
/** One scan result — a concrete (id, def, on-disk model file, on-disk texture file) tuple. */
|
||||
public record Entry(
|
||||
Identifier id,
|
||||
CosmeticDefinition def,
|
||||
Path modelFile,
|
||||
Path textureFile
|
||||
) {}
|
||||
|
||||
private UserCosmeticLoader() {}
|
||||
|
||||
public static List<Entry> scan(Path gameDir) {
|
||||
List<Entry> out = new ArrayList<>();
|
||||
Path root = gameDir.resolve(ROOT_DIR);
|
||||
|
||||
if (!Files.isDirectory(root)) {
|
||||
try {
|
||||
Files.createDirectories(root);
|
||||
DecoFashion.LOGGER.info("Created user cosmetics folder at {}", root);
|
||||
} catch (IOException ex) {
|
||||
DecoFashion.LOGGER.warn("Couldn't create user cosmetics folder {}: {}",
|
||||
root, ex.getMessage());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
try (DirectoryStream<Path> categories = Files.newDirectoryStream(root)) {
|
||||
for (Path categoryDir : categories) {
|
||||
if (!Files.isDirectory(categoryDir)) continue;
|
||||
String category = normalize(categoryDir.getFileName().toString());
|
||||
if (category.isEmpty()) continue;
|
||||
|
||||
try (DirectoryStream<Path> folders = Files.newDirectoryStream(categoryDir)) {
|
||||
for (Path folder : folders) {
|
||||
if (!Files.isDirectory(folder)) continue;
|
||||
scanOne(folder, category, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (IOException ex) {
|
||||
DecoFashion.LOGGER.error("Failed scanning user cosmetics under {}", root, ex);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static void scanOne(Path cosmeticDir, String category, List<Entry> out) throws IOException {
|
||||
String rawFolder = cosmeticDir.getFileName().toString();
|
||||
String folderId = normalize(rawFolder);
|
||||
if (folderId.isEmpty()) return;
|
||||
|
||||
Path modelFile = null;
|
||||
List<Path> pngs = new ArrayList<>();
|
||||
|
||||
try (DirectoryStream<Path> files = Files.newDirectoryStream(cosmeticDir)) {
|
||||
for (Path f : files) {
|
||||
if (!Files.isRegularFile(f)) continue;
|
||||
String name = f.getFileName().toString().toLowerCase(Locale.ROOT);
|
||||
if (name.endsWith(".bbmodel")) {
|
||||
if (modelFile == null) modelFile = f;
|
||||
} else if (name.endsWith(".png")) {
|
||||
pngs.add(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (modelFile == null) {
|
||||
DecoFashion.LOGGER.warn("User cosmetic {}/{}: no .bbmodel, skipping", category, rawFolder);
|
||||
return;
|
||||
}
|
||||
if (pngs.isEmpty()) {
|
||||
DecoFashion.LOGGER.warn("User cosmetic {}/{}: no .png, skipping", category, rawFolder);
|
||||
return;
|
||||
}
|
||||
pngs.sort(Comparator.comparing(p -> p.getFileName().toString().toLowerCase(Locale.ROOT)));
|
||||
|
||||
// Single texture that matches the folder name → un-suffixed id/display.
|
||||
boolean singleMatching = pngs.size() == 1
|
||||
&& stem(pngs.get(0)).equalsIgnoreCase(rawFolder);
|
||||
|
||||
for (Path png : pngs) {
|
||||
String textureId = normalize(stem(png));
|
||||
if (textureId.isEmpty()) continue;
|
||||
|
||||
String idPath;
|
||||
String displayName;
|
||||
if (singleMatching) {
|
||||
idPath = folderId;
|
||||
displayName = titleCase(folderId);
|
||||
} else {
|
||||
idPath = folderId + "_" + textureId;
|
||||
displayName = titleCase(folderId) + " " + titleCase(textureId);
|
||||
}
|
||||
|
||||
Identifier id = Identifier.fromNamespaceAndPath(USER_NAMESPACE, idPath);
|
||||
Identifier modelRef = Identifier.fromNamespaceAndPath(
|
||||
USER_NAMESPACE, "cosmetic/" + idPath + ".bbmodel");
|
||||
Identifier textureRef = Identifier.fromNamespaceAndPath(
|
||||
USER_NAMESPACE, "textures/cosmetic/" + idPath + ".png");
|
||||
|
||||
CosmeticDefinition def = new CosmeticDefinition(displayName, category, modelRef, textureRef);
|
||||
out.add(new Entry(id, def, modelFile, png));
|
||||
}
|
||||
}
|
||||
|
||||
private static String stem(Path file) {
|
||||
String name = file.getFileName().toString();
|
||||
int dot = name.lastIndexOf('.');
|
||||
return dot < 0 ? name : name.substring(0, dot);
|
||||
}
|
||||
|
||||
/** lowercase; spaces/hyphens → underscore; strip anything outside [a-z0-9_]. */
|
||||
private static String normalize(String raw) {
|
||||
StringBuilder sb = new StringBuilder(raw.length());
|
||||
for (int i = 0; i < raw.length(); i++) {
|
||||
char c = Character.toLowerCase(raw.charAt(i));
|
||||
if (c == ' ' || c == '-') sb.append('_');
|
||||
else if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_') sb.append(c);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** snake_case → Title Case. */
|
||||
private static String titleCase(String id) {
|
||||
if (id.isEmpty()) return id;
|
||||
String[] parts = id.split("_");
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (String part : parts) {
|
||||
if (part.isEmpty()) continue;
|
||||
if (sb.length() > 0) sb.append(' ');
|
||||
sb.append(Character.toUpperCase(part.charAt(0)));
|
||||
if (part.length() > 1) sb.append(part.substring(1));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
{
|
||||
"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
@ -1,3 +1,3 @@
|
||||
{
|
||||
"_comment": "DecoFashion translation keys — populated as features are added"
|
||||
"block.decofashion.wardrobe_1": "Wardrobe"
|
||||
}
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
{
|
||||
"textures": {
|
||||
"particle": "decofashion:closet/closet_base_spruce"
|
||||
}
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
{
|
||||
"parent": "decofashion:block/closet"
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 33 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@ -0,0 +1,7 @@
|
||||
{
|
||||
"wardrobe_1": {
|
||||
"closed": "decofashion:closet/wardrobe_1_closed.bbmodel",
|
||||
"open": "decofashion:closet/wardrobe_1_open.bbmodel",
|
||||
"texture": "decofashion:textures/closet/wardrobe_1.png"
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue