Clean repo for open source release
Remove build artifacts, dev tool configs, unused dependencies, and third-party source dumps. Add proper README, update .gitignore, clean up Makefile.
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
package com.tiedup.remake.v2.bondage.datadriven;
|
||||
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import com.tiedup.remake.v2.bondage.IV2BondageEquipment;
|
||||
import com.tiedup.remake.v2.bondage.V2BondageItems;
|
||||
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
|
||||
import com.tiedup.remake.v2.bondage.items.AbstractV2BondageItem;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
/**
|
||||
* Generic Item class for all data-driven bondage items.
|
||||
*
|
||||
* <p>A single Forge-registered Item. Each ItemStack carries a {@code tiedup_item_id}
|
||||
* NBT tag that points to a {@link DataDrivenItemDefinition} in the
|
||||
* {@link DataDrivenItemRegistry}. All property methods are overridden to read
|
||||
* from the definition via the stack-aware interface methods.</p>
|
||||
*
|
||||
* <p>The no-arg methods return safe defaults because the singleton item cannot
|
||||
* know which definition to use without an ItemStack. The real values come
|
||||
* exclusively from the stack-aware overrides.</p>
|
||||
*/
|
||||
public class DataDrivenBondageItem extends AbstractV2BondageItem {
|
||||
|
||||
public DataDrivenBondageItem() {
|
||||
super(new Properties().stacksTo(1));
|
||||
}
|
||||
|
||||
// ===== REGIONS (stack-aware overrides) =====
|
||||
|
||||
@Override
|
||||
public Set<BodyRegionV2> getOccupiedRegions() {
|
||||
// Safe default for the singleton — real value comes from stack-aware override
|
||||
return Set.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<BodyRegionV2> getOccupiedRegions(ItemStack stack) {
|
||||
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
|
||||
return def != null ? def.occupiedRegions() : Set.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<BodyRegionV2> getBlockedRegions(ItemStack stack) {
|
||||
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
|
||||
return def != null ? def.blockedRegions() : Set.of();
|
||||
}
|
||||
|
||||
// ===== 3D MODELS (stack-aware overrides) =====
|
||||
|
||||
@Override
|
||||
@Nullable
|
||||
public ResourceLocation getModelLocation() {
|
||||
return null; // Safe default
|
||||
}
|
||||
|
||||
@Override
|
||||
@Nullable
|
||||
public ResourceLocation getModelLocation(ItemStack stack) {
|
||||
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
|
||||
return def != null ? def.modelLocation() : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Nullable
|
||||
public ResourceLocation getSlimModelLocation(ItemStack stack) {
|
||||
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
|
||||
return def != null ? def.slimModelLocation() : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsSlimModel(ItemStack stack) {
|
||||
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
|
||||
return def != null && def.slimModelLocation() != null;
|
||||
}
|
||||
|
||||
// ===== POSES & ANIMATIONS (stack-aware overrides) =====
|
||||
|
||||
@Override
|
||||
public int getPosePriority() {
|
||||
return 0; // Safe default
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getPosePriority(ItemStack stack) {
|
||||
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
|
||||
return def != null ? def.posePriority() : 0;
|
||||
}
|
||||
|
||||
// ===== ITEM STATE (stack-aware overrides) =====
|
||||
|
||||
@Override
|
||||
public int getEscapeDifficulty(ItemStack stack) {
|
||||
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
|
||||
return def != null ? def.escapeDifficulty() : 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsColor(ItemStack stack) {
|
||||
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
|
||||
return def != null && def.supportsColor();
|
||||
}
|
||||
|
||||
// ===== IHasResistance IMPLEMENTATION =====
|
||||
|
||||
@Override
|
||||
public String getResistanceId() {
|
||||
// Safe default for the singleton -- the real resistance comes from
|
||||
// getBaseResistance() which bypasses the GameRules switch entirely.
|
||||
return "data_driven";
|
||||
}
|
||||
|
||||
/**
|
||||
* Bypass the GameRules switch lookup entirely for data-driven items.
|
||||
*
|
||||
* <p>The default IHasResistance implementation calls
|
||||
* {@code ModGameRules.getResistance(gameRules, getResistanceId())} which has
|
||||
* a hardcoded switch for "rope", "gag", "blindfold", "collar" and defaults
|
||||
* to 100 for everything else. This makes the JSON {@code escape_difficulty}
|
||||
* field useless.</p>
|
||||
*
|
||||
* <p>Instead, we scan the entity's equipped items to find ALL data-driven items
|
||||
* and return the MAX escape difficulty. This is because IHasResistance has no
|
||||
* ItemStack parameter, so we cannot distinguish which specific data-driven item
|
||||
* is being queried when multiple are equipped (they all share the same Item
|
||||
* singleton). Returning the MAX is the safe choice: it prevents the struggle
|
||||
* system from underestimating resistance.</p>
|
||||
*/
|
||||
@Override
|
||||
public int getBaseResistance(LivingEntity entity) {
|
||||
if (entity != null) {
|
||||
IV2BondageEquipment equip = V2EquipmentHelper.getEquipment(entity);
|
||||
if (equip != null) {
|
||||
int maxDifficulty = -1;
|
||||
for (Map.Entry<BodyRegionV2, ItemStack> entry : equip.getAllEquipped().entrySet()) {
|
||||
ItemStack stack = entry.getValue();
|
||||
if (stack.getItem() == this) {
|
||||
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
|
||||
if (def != null) {
|
||||
maxDifficulty = Math.max(maxDifficulty, def.escapeDifficulty());
|
||||
}
|
||||
}
|
||||
}
|
||||
if (maxDifficulty >= 0) {
|
||||
return maxDifficulty;
|
||||
}
|
||||
}
|
||||
}
|
||||
return 100; // safe fallback
|
||||
}
|
||||
|
||||
@Override
|
||||
public void notifyStruggle(LivingEntity entity) {
|
||||
// Play a generic chain sound for data-driven items
|
||||
entity.level().playSound(
|
||||
null, entity.getX(), entity.getY(), entity.getZ(),
|
||||
net.minecraft.sounds.SoundEvents.CHAIN_STEP,
|
||||
net.minecraft.sounds.SoundSource.PLAYERS,
|
||||
0.4f, 1.0f
|
||||
);
|
||||
}
|
||||
|
||||
// ===== DISPLAY NAME =====
|
||||
|
||||
@Override
|
||||
public Component getName(ItemStack stack) {
|
||||
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
|
||||
if (def == null) return super.getName(stack);
|
||||
if (def.translationKey() != null) {
|
||||
return Component.translatable(def.translationKey());
|
||||
}
|
||||
return Component.literal(def.displayName());
|
||||
}
|
||||
|
||||
// ===== FACTORY =====
|
||||
|
||||
/**
|
||||
* Create an ItemStack for a data-driven bondage item.
|
||||
*
|
||||
* @param itemId the definition ID (must exist in {@link DataDrivenItemRegistry})
|
||||
* @return a new ItemStack with the {@code tiedup_item_id} NBT tag set,
|
||||
* or {@link ItemStack#EMPTY} if the item is not registered in Forge
|
||||
*/
|
||||
public static ItemStack createStack(ResourceLocation itemId) {
|
||||
if (V2BondageItems.DATA_DRIVEN_ITEM == null) return ItemStack.EMPTY;
|
||||
ItemStack stack = new ItemStack(V2BondageItems.DATA_DRIVEN_ITEM.get());
|
||||
stack.getOrCreateTag().putString(DataDrivenItemRegistry.NBT_ITEM_ID, itemId.toString());
|
||||
return stack;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.tiedup.remake.v2.bondage.datadriven;
|
||||
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
/**
|
||||
* Immutable definition for a data-driven bondage item.
|
||||
*
|
||||
* <p>Loaded from JSON files in {@code assets/<namespace>/tiedup_items/}.
|
||||
* Each definition describes the properties of a bondage item variant
|
||||
* that can be instantiated as an ItemStack with the {@code tiedup_item_id} NBT tag.</p>
|
||||
*
|
||||
* <p>All rendering and gameplay properties are read from this record at runtime
|
||||
* via {@link DataDrivenBondageItem}'s stack-aware method overrides.</p>
|
||||
*/
|
||||
public record DataDrivenItemDefinition(
|
||||
/** Unique identifier for this item definition (e.g., "tiedup:leather_armbinder"). */
|
||||
ResourceLocation id,
|
||||
|
||||
/** Human-readable display name (fallback if no translation key). */
|
||||
String displayName,
|
||||
|
||||
/** Optional translation key for localized display name. */
|
||||
@Nullable String translationKey,
|
||||
|
||||
/** Resource location of the GLB model file. */
|
||||
ResourceLocation modelLocation,
|
||||
|
||||
/** Optional slim (Alex-style) model variant. */
|
||||
@Nullable ResourceLocation slimModelLocation,
|
||||
|
||||
/** Optional base texture path for color variant resolution. */
|
||||
@Nullable ResourceLocation texturePath,
|
||||
|
||||
/** Optional separate GLB for animations (shared template). */
|
||||
@Nullable ResourceLocation animationSource,
|
||||
|
||||
/** Body regions this item occupies. Never empty. */
|
||||
Set<BodyRegionV2> occupiedRegions,
|
||||
|
||||
/** Body regions this item blocks. Defaults to occupiedRegions if not specified. */
|
||||
Set<BodyRegionV2> blockedRegions,
|
||||
|
||||
/** Pose priority for conflict resolution. Higher wins. */
|
||||
int posePriority,
|
||||
|
||||
/** Escape difficulty for the struggle minigame. */
|
||||
int escapeDifficulty,
|
||||
|
||||
/** Whether this item can be locked with a padlock. */
|
||||
boolean lockable,
|
||||
|
||||
/** Whether this item supports color variants. */
|
||||
boolean supportsColor,
|
||||
|
||||
/** Default tint colors per channel (e.g. "tintable_0" -> 0x8B4513). Empty map if none. */
|
||||
Map<String, Integer> tintChannels,
|
||||
|
||||
/**
|
||||
* Optional inventory icon model location (e.g., "tiedup:item/armbinder").
|
||||
*
|
||||
* <p>Points to a standard {@code item/generated} model JSON that will be used
|
||||
* as the inventory sprite for this data-driven item variant. When null, the
|
||||
* default {@code tiedup:item/data_driven_item} model is used.</p>
|
||||
*/
|
||||
@Nullable ResourceLocation icon,
|
||||
|
||||
/**
|
||||
* Optional movement style that changes how a bound player physically moves.
|
||||
* Determines server-side speed reduction, jump suppression, and client animation.
|
||||
*/
|
||||
@Nullable com.tiedup.remake.v2.bondage.movement.MovementStyle movementStyle,
|
||||
|
||||
/**
|
||||
* Optional per-item overrides for the movement style's default values.
|
||||
* Requires {@code movementStyle} to be non-null (ignored otherwise).
|
||||
*/
|
||||
@Nullable com.tiedup.remake.v2.bondage.movement.MovementModifier movementModifier,
|
||||
|
||||
/**
|
||||
* Per-animation bone whitelist. Maps animation name (e.g. "idle", "struggle")
|
||||
* to the set of PlayerAnimator bone names this item is allowed to animate.
|
||||
*
|
||||
* <p>Valid bone names: head, body, rightArm, leftArm, rightLeg, leftLeg.</p>
|
||||
*
|
||||
* <p>At animation time, the effective parts for a given clip are computed as
|
||||
* {@code intersection(animationBones[clipName], ownedParts)}. If the clip name
|
||||
* is not present in this map (or null), the item falls back to its full
|
||||
* {@code ownedParts}.</p>
|
||||
*
|
||||
* <p>This field is required in the JSON definition. Never null, never empty.</p>
|
||||
*/
|
||||
Map<String, Set<String>> animationBones
|
||||
) {}
|
||||
@@ -0,0 +1,422 @@
|
||||
package com.tiedup.remake.v2.bondage.datadriven;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import com.tiedup.remake.v2.bondage.movement.MovementModifier;
|
||||
import com.tiedup.remake.v2.bondage.movement.MovementStyle;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collections;
|
||||
import java.util.EnumSet;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
/**
|
||||
* Parses JSON files into {@link DataDrivenItemDefinition} instances.
|
||||
*
|
||||
* <p>Uses manual field extraction (not Gson deserialization) for validation control.
|
||||
* Invalid fields are logged as warnings; critical errors (missing type, empty regions,
|
||||
* missing model) cause the entire definition to be skipped.</p>
|
||||
*
|
||||
* <p>Expected JSON format:
|
||||
* <pre>{@code
|
||||
* {
|
||||
* "type": "tiedup:bondage_item",
|
||||
* "display_name": "Leather Armbinder",
|
||||
* "translation_key": "item.tiedup.leather_armbinder",
|
||||
* "model": "tiedup:models/gltf/v2/armbinder/armbinder.glb",
|
||||
* "slim_model": "tiedup:models/gltf/v2/armbinder/armbinder_slim.glb",
|
||||
* "texture": "tiedup:textures/item/armbinder",
|
||||
* "animation_source": "tiedup:models/gltf/v2/armbinder/armbinder_anim.glb",
|
||||
* "regions": ["ARMS", "HANDS", "TORSO"],
|
||||
* "blocked_regions": ["ARMS", "HANDS", "TORSO", "FINGERS"],
|
||||
* "pose_type": "STANDARD",
|
||||
* "pose_priority": 50,
|
||||
* "escape_difficulty": 150,
|
||||
* "resistance_id": "armbinder",
|
||||
* "lockable": true,
|
||||
* "supports_color": false,
|
||||
* "color_variants": []
|
||||
* }
|
||||
* }</pre>
|
||||
*/
|
||||
public final class DataDrivenItemParser {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger("DataDrivenItems");
|
||||
|
||||
private DataDrivenItemParser() {}
|
||||
|
||||
/**
|
||||
* Parse a JSON input stream into a DataDrivenItemDefinition.
|
||||
*
|
||||
* @param input the JSON input stream
|
||||
* @param fileId the resource location of the file (for error messages)
|
||||
* @return the parsed definition, or null if the file is invalid
|
||||
*/
|
||||
@Nullable
|
||||
public static DataDrivenItemDefinition parse(InputStream input, ResourceLocation fileId) {
|
||||
try {
|
||||
JsonObject root = JsonParser.parseReader(
|
||||
new InputStreamReader(input, StandardCharsets.UTF_8)
|
||||
).getAsJsonObject();
|
||||
|
||||
return parseObject(root, fileId);
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("[DataDrivenItems] Failed to parse JSON {}: {}", fileId, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a JsonObject into a DataDrivenItemDefinition.
|
||||
*
|
||||
* @param root the parsed JSON object
|
||||
* @param fileId the resource location of the file (for error messages)
|
||||
* @return the parsed definition, or null if validation fails
|
||||
*/
|
||||
@Nullable
|
||||
public static DataDrivenItemDefinition parseObject(JsonObject root, ResourceLocation fileId) {
|
||||
// Validate type field
|
||||
String type = getStringOrNull(root, "type");
|
||||
if (!"tiedup:bondage_item".equals(type)) {
|
||||
LOGGER.error("[DataDrivenItems] Skipping {}: invalid or missing type '{}' (expected 'tiedup:bondage_item')",
|
||||
fileId, type);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Required: display_name
|
||||
String displayName = getStringOrNull(root, "display_name");
|
||||
if (displayName == null || displayName.isEmpty()) {
|
||||
LOGGER.error("[DataDrivenItems] Skipping {}: missing 'display_name'", fileId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Optional: translation_key
|
||||
String translationKey = getStringOrNull(root, "translation_key");
|
||||
|
||||
// Required: model
|
||||
String modelStr = getStringOrNull(root, "model");
|
||||
if (modelStr == null || modelStr.isEmpty()) {
|
||||
LOGGER.error("[DataDrivenItems] Skipping {}: missing 'model'", fileId);
|
||||
return null;
|
||||
}
|
||||
ResourceLocation modelLocation = ResourceLocation.tryParse(modelStr);
|
||||
if (modelLocation == null) {
|
||||
LOGGER.error("[DataDrivenItems] Skipping {}: invalid model ResourceLocation '{}'", fileId, modelStr);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Optional: slim_model
|
||||
ResourceLocation slimModelLocation = parseOptionalResourceLocation(root, "slim_model", fileId);
|
||||
|
||||
// Optional: texture
|
||||
ResourceLocation texturePath = parseOptionalResourceLocation(root, "texture", fileId);
|
||||
|
||||
// Optional: animation_source
|
||||
ResourceLocation animationSource = parseOptionalResourceLocation(root, "animation_source", fileId);
|
||||
|
||||
// Required: regions (non-empty)
|
||||
Set<BodyRegionV2> occupiedRegions = parseRegions(root, "regions", fileId);
|
||||
if (occupiedRegions == null || occupiedRegions.isEmpty()) {
|
||||
LOGGER.error("[DataDrivenItems] Skipping {}: missing or empty 'regions'", fileId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Optional: blocked_regions (defaults to regions)
|
||||
Set<BodyRegionV2> blockedRegions = parseRegions(root, "blocked_regions", fileId);
|
||||
if (blockedRegions == null || blockedRegions.isEmpty()) {
|
||||
blockedRegions = occupiedRegions;
|
||||
}
|
||||
|
||||
// Optional: pose_priority (default 0)
|
||||
int posePriority = getIntOrDefault(root, "pose_priority", 0);
|
||||
|
||||
// Optional: escape_difficulty (default 0)
|
||||
int escapeDifficulty = getIntOrDefault(root, "escape_difficulty", 0);
|
||||
|
||||
// Optional: lockable (default true)
|
||||
boolean lockable = getBooleanOrDefault(root, "lockable", true);
|
||||
|
||||
// Optional: supports_color (default false)
|
||||
boolean supportsColor = getBooleanOrDefault(root, "supports_color", false);
|
||||
|
||||
// Optional: tint_channels (default empty)
|
||||
Map<String, Integer> tintChannels = parseTintChannels(root, "tint_channels", fileId);
|
||||
|
||||
// Optional: icon (item model ResourceLocation for inventory sprite)
|
||||
ResourceLocation icon = parseOptionalResourceLocation(root, "icon", fileId);
|
||||
|
||||
// Optional: movement_style (requires valid MovementStyle name)
|
||||
MovementStyle movementStyle = null;
|
||||
String movementStyleStr = getStringOrNull(root, "movement_style");
|
||||
if (movementStyleStr != null && !movementStyleStr.isEmpty()) {
|
||||
movementStyle = MovementStyle.fromName(movementStyleStr);
|
||||
if (movementStyle == null) {
|
||||
LOGGER.warn("[DataDrivenItems] In {}: unknown movement_style '{}', ignoring",
|
||||
fileId, movementStyleStr);
|
||||
}
|
||||
}
|
||||
|
||||
// Optional: movement_modifier (requires movement_style to be set)
|
||||
MovementModifier movementModifier = null;
|
||||
if (movementStyle != null && root.has("movement_modifier") && root.get("movement_modifier").isJsonObject()) {
|
||||
JsonObject modObj = root.getAsJsonObject("movement_modifier");
|
||||
Float speedMul = getFloatOrNull(modObj, "speed_multiplier");
|
||||
Boolean jumpDis = getBooleanOrNull(modObj, "jump_disabled");
|
||||
if (speedMul != null || jumpDis != null) {
|
||||
movementModifier = new MovementModifier(speedMul, jumpDis);
|
||||
}
|
||||
} else if (movementStyle == null && root.has("movement_modifier")) {
|
||||
LOGGER.warn("[DataDrivenItems] In {}: movement_modifier ignored because movement_style is absent",
|
||||
fileId);
|
||||
}
|
||||
|
||||
// Required: animation_bones (per-animation bone whitelist)
|
||||
Map<String, Set<String>> animationBones = parseAnimationBones(root, fileId);
|
||||
if (animationBones == null) {
|
||||
LOGGER.error("[DataDrivenItems] Skipping {}: missing or invalid 'animation_bones'", fileId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build the item ID from the file path
|
||||
// fileId is like "tiedup:tiedup_items/leather_armbinder.json"
|
||||
// We want "tiedup:leather_armbinder"
|
||||
String idPath = fileId.getPath();
|
||||
// Strip "tiedup_items/" prefix
|
||||
if (idPath.startsWith("tiedup_items/")) {
|
||||
idPath = idPath.substring("tiedup_items/".length());
|
||||
}
|
||||
// Strip ".json" suffix
|
||||
if (idPath.endsWith(".json")) {
|
||||
idPath = idPath.substring(0, idPath.length() - 5);
|
||||
}
|
||||
ResourceLocation id = new ResourceLocation(fileId.getNamespace(), idPath);
|
||||
|
||||
return new DataDrivenItemDefinition(
|
||||
id, displayName, translationKey, modelLocation, slimModelLocation,
|
||||
texturePath, animationSource, occupiedRegions, blockedRegions,
|
||||
posePriority, escapeDifficulty,
|
||||
lockable, supportsColor, tintChannels, icon,
|
||||
movementStyle, movementModifier, animationBones
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Helper Methods =====
|
||||
|
||||
@Nullable
|
||||
private static String getStringOrNull(JsonObject obj, String key) {
|
||||
if (!obj.has(key) || obj.get(key).isJsonNull()) return null;
|
||||
try {
|
||||
return obj.get(key).getAsString();
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static int getIntOrDefault(JsonObject obj, String key, int defaultValue) {
|
||||
if (!obj.has(key) || obj.get(key).isJsonNull()) return defaultValue;
|
||||
try {
|
||||
return obj.get(key).getAsInt();
|
||||
} catch (Exception e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean getBooleanOrDefault(JsonObject obj, String key, boolean defaultValue) {
|
||||
if (!obj.has(key) || obj.get(key).isJsonNull()) return defaultValue;
|
||||
try {
|
||||
return obj.get(key).getAsBoolean();
|
||||
} catch (Exception e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static Float getFloatOrNull(JsonObject obj, String key) {
|
||||
if (!obj.has(key) || obj.get(key).isJsonNull()) return null;
|
||||
try {
|
||||
return obj.get(key).getAsFloat();
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static Boolean getBooleanOrNull(JsonObject obj, String key) {
|
||||
if (!obj.has(key) || obj.get(key).isJsonNull()) return null;
|
||||
try {
|
||||
return obj.get(key).getAsBoolean();
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static ResourceLocation parseOptionalResourceLocation(
|
||||
JsonObject obj, String key, ResourceLocation fileId
|
||||
) {
|
||||
String value = getStringOrNull(obj, key);
|
||||
if (value == null || value.isEmpty()) return null;
|
||||
ResourceLocation loc = ResourceLocation.tryParse(value);
|
||||
if (loc == null) {
|
||||
LOGGER.warn("[DataDrivenItems] In {}: invalid ResourceLocation for '{}': '{}'", fileId, key, value);
|
||||
}
|
||||
return loc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a JSON string array into an EnumSet of BodyRegionV2.
|
||||
* Unknown region names are logged as warnings and skipped.
|
||||
*/
|
||||
@Nullable
|
||||
private static Set<BodyRegionV2> parseRegions(JsonObject obj, String key, ResourceLocation fileId) {
|
||||
if (!obj.has(key) || !obj.get(key).isJsonArray()) return null;
|
||||
|
||||
JsonArray arr = obj.getAsJsonArray(key);
|
||||
if (arr.isEmpty()) return null;
|
||||
|
||||
EnumSet<BodyRegionV2> regions = EnumSet.noneOf(BodyRegionV2.class);
|
||||
for (JsonElement elem : arr) {
|
||||
try {
|
||||
String name = elem.getAsString().toUpperCase();
|
||||
BodyRegionV2 region = BodyRegionV2.fromName(name);
|
||||
if (region != null) {
|
||||
regions.add(region);
|
||||
} else {
|
||||
LOGGER.warn("[DataDrivenItems] In {}: unknown region '{}' in '{}', skipping",
|
||||
fileId, name, key);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("[DataDrivenItems] In {}: invalid element in '{}': {}",
|
||||
fileId, key, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return regions.isEmpty() ? null : Collections.unmodifiableSet(regions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a tint_channels JSON object mapping channel names to hex color strings.
|
||||
*
|
||||
* <p>Example JSON:
|
||||
* <pre>{@code
|
||||
* "tint_channels": {
|
||||
* "tintable_0": "#8B4513",
|
||||
* "tintable_1": "#FF0000"
|
||||
* }
|
||||
* }</pre>
|
||||
*
|
||||
* @param obj the parent JSON object
|
||||
* @param key the field name to parse
|
||||
* @param fileId the source file for error messages
|
||||
* @return an unmodifiable map of channel names to RGB ints, or empty map if absent
|
||||
*/
|
||||
private static Map<String, Integer> parseTintChannels(JsonObject obj, String key, ResourceLocation fileId) {
|
||||
if (!obj.has(key) || !obj.get(key).isJsonObject()) return Map.of();
|
||||
JsonObject channels = obj.getAsJsonObject(key);
|
||||
Map<String, Integer> result = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, JsonElement> entry : channels.entrySet()) {
|
||||
try {
|
||||
String hex = entry.getValue().getAsString();
|
||||
int color = Integer.parseInt(hex.startsWith("#") ? hex.substring(1) : hex, 16);
|
||||
result.put(entry.getKey(), color);
|
||||
} catch (NumberFormatException e) {
|
||||
LOGGER.warn("[DataDrivenItems] In {}: invalid hex color '{}' for tint channel '{}'",
|
||||
fileId, entry.getValue(), entry.getKey());
|
||||
}
|
||||
}
|
||||
return Collections.unmodifiableMap(result);
|
||||
}
|
||||
|
||||
/** Valid PlayerAnimator bone names for animation_bones validation. */
|
||||
private static final Set<String> VALID_BONE_NAMES = Set.of(
|
||||
"head", "body", "rightArm", "leftArm", "rightLeg", "leftLeg"
|
||||
);
|
||||
|
||||
/**
|
||||
* Parse the {@code animation_bones} JSON object.
|
||||
*
|
||||
* <p>Format:
|
||||
* <pre>{@code
|
||||
* "animation_bones": {
|
||||
* "idle": ["rightArm", "leftArm"],
|
||||
* "struggle": ["rightArm", "leftArm", "body"]
|
||||
* }
|
||||
* }</pre>
|
||||
*
|
||||
* <p>Each key is an animation name, each value is a JSON array of bone name strings.
|
||||
* Bone names are validated against the 6 PlayerAnimator parts. Invalid bone names
|
||||
* are logged as warnings and skipped. Empty arrays or unknown-only arrays cause the
|
||||
* entire animation entry to be skipped.</p>
|
||||
*
|
||||
* @param obj the parent JSON object
|
||||
* @param fileId the source file for error messages
|
||||
* @return unmodifiable map of animation name to bone set, or null if absent/invalid
|
||||
*/
|
||||
@Nullable
|
||||
private static Map<String, Set<String>> parseAnimationBones(JsonObject obj, ResourceLocation fileId) {
|
||||
if (!obj.has("animation_bones") || !obj.get("animation_bones").isJsonObject()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
JsonObject bonesObj = obj.getAsJsonObject("animation_bones");
|
||||
if (bonesObj.size() == 0) {
|
||||
LOGGER.error("[DataDrivenItems] In {}: 'animation_bones' is empty", fileId);
|
||||
return null;
|
||||
}
|
||||
|
||||
Map<String, Set<String>> result = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, JsonElement> entry : bonesObj.entrySet()) {
|
||||
String animName = entry.getKey();
|
||||
JsonElement value = entry.getValue();
|
||||
|
||||
if (!value.isJsonArray()) {
|
||||
LOGGER.warn("[DataDrivenItems] In {}: animation_bones['{}'] is not an array, skipping",
|
||||
fileId, animName);
|
||||
continue;
|
||||
}
|
||||
|
||||
JsonArray boneArray = value.getAsJsonArray();
|
||||
Set<String> bones = new HashSet<>();
|
||||
for (JsonElement boneElem : boneArray) {
|
||||
try {
|
||||
String boneName = boneElem.getAsString();
|
||||
if (VALID_BONE_NAMES.contains(boneName)) {
|
||||
bones.add(boneName);
|
||||
} else {
|
||||
LOGGER.warn("[DataDrivenItems] In {}: animation_bones['{}'] contains unknown bone '{}', skipping",
|
||||
fileId, animName, boneName);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("[DataDrivenItems] In {}: invalid element in animation_bones['{}']",
|
||||
fileId, animName);
|
||||
}
|
||||
}
|
||||
|
||||
if (!bones.isEmpty()) {
|
||||
result.put(animName, Collections.unmodifiableSet(bones));
|
||||
} else {
|
||||
LOGGER.warn("[DataDrivenItems] In {}: animation_bones['{}'] resolved to empty set, skipping",
|
||||
fileId, animName);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.isEmpty()) {
|
||||
LOGGER.error("[DataDrivenItems] In {}: 'animation_bones' has no valid entries", fileId);
|
||||
return null;
|
||||
}
|
||||
|
||||
return Collections.unmodifiableMap(result);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.tiedup.remake.v2.bondage.datadriven;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
/**
|
||||
* Thread-safe registry for data-driven bondage item definitions.
|
||||
*
|
||||
* <p>Populated by the reload listener that scans {@code tiedup_items/} JSON files.
|
||||
* Uses volatile atomic swap (same pattern as {@link
|
||||
* com.tiedup.remake.client.animation.context.ContextGlbRegistry}) to ensure
|
||||
* the render thread always sees a consistent snapshot.</p>
|
||||
*
|
||||
* <p>Lookup methods accept either a {@link ResourceLocation} ID directly
|
||||
* or an {@link ItemStack} (reads the {@code tiedup_item_id} NBT tag).</p>
|
||||
*/
|
||||
public final class DataDrivenItemRegistry {
|
||||
|
||||
/** NBT key storing the data-driven item ID on ItemStacks. */
|
||||
public static final String NBT_ITEM_ID = "tiedup_item_id";
|
||||
|
||||
/**
|
||||
* Volatile reference to an unmodifiable map. Reload builds a new map
|
||||
* and swaps atomically; consumer threads always see a consistent snapshot.
|
||||
*/
|
||||
private static volatile Map<ResourceLocation, DataDrivenItemDefinition> DEFINITIONS = Map.of();
|
||||
|
||||
private DataDrivenItemRegistry() {}
|
||||
|
||||
/**
|
||||
* Atomically replace all definitions with a new set.
|
||||
* Called by the reload listener after parsing all JSON files.
|
||||
*
|
||||
* @param newDefs the new definitions map (will be defensively copied)
|
||||
*/
|
||||
public static void reload(Map<ResourceLocation, DataDrivenItemDefinition> newDefs) {
|
||||
DEFINITIONS = Collections.unmodifiableMap(new HashMap<>(newDefs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically merge new definitions into the existing registry.
|
||||
*
|
||||
* <p>On an integrated server, both the client (assets/) and server (data/) reload
|
||||
* listeners populate this registry. Using {@link #reload} would cause the second
|
||||
* listener to overwrite the first's definitions. This method builds a new map
|
||||
* from the existing snapshot + the new entries, then swaps atomically.</p>
|
||||
*
|
||||
* @param newDefs the definitions to merge (will overwrite existing entries with same key)
|
||||
*/
|
||||
public static void mergeAll(Map<ResourceLocation, DataDrivenItemDefinition> newDefs) {
|
||||
Map<ResourceLocation, DataDrivenItemDefinition> merged = new HashMap<>(DEFINITIONS);
|
||||
merged.putAll(newDefs);
|
||||
DEFINITIONS = Collections.unmodifiableMap(merged);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a definition by its unique ID.
|
||||
*
|
||||
* @param id the definition ID (e.g., "tiedup:leather_armbinder")
|
||||
* @return the definition, or null if not found
|
||||
*/
|
||||
@Nullable
|
||||
public static DataDrivenItemDefinition get(ResourceLocation id) {
|
||||
return DEFINITIONS.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a definition from an ItemStack by reading the {@code tiedup_item_id} NBT tag.
|
||||
*
|
||||
* @param stack the ItemStack to inspect
|
||||
* @return the definition, or null if the stack is empty, has no tag, or the ID is unknown
|
||||
*/
|
||||
@Nullable
|
||||
public static DataDrivenItemDefinition get(ItemStack stack) {
|
||||
if (stack.isEmpty()) return null;
|
||||
CompoundTag tag = stack.getTag();
|
||||
if (tag == null || !tag.contains(NBT_ITEM_ID)) return null;
|
||||
ResourceLocation id = ResourceLocation.tryParse(tag.getString(NBT_ITEM_ID));
|
||||
if (id == null) return null;
|
||||
return DEFINITIONS.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered definitions.
|
||||
*
|
||||
* @return unmodifiable collection of all definitions
|
||||
*/
|
||||
public static Collection<DataDrivenItemDefinition> getAll() {
|
||||
return DEFINITIONS.values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all definitions. Called on world unload or for testing.
|
||||
*/
|
||||
public static void clear() {
|
||||
DEFINITIONS = Map.of();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.tiedup.remake.v2.bondage.datadriven;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.server.packs.resources.Resource;
|
||||
import net.minecraft.server.packs.resources.ResourceManager;
|
||||
import net.minecraft.server.packs.resources.SimplePreparableReloadListener;
|
||||
import net.minecraft.util.profiling.ProfilerFiller;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
/**
|
||||
* Resource reload listener that scans {@code assets/<namespace>/tiedup_items/}
|
||||
* for JSON files and populates the {@link DataDrivenItemRegistry}.
|
||||
*
|
||||
* <p>Registered via {@link net.minecraftforge.client.event.RegisterClientReloadListenersEvent}
|
||||
* in {@link com.tiedup.remake.client.gltf.GltfClientSetup}.</p>
|
||||
*
|
||||
* <p>Follows the same pattern as {@link com.tiedup.remake.client.animation.context.ContextGlbRegistry}:
|
||||
* prepare phase is a no-op, apply phase scans + parses + atomic-swaps the registry.</p>
|
||||
*/
|
||||
public class DataDrivenItemReloadListener extends SimplePreparableReloadListener<Void> {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger("DataDrivenItems");
|
||||
|
||||
/** Resource directory containing item definition JSON files. */
|
||||
private static final String DIRECTORY = "tiedup_items";
|
||||
|
||||
@Override
|
||||
protected Void prepare(ResourceManager resourceManager, ProfilerFiller profiler) {
|
||||
// No preparation needed — parsing happens in apply phase
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void apply(Void nothing, ResourceManager resourceManager, ProfilerFiller profiler) {
|
||||
Map<ResourceLocation, DataDrivenItemDefinition> newDefs = new HashMap<>();
|
||||
|
||||
Map<ResourceLocation, Resource> resources = resourceManager.listResources(
|
||||
DIRECTORY, loc -> loc.getPath().endsWith(".json")
|
||||
);
|
||||
|
||||
int skipped = 0;
|
||||
|
||||
for (Map.Entry<ResourceLocation, Resource> entry : resources.entrySet()) {
|
||||
ResourceLocation fileId = entry.getKey();
|
||||
Resource resource = entry.getValue();
|
||||
|
||||
try (InputStream input = resource.open()) {
|
||||
DataDrivenItemDefinition def = DataDrivenItemParser.parse(input, fileId);
|
||||
|
||||
if (def != null) {
|
||||
// Check for duplicate IDs
|
||||
if (newDefs.containsKey(def.id())) {
|
||||
LOGGER.warn("[DataDrivenItems] Duplicate item ID '{}' from file '{}' — overwriting previous definition",
|
||||
def.id(), fileId);
|
||||
}
|
||||
|
||||
newDefs.put(def.id(), def);
|
||||
LOGGER.debug("[DataDrivenItems] Loaded: {} -> '{}'", def.id(), def.displayName());
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("[DataDrivenItems] Failed to read resource {}: {}", fileId, e.getMessage());
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
// Merge into the registry (not replace) so the server listener doesn't
|
||||
// overwrite client-only definitions on integrated server
|
||||
DataDrivenItemRegistry.mergeAll(newDefs);
|
||||
|
||||
LOGGER.info("[DataDrivenItems] Loaded {} item definitions ({} skipped) from {} JSON files",
|
||||
newDefs.size(), skipped, resources.size());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.tiedup.remake.v2.bondage.datadriven;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.server.packs.resources.Resource;
|
||||
import net.minecraft.server.packs.resources.ResourceManager;
|
||||
import net.minecraft.server.packs.resources.SimplePreparableReloadListener;
|
||||
import net.minecraft.util.profiling.ProfilerFiller;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
/**
|
||||
* Server-side resource reload listener that scans {@code data/<namespace>/tiedup_items/}
|
||||
* for JSON files and populates the {@link DataDrivenItemRegistry}.
|
||||
*
|
||||
* <p>This is the server counterpart to {@link DataDrivenItemReloadListener} (client-side,
|
||||
* which scans {@code assets/}). On a dedicated server, only this listener runs.
|
||||
* On an integrated server (singleplayer), both listeners run -- the last one to apply
|
||||
* wins the atomic swap, but they parse identical JSON content so the result is the same.</p>
|
||||
*
|
||||
* <p>Registered via {@link net.minecraftforge.event.AddReloadListenerEvent} in
|
||||
* {@link com.tiedup.remake.core.TiedUpMod.ForgeEvents}.</p>
|
||||
*/
|
||||
public class DataDrivenItemServerReloadListener extends SimplePreparableReloadListener<Void> {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger("DataDrivenItems");
|
||||
|
||||
/** Resource directory containing item definition JSON files (under data/). */
|
||||
private static final String DIRECTORY = "tiedup_items";
|
||||
|
||||
@Override
|
||||
protected Void prepare(ResourceManager resourceManager, ProfilerFiller profiler) {
|
||||
// No preparation needed -- parsing happens in apply phase
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void apply(Void nothing, ResourceManager resourceManager, ProfilerFiller profiler) {
|
||||
Map<ResourceLocation, DataDrivenItemDefinition> newDefs = new HashMap<>();
|
||||
|
||||
Map<ResourceLocation, Resource> resources = resourceManager.listResources(
|
||||
DIRECTORY, loc -> loc.getPath().endsWith(".json")
|
||||
);
|
||||
|
||||
int skipped = 0;
|
||||
|
||||
for (Map.Entry<ResourceLocation, Resource> entry : resources.entrySet()) {
|
||||
ResourceLocation fileId = entry.getKey();
|
||||
Resource resource = entry.getValue();
|
||||
|
||||
try (InputStream input = resource.open()) {
|
||||
DataDrivenItemDefinition def = DataDrivenItemParser.parse(input, fileId);
|
||||
|
||||
if (def != null) {
|
||||
// Check for duplicate IDs
|
||||
if (newDefs.containsKey(def.id())) {
|
||||
LOGGER.warn("[DataDrivenItems] Server: Duplicate item ID '{}' from file '{}' -- overwriting previous definition",
|
||||
def.id(), fileId);
|
||||
}
|
||||
|
||||
newDefs.put(def.id(), def);
|
||||
LOGGER.debug("[DataDrivenItems] Server loaded: {} -> '{}'", def.id(), def.displayName());
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("[DataDrivenItems] Server: Failed to read resource {}: {}", fileId, e.getMessage());
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
// Merge into the registry (not replace) so the client listener's
|
||||
// definitions aren't overwritten on integrated server
|
||||
DataDrivenItemRegistry.mergeAll(newDefs);
|
||||
|
||||
LOGGER.info("[DataDrivenItems] Server loaded {} item definitions ({} skipped) from {} JSON files",
|
||||
newDefs.size(), skipped, resources.size());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user