diff --git a/docs/ARTIST_GUIDE.md b/docs/ARTIST_GUIDE.md index 3b72aeb..aa1eb79 100644 --- a/docs/ARTIST_GUIDE.md +++ b/docs/ARTIST_GUIDE.md @@ -16,7 +16,7 @@ 7. [Animations](#animations) — item poses, fallback chain, variants, context animations 8. [Animation Templates](#animation-templates) 9. [Exporting from Blender](#exporting-from-blender) -10. [The JSON Definition](#the-json-definition) +10. [The JSON Definition](#the-json-definition) — field reference, components, pose priority, movement styles 11. [Packaging as a Resource Pack](#packaging-as-a-resource-pack) 12. [Common Mistakes](#common-mistakes) 13. [Examples](#examples) @@ -764,6 +764,7 @@ The `movement_style` changes how the player physically moves — slower speed, d | `movement_modifier` | object | No | Override speed/jump for the movement style (requires `movement_style`) | | `creator` | string | No | Author/creator name, shown in the item tooltip | | `animation_bones` | object | Yes | Per-animation bone whitelist (see below) | +| `components` | object | No | Gameplay behavior components (see [Components](#components-gameplay-behaviors) below) | ### animation_bones (required) @@ -785,6 +786,87 @@ At runtime, the effective bones for a given animation clip are computed as the * This field is **required**. Items without `animation_bones` will be rejected by the parser. +### Components (Gameplay Behaviors) + +Components add gameplay behaviors to your item without requiring Java code. Each component is a self-contained module you declare in the `"components"` block of your JSON definition. + +**Format:** A JSON object where each key is a component name and each value is the component's configuration (an object, or `true` for defaults). + +```json +"components": { + "lockable": { "lock_resistance": 200 }, + "resistance": { "base": 150 }, + "gagging": { "comprehension": 0.2, "range": 10.0 } +} +``` + +Items without the `"components"` field work normally — components are entirely optional. + +#### Available Components + +| Component | Description | Config Fields | +|-----------|-------------|---------------| +| `lockable` | Item can be locked with a padlock. Locked items cannot be unequipped. | `lock_resistance` (int, default: 250) — resistance added by the lock for struggle mechanics | +| `resistance` | Struggle resistance. Higher = harder to escape. | `base` (int, default: 100) — base resistance value | +| `gagging` | Muffles the wearer's speech. | `comprehension` (0.0–1.0, default: 0.2) — how much speech is understandable. `range` (float, default: 10.0) — max hearing distance in blocks | +| `blinding` | Applies a blindfold overlay to the wearer's screen. | `overlay` (string, optional) — custom overlay texture path. Omit for default | +| `shock` | Item can shock the wearer (manually or automatically). | `damage` (float, default: 2.0) — damage per shock. `auto_interval` (int, default: 0) — ticks between auto-shocks (0 = manual only) | +| `gps` | GPS tracking and safe zone enforcement. | `safe_zone_radius` (int, default: 50) — safe zone in blocks (0 = tracking only). `public_tracking` (bool, default: false) — anyone can track, not just owner | +| `choking` | Drains air, applies darkness/slowness, deals damage when activated. | `air_drain_per_tick` (int, default: 8) — air drained per tick. `non_lethal_for_master` (bool, default: true) — won't kill if worn by a master's pet | +| `adjustable` | Allows Y-offset adjustment via GUI slider. | `default` (float, default: 0.0), `min` (float, default: -4.0), `max` (float, default: 4.0), `step` (float, default: 0.25) — all in pixels (1px = 1/16 block) | + +#### Example: Shock Collar with GPS + +```json +{ + "type": "tiedup:bondage_item", + "display_name": "GPS Shock Collar", + "model": "mycreator:models/gltf/gps_shock_collar.glb", + "regions": ["NECK"], + "animation_bones": { + "idle": [] + }, + "pose_priority": 10, + "escape_difficulty": 5, + "components": { + "lockable": { "lock_resistance": 300 }, + "resistance": { "base": 150 }, + "shock": { "damage": 3.0, "auto_interval": 200 }, + "gps": { "safe_zone_radius": 50 } + } +} +``` + +This collar can be locked (300 resistance to break the lock), has 150 base struggle resistance, shocks every 200 ticks (10 seconds) automatically, and enforces a 50-block safe zone. + +#### Example: Adjustable Blindfold + +```json +{ + "type": "tiedup:bondage_item", + "display_name": "Leather Blindfold", + "model": "mycreator:models/gltf/blindfold.glb", + "regions": ["EYES"], + "animation_bones": { + "idle": ["head"] + }, + "pose_priority": 10, + "escape_difficulty": 2, + "components": { + "blinding": {}, + "resistance": { "base": 80 }, + "adjustable": { "min": -2.0, "max": 2.0, "step": 0.5 } + } +} +``` + +#### Component Tips + +- **You can combine any components.** A gag with `gagging` + `lockable` + `resistance` + `adjustable` is perfectly valid. +- **Omit components you don't need.** A decorative collar with no shock/GPS just omits those components entirely. +- **Default values are sensible.** `"lockable": {}` gives you standard lock behavior with default resistance. You only need to specify fields you want to customize. +- **Components don't affect rendering.** They are purely gameplay — your GLB model and animations are independent of which components you use. + ### Pose Priority When multiple items affect the same bones, the highest `pose_priority` wins. diff --git a/src/main/java/com/tiedup/remake/v2/bondage/component/AdjustableComponent.java b/src/main/java/com/tiedup/remake/v2/bondage/component/AdjustableComponent.java new file mode 100644 index 0000000..63238e7 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/bondage/component/AdjustableComponent.java @@ -0,0 +1,67 @@ +package com.tiedup.remake.v2.bondage.component; + +import com.google.gson.JsonObject; + +/** + * Component: positional adjustment for data-driven items. + * Allows Y offset adjustment via GUI slider. + * + * JSON config: + * {@code "adjustable": {"default": 0.0, "min": -4.0, "max": 4.0, "step": 0.25}} + */ +public class AdjustableComponent implements IItemComponent { + + private final float defaultValue; + private final float min; + private final float max; + private final float step; + + private AdjustableComponent(float defaultValue, float min, float max, float step) { + this.defaultValue = defaultValue; + this.min = min; + this.max = max; + this.step = step; + } + + public static IItemComponent fromJson(JsonObject config) { + float defaultVal = 0.0f; + float min = -4.0f; + float max = 4.0f; + float step = 0.25f; + if (config != null) { + if (config.has("default")) defaultVal = config.get("default").getAsFloat(); + if (config.has("min")) min = config.get("min").getAsFloat(); + if (config.has("max")) max = config.get("max").getAsFloat(); + if (config.has("step")) step = config.get("step").getAsFloat(); + } + // Ensure min <= max + if (min > max) { + float tmp = min; + min = max; + max = tmp; + } + step = Math.max(0.01f, step); + defaultVal = Math.max(min, Math.min(max, defaultVal)); + return new AdjustableComponent(defaultVal, min, max, step); + } + + /** Default Y offset value. */ + public float getDefaultValue() { + return defaultValue; + } + + /** Minimum Y offset. */ + public float getMin() { + return min; + } + + /** Maximum Y offset. */ + public float getMax() { + return max; + } + + /** Step increment for the adjustment slider. */ + public float getStep() { + return step; + } +} diff --git a/src/main/java/com/tiedup/remake/v2/bondage/component/BlindingComponent.java b/src/main/java/com/tiedup/remake/v2/bondage/component/BlindingComponent.java new file mode 100644 index 0000000..d9142f6 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/bondage/component/BlindingComponent.java @@ -0,0 +1,36 @@ +package com.tiedup.remake.v2.bondage.component; + +import com.google.gson.JsonObject; +import org.jetbrains.annotations.Nullable; + +/** + * Component: blinding effect for data-driven items. + * Replaces IHasBlindingEffect marker interface. + * + * JSON config: + * {@code "blinding": {}} or {@code "blinding": {"overlay": "tiedup:textures/overlay/custom.png"}} + */ +public class BlindingComponent implements IItemComponent { + + private final String overlay; // nullable — null means default overlay + + private BlindingComponent(String overlay) { + this.overlay = overlay; + } + + public static IItemComponent fromJson(JsonObject config) { + String overlay = null; + if (config != null && config.has("overlay")) { + overlay = config.get("overlay").getAsString(); + } + return new BlindingComponent(overlay); + } + + /** + * Custom overlay texture path, or null for the mod's default blindfold overlay. + */ + @Nullable + public String getOverlay() { + return overlay; + } +} diff --git a/src/main/java/com/tiedup/remake/v2/bondage/component/ChokingComponent.java b/src/main/java/com/tiedup/remake/v2/bondage/component/ChokingComponent.java new file mode 100644 index 0000000..5860159 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/bondage/component/ChokingComponent.java @@ -0,0 +1,46 @@ +package com.tiedup.remake.v2.bondage.component; + +import com.google.gson.JsonObject; + +/** + * Component: choking effect for data-driven items. + * When active, drains air, applies darkness/slowness, deals damage. + * + * JSON config: + * {@code "choking": {"air_drain_per_tick": 8, "non_lethal_for_master": true}} + */ +public class ChokingComponent implements IItemComponent { + + private final int airDrainPerTick; + private final boolean nonLethalForMaster; + + private ChokingComponent(int airDrainPerTick, boolean nonLethalForMaster) { + this.airDrainPerTick = airDrainPerTick; + this.nonLethalForMaster = nonLethalForMaster; + } + + public static IItemComponent fromJson(JsonObject config) { + int drain = 8; + boolean nonLethal = true; + if (config != null) { + if (config.has("air_drain_per_tick")) { + drain = config.get("air_drain_per_tick").getAsInt(); + } + if (config.has("non_lethal_for_master")) { + nonLethal = config.get("non_lethal_for_master").getAsBoolean(); + } + } + drain = Math.max(1, drain); + return new ChokingComponent(drain, nonLethal); + } + + /** Air drained per tick (net after vanilla +4 restoration). */ + public int getAirDrainPerTick() { + return airDrainPerTick; + } + + /** Whether the choke is non-lethal when used by a master on their pet. */ + public boolean isNonLethalForMaster() { + return nonLethalForMaster; + } +} diff --git a/src/main/java/com/tiedup/remake/v2/bondage/component/ComponentHolder.java b/src/main/java/com/tiedup/remake/v2/bondage/component/ComponentHolder.java new file mode 100644 index 0000000..6a465ae --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/bondage/component/ComponentHolder.java @@ -0,0 +1,64 @@ +package com.tiedup.remake.v2.bondage.component; + +import java.util.Collections; +import java.util.EnumMap; +import java.util.Map; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.item.ItemStack; +import org.jetbrains.annotations.Nullable; + +public final class ComponentHolder { + + public static final ComponentHolder EMPTY = new ComponentHolder(Map.of()); + + private final Map components; + + public ComponentHolder(Map components) { + this.components = components.isEmpty() + ? Map.of() + : Collections.unmodifiableMap(new EnumMap<>(components)); + } + + @Nullable + public IItemComponent get(ComponentType type) { + return components.get(type); + } + + @Nullable + @SuppressWarnings("unchecked") + public T get( + ComponentType type, + Class clazz + ) { + IItemComponent component = components.get(type); + if (clazz.isInstance(component)) return (T) component; + return null; + } + + public boolean has(ComponentType type) { + return components.containsKey(type); + } + + public void onEquipped(ItemStack stack, LivingEntity entity) { + for (IItemComponent c : components.values()) { + c.onEquipped(stack, entity); + } + } + + public void onUnequipped(ItemStack stack, LivingEntity entity) { + for (IItemComponent c : components.values()) { + c.onUnequipped(stack, entity); + } + } + + public boolean blocksUnequip(ItemStack stack, LivingEntity entity) { + for (IItemComponent c : components.values()) { + if (c.blocksUnequip(stack, entity)) return true; + } + return false; + } + + public boolean isEmpty() { + return components.isEmpty(); + } +} diff --git a/src/main/java/com/tiedup/remake/v2/bondage/component/ComponentType.java b/src/main/java/com/tiedup/remake/v2/bondage/component/ComponentType.java new file mode 100644 index 0000000..3964db4 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/bondage/component/ComponentType.java @@ -0,0 +1,53 @@ +package com.tiedup.remake.v2.bondage.component; + +import com.google.gson.JsonObject; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; +import org.jetbrains.annotations.Nullable; + +public enum ComponentType { + LOCKABLE("lockable", LockableComponent::fromJson), + RESISTANCE("resistance", ResistanceComponent::fromJson), + GAGGING("gagging", GaggingComponent::fromJson), + BLINDING("blinding", BlindingComponent::fromJson), + SHOCK("shock", ShockComponent::fromJson), + GPS("gps", GpsComponent::fromJson), + CHOKING("choking", ChokingComponent::fromJson), + ADJUSTABLE("adjustable", AdjustableComponent::fromJson); + + private final String jsonKey; + private final Function factory; + + /** Pre-built lookup map for O(1) fromKey() instead of linear scan. */ + private static final Map BY_KEY; + static { + Map map = new HashMap<>(); + for (ComponentType type : values()) { + map.put(type.jsonKey, type); + } + BY_KEY = Collections.unmodifiableMap(map); + } + + ComponentType( + String jsonKey, + Function factory + ) { + this.jsonKey = jsonKey; + this.factory = factory; + } + + public String getJsonKey() { + return jsonKey; + } + + public IItemComponent create(JsonObject config) { + return factory.apply(config); + } + + @Nullable + public static ComponentType fromKey(String key) { + return BY_KEY.get(key); + } +} diff --git a/src/main/java/com/tiedup/remake/v2/bondage/component/GaggingComponent.java b/src/main/java/com/tiedup/remake/v2/bondage/component/GaggingComponent.java new file mode 100644 index 0000000..0e401a5 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/bondage/component/GaggingComponent.java @@ -0,0 +1,45 @@ +package com.tiedup.remake.v2.bondage.component; + +import com.google.gson.JsonObject; + +/** + * Component: gagging behavior for data-driven items. + * + * JSON config: {@code "gagging": {"comprehension": 0.2, "range": 10.0}} + */ +public class GaggingComponent implements IItemComponent { + + private final double comprehension; + private final double range; + + private GaggingComponent(double comprehension, double range) { + this.comprehension = comprehension; + this.range = range; + } + + public static IItemComponent fromJson(JsonObject config) { + double comprehension = 0.2; + double range = 10.0; + if (config != null) { + if (config.has("comprehension")) { + comprehension = config.get("comprehension").getAsDouble(); + } + if (config.has("range")) { + range = config.get("range").getAsDouble(); + } + } + comprehension = Math.max(0.0, Math.min(1.0, comprehension)); + range = Math.max(0.0, range); + return new GaggingComponent(comprehension, range); + } + + /** How much of the gagged speech is comprehensible (0.0 = nothing, 1.0 = full). */ + public double getComprehension() { + return comprehension; + } + + /** Maximum range in blocks where muffled speech can be heard. */ + public double getRange() { + return range; + } +} diff --git a/src/main/java/com/tiedup/remake/v2/bondage/component/GpsComponent.java b/src/main/java/com/tiedup/remake/v2/bondage/component/GpsComponent.java new file mode 100644 index 0000000..272792a --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/bondage/component/GpsComponent.java @@ -0,0 +1,45 @@ +package com.tiedup.remake.v2.bondage.component; + +import com.google.gson.JsonObject; + +/** + * Component: GPS tracking and safe zone for data-driven items. + * + * JSON config: + * {@code "gps": {"safe_zone_radius": 50, "public_tracking": false}} + */ +public class GpsComponent implements IItemComponent { + + private final int safeZoneRadius; + private final boolean publicTracking; + + private GpsComponent(int safeZoneRadius, boolean publicTracking) { + this.safeZoneRadius = safeZoneRadius; + this.publicTracking = publicTracking; + } + + public static IItemComponent fromJson(JsonObject config) { + int radius = 50; + boolean publicTracking = false; + if (config != null) { + if (config.has("safe_zone_radius")) { + radius = config.get("safe_zone_radius").getAsInt(); + } + if (config.has("public_tracking")) { + publicTracking = config.get("public_tracking").getAsBoolean(); + } + } + radius = Math.max(0, radius); + return new GpsComponent(radius, publicTracking); + } + + /** Safe zone radius in blocks. 0 = no safe zone (tracking only). */ + public int getSafeZoneRadius() { + return safeZoneRadius; + } + + /** Whether any player can track (not just the owner). */ + public boolean isPublicTracking() { + return publicTracking; + } +} diff --git a/src/main/java/com/tiedup/remake/v2/bondage/component/IItemComponent.java b/src/main/java/com/tiedup/remake/v2/bondage/component/IItemComponent.java new file mode 100644 index 0000000..4af99c0 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/bondage/component/IItemComponent.java @@ -0,0 +1,19 @@ +package com.tiedup.remake.v2.bondage.component; + +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.item.ItemStack; + +/** + * A reusable behavior module for data-driven bondage items. + * Components are declared in JSON and instantiated per item definition. + */ +public interface IItemComponent { + + default void onEquipped(ItemStack stack, LivingEntity entity) {} + + default void onUnequipped(ItemStack stack, LivingEntity entity) {} + + default boolean blocksUnequip(ItemStack stack, LivingEntity entity) { + return false; + } +} diff --git a/src/main/java/com/tiedup/remake/v2/bondage/component/LockableComponent.java b/src/main/java/com/tiedup/remake/v2/bondage/component/LockableComponent.java new file mode 100644 index 0000000..3a57c71 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/bondage/component/LockableComponent.java @@ -0,0 +1,44 @@ +package com.tiedup.remake.v2.bondage.component; + +import com.google.gson.JsonObject; + +/** + * Component: lockable behavior for data-driven items. + * + *

Stores the per-item lock resistance parsed from JSON. The lock check + * itself is NOT done here because {@code AbstractV2BondageItem.canUnequip()} + * already delegates to {@code ILockable.isLocked()} -- duplicating it in + * {@code blocksUnequip()} would be redundant.

+ * + *

Consumers retrieve the per-item lock resistance via + * {@link com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem#getItemLockResistance(net.minecraft.world.item.ItemStack)}.

+ * + * JSON config: + * {@code "lockable": {}} or {@code "lockable": {"lock_resistance": 300}} + */ +public class LockableComponent implements IItemComponent { + + private final int lockResistance; + + private LockableComponent(int lockResistance) { + this.lockResistance = lockResistance; + } + + public static IItemComponent fromJson(JsonObject config) { + int resistance = 250; // default + if (config != null && config.has("lock_resistance")) { + resistance = config.get("lock_resistance").getAsInt(); + } + resistance = Math.max(0, resistance); + return new LockableComponent(resistance); + } + + /** + * Get the per-item lock resistance value parsed from JSON. + * + * @return lock resistance (always >= 0 after RISK-003 clamping) + */ + public int getLockResistance() { + return lockResistance; + } +} diff --git a/src/main/java/com/tiedup/remake/v2/bondage/component/ResistanceComponent.java b/src/main/java/com/tiedup/remake/v2/bondage/component/ResistanceComponent.java new file mode 100644 index 0000000..d59f838 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/bondage/component/ResistanceComponent.java @@ -0,0 +1,33 @@ +package com.tiedup.remake.v2.bondage.component; + +import com.google.gson.JsonObject; + +/** + * Component: struggle resistance for data-driven items. + * + * JSON config: {@code "resistance": {"base": 150}} + */ +public class ResistanceComponent implements IItemComponent { + + private final int baseResistance; + + private ResistanceComponent(int baseResistance) { + this.baseResistance = baseResistance; + } + + public static IItemComponent fromJson(JsonObject config) { + int base = 100; + if (config != null && config.has("base")) { + base = config.get("base").getAsInt(); + } + base = Math.max(0, base); + return new ResistanceComponent(base); + } + + /** + * Get the base resistance for this item. + */ + public int getBaseResistance() { + return baseResistance; + } +} diff --git a/src/main/java/com/tiedup/remake/v2/bondage/component/ShockComponent.java b/src/main/java/com/tiedup/remake/v2/bondage/component/ShockComponent.java new file mode 100644 index 0000000..1c6d867 --- /dev/null +++ b/src/main/java/com/tiedup/remake/v2/bondage/component/ShockComponent.java @@ -0,0 +1,52 @@ +package com.tiedup.remake.v2.bondage.component; + +import com.google.gson.JsonObject; + +/** + * Component: shock collar behavior for data-driven items. + * + * JSON config: + * {@code "shock": {"damage": 2.0, "auto_interval": 0}} + * auto_interval: ticks between auto-shocks (0 = no auto-shock, manual only) + */ +public class ShockComponent implements IItemComponent { + + private final float damage; + private final int autoInterval; // 0 = manual only + + private ShockComponent(float damage, int autoInterval) { + this.damage = damage; + this.autoInterval = autoInterval; + } + + public static IItemComponent fromJson(JsonObject config) { + float damage = 2.0f; + int autoInterval = 0; + if (config != null) { + if (config.has("damage")) { + damage = config.get("damage").getAsFloat(); + } + if (config.has("auto_interval")) { + autoInterval = config.get("auto_interval").getAsInt(); + } + } + damage = Math.max(0.0f, damage); + autoInterval = Math.max(0, autoInterval); + return new ShockComponent(damage, autoInterval); + } + + /** Damage dealt per shock. */ + public float getDamage() { + return damage; + } + + /** Ticks between auto-shocks. 0 = manual shock only (via controller). */ + public int getAutoInterval() { + return autoInterval; + } + + /** Whether this item has auto-shock capability. */ + public boolean hasAutoShock() { + return autoInterval > 0; + } +} diff --git a/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenBondageItem.java b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenBondageItem.java index 9f215a2..56d6738 100644 --- a/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenBondageItem.java +++ b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenBondageItem.java @@ -4,6 +4,11 @@ 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.component.ComponentHolder; +import com.tiedup.remake.v2.bondage.component.ComponentType; +import com.tiedup.remake.v2.bondage.component.IItemComponent; +import com.tiedup.remake.v2.bondage.component.LockableComponent; +import com.tiedup.remake.v2.bondage.component.ResistanceComponent; import com.tiedup.remake.v2.bondage.items.AbstractV2BondageItem; import java.util.List; import java.util.Map; @@ -147,6 +152,21 @@ public class DataDrivenBondageItem extends AbstractV2BondageItem { .entrySet()) { ItemStack stack = entry.getValue(); if (stack.getItem() == this) { + // Try component first (stack-aware, fixes I-03) + ResistanceComponent comp = DataDrivenBondageItem + .getComponent( + stack, + ComponentType.RESISTANCE, + ResistanceComponent.class + ); + if (comp != null) { + maxDifficulty = Math.max( + maxDifficulty, + comp.getBaseResistance() + ); + continue; + } + // Fallback: read from definition directly DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack); if (def != null) { @@ -257,6 +277,77 @@ public class DataDrivenBondageItem extends AbstractV2BondageItem { return Component.literal(def.displayName()); } + // ===== COMPONENT LIFECYCLE DELEGATION ===== + + @Override + public void onEquipped(ItemStack stack, LivingEntity entity) { + ComponentHolder holder = DataDrivenItemRegistry.getComponents(stack); + if (holder != null) { + holder.onEquipped(stack, entity); + } + } + + @Override + public void onUnequipped(ItemStack stack, LivingEntity entity) { + ComponentHolder holder = DataDrivenItemRegistry.getComponents(stack); + if (holder != null) { + holder.onUnequipped(stack, entity); + } + } + + @Override + public boolean canUnequip(ItemStack stack, LivingEntity entity) { + ComponentHolder holder = DataDrivenItemRegistry.getComponents(stack); + if (holder != null && holder.blocksUnequip(stack, entity)) { + return false; + } + return super.canUnequip(stack, entity); + } + + /** + * Get a specific component from a data-driven item stack. + * + * @param stack the ItemStack to inspect + * @param type the component type to look up + * @param clazz the expected component class + * @param the component type + * @return the component, or null if the item is not data-driven or lacks this component + */ + @Nullable + public static T getComponent( + ItemStack stack, + ComponentType type, + Class clazz + ) { + ComponentHolder holder = DataDrivenItemRegistry.getComponents(stack); + if (holder == null) return null; + return holder.get(type, clazz); + } + + /** + * Get per-item lock resistance from the LockableComponent, if present. + * + *

Returns the component's JSON-configured value if the stack has a + * LockableComponent, otherwise falls back to the global config value + * from {@link com.tiedup.remake.core.SettingsAccessor#getPadlockResistance}.

+ * + * @param stack the ItemStack to inspect + * @return lock resistance value for this specific item + */ + public static int getItemLockResistance(ItemStack stack) { + LockableComponent comp = getComponent( + stack, + ComponentType.LOCKABLE, + LockableComponent.class + ); + if (comp != null) { + return comp.getLockResistance(); + } + return com.tiedup.remake.core.SettingsAccessor.getPadlockResistance( + null + ); + } + // ===== FACTORY ===== /** diff --git a/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemDefinition.java b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemDefinition.java index 0c5f5f4..8d3e972 100644 --- a/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemDefinition.java +++ b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemDefinition.java @@ -1,6 +1,8 @@ package com.tiedup.remake.v2.bondage.datadriven; +import com.google.gson.JsonObject; import com.tiedup.remake.v2.BodyRegionV2; +import com.tiedup.remake.v2.bondage.component.ComponentType; import java.util.Map; import java.util.Set; import net.minecraft.resources.ResourceLocation; @@ -97,5 +99,19 @@ public record DataDrivenItemDefinition( * *

This field is required in the JSON definition. Never null, never empty.

*/ - Map> animationBones -) {} + Map> animationBones, + + /** Raw component configs from JSON, keyed by ComponentType. */ + Map componentConfigs +) { + + /** Compact constructor: default null componentConfigs to empty immutable map. */ + public DataDrivenItemDefinition { + if (componentConfigs == null) componentConfigs = Map.of(); + } + + /** Check whether this definition declares a given component type. */ + public boolean hasComponent(ComponentType type) { + return componentConfigs.containsKey(type); + } +} diff --git a/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParser.java b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParser.java index ff9e9fb..221b2ae 100644 --- a/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParser.java +++ b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemParser.java @@ -5,12 +5,14 @@ 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.component.ComponentType; 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.EnumMap; import java.util.EnumSet; import java.util.HashSet; import java.util.LinkedHashMap; @@ -266,6 +268,39 @@ public final class DataDrivenItemParser { return null; } + // Optional: components (per-component JSON configs) + Map componentConfigs = + new EnumMap<>(ComponentType.class); + if (root.has("components")) { + JsonObject componentsObj = root.getAsJsonObject("components"); + for (Map.Entry entry : + componentsObj.entrySet()) { + ComponentType compType = ComponentType.fromKey( + entry.getKey() + ); + if (compType != null) { + JsonObject config; + if (entry.getValue().isJsonObject()) { + config = entry.getValue().getAsJsonObject().deepCopy(); + } else { + LOGGER.warn( + "[DataDrivenItemParser] Component '{}' in item '{}' has non-object config, using defaults", + entry.getKey(), + fileId + ); + config = new JsonObject(); + } + componentConfigs.put(compType, config); + } else { + LOGGER.warn( + "[DataDrivenItemParser] Unknown component type '{}' in item '{}'", + entry.getKey(), + fileId + ); + } + } + } + // Build the item ID from the file path // fileId is like "tiedup:tiedup_items/leather_armbinder.json" // We want "tiedup:leather_armbinder" @@ -302,7 +337,8 @@ public final class DataDrivenItemParser { movementStyle, movementModifier, creator, - animationBones + animationBones, + componentConfigs ); } diff --git a/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemRegistry.java b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemRegistry.java index b721b0f..1c72af3 100644 --- a/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemRegistry.java +++ b/src/main/java/com/tiedup/remake/v2/bondage/datadriven/DataDrivenItemRegistry.java @@ -1,7 +1,12 @@ package com.tiedup.remake.v2.bondage.datadriven; +import com.google.gson.JsonObject; +import com.tiedup.remake.v2.bondage.component.ComponentHolder; +import com.tiedup.remake.v2.bondage.component.ComponentType; +import com.tiedup.remake.v2.bondage.component.IItemComponent; import java.util.Collection; import java.util.Collections; +import java.util.EnumMap; import java.util.HashMap; import java.util.Map; import net.minecraft.nbt.CompoundTag; @@ -13,9 +18,9 @@ import org.jetbrains.annotations.Nullable; * Thread-safe registry for data-driven bondage item definitions. * *

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.

+ * Uses a single volatile snapshot reference to ensure readers always see a + * consistent pair of definitions + component holders. This prevents torn reads + * where one map is updated but the other is stale.

* *

Lookup methods accept either a {@link ResourceLocation} ID directly * or an {@link ItemStack} (reads the {@code tiedup_item_id} NBT tag).

@@ -26,13 +31,25 @@ public final class DataDrivenItemRegistry { 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. + * Immutable snapshot combining definitions and their component holders. + * Swapped atomically via a single volatile write to prevent torn reads. */ - private static volatile Map< - ResourceLocation, - DataDrivenItemDefinition - > DEFINITIONS = Map.of(); + private record RegistrySnapshot( + Map definitions, + Map holders + ) { + static final RegistrySnapshot EMPTY = new RegistrySnapshot(Map.of(), Map.of()); + } + + /** + * Single volatile reference to the current registry state. + * All read methods capture this reference ONCE at the start to ensure + * consistency within a single call. + */ + private static volatile RegistrySnapshot SNAPSHOT = RegistrySnapshot.EMPTY; + + /** Guards read-then-write sequences in {@link #reload} and {@link #mergeAll}. */ + private static final Object RELOAD_LOCK = new Object(); private DataDrivenItemRegistry() {} @@ -45,7 +62,11 @@ public final class DataDrivenItemRegistry { public static void reload( Map newDefs ) { - DEFINITIONS = Collections.unmodifiableMap(new HashMap<>(newDefs)); + synchronized (RELOAD_LOCK) { + Map defs = + Collections.unmodifiableMap(new HashMap<>(newDefs)); + SNAPSHOT = new RegistrySnapshot(defs, buildComponentHolders(defs)); + } } /** @@ -54,18 +75,23 @@ public final class DataDrivenItemRegistry { *

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.

+ * from the existing snapshot + the new entries, then swaps atomically as a + * single snapshot to prevent torn reads.

* * @param newDefs the definitions to merge (will overwrite existing entries with same key) */ public static void mergeAll( Map newDefs ) { - Map merged = new HashMap<>( - DEFINITIONS - ); - merged.putAll(newDefs); - DEFINITIONS = Collections.unmodifiableMap(merged); + synchronized (RELOAD_LOCK) { + Map merged = new HashMap<>( + SNAPSHOT.definitions + ); + merged.putAll(newDefs); + Map defs = + Collections.unmodifiableMap(merged); + SNAPSHOT = new RegistrySnapshot(defs, buildComponentHolders(defs)); + } } /** @@ -76,7 +102,8 @@ public final class DataDrivenItemRegistry { */ @Nullable public static DataDrivenItemDefinition get(ResourceLocation id) { - return DEFINITIONS.get(id); + RegistrySnapshot snap = SNAPSHOT; + return snap.definitions.get(id); } /** @@ -94,7 +121,8 @@ public final class DataDrivenItemRegistry { tag.getString(NBT_ITEM_ID) ); if (id == null) return null; - return DEFINITIONS.get(id); + RegistrySnapshot snap = SNAPSHOT; + return snap.definitions.get(id); } /** @@ -103,13 +131,85 @@ public final class DataDrivenItemRegistry { * @return unmodifiable collection of all definitions */ public static Collection getAll() { - return DEFINITIONS.values(); + RegistrySnapshot snap = SNAPSHOT; + return snap.definitions.values(); } /** * Clear all definitions. Called on world unload or for testing. */ public static void clear() { - DEFINITIONS = Map.of(); + SNAPSHOT = RegistrySnapshot.EMPTY; + } + + // ===== COMPONENT HOLDERS ===== + + /** + * Get the ComponentHolder for a data-driven item stack. + * + *

Captures the snapshot reference once to ensure consistent reads + * between the definition lookup and the holder lookup.

+ * + * @param stack the ItemStack to inspect + * @return the holder, or null if the stack is not data-driven or has no definition + */ + @Nullable + public static ComponentHolder getComponents(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; + // Capture snapshot once to ensure definition and holder come from + // the same atomic snapshot, preventing torn reads. + RegistrySnapshot snap = SNAPSHOT; + DataDrivenItemDefinition def = snap.definitions.get(id); + if (def == null) return null; + return snap.holders.get(def.id()); + } + + /** + * Get the ComponentHolder for a data-driven item by its definition ID. + * + * @param id the definition ID + * @return the holder, or null if not found + */ + @Nullable + public static ComponentHolder getComponents(ResourceLocation id) { + RegistrySnapshot snap = SNAPSHOT; + return snap.holders.get(id); + } + + /** + * Build component holders from a definitions map. + * Each definition's raw componentConfigs are instantiated via + * {@link ComponentType#create(JsonObject)}. + */ + private static Map buildComponentHolders( + Map definitions + ) { + Map holders = new HashMap<>(); + for (Map.Entry entry : + definitions.entrySet()) { + DataDrivenItemDefinition def = entry.getValue(); + Map components = + new EnumMap<>(ComponentType.class); + for (Map.Entry compEntry : + def.componentConfigs().entrySet()) { + components.put( + compEntry.getKey(), + compEntry.getKey().create(compEntry.getValue()) + ); + } + holders.put( + entry.getKey(), + components.isEmpty() + ? ComponentHolder.EMPTY + : new ComponentHolder(components) + ); + } + return Collections.unmodifiableMap(holders); } } diff --git a/src/main/resources/data/tiedup/tiedup_items/test_component_gag.json b/src/main/resources/data/tiedup/tiedup_items/test_component_gag.json new file mode 100644 index 0000000..b7adc12 --- /dev/null +++ b/src/main/resources/data/tiedup/tiedup_items/test_component_gag.json @@ -0,0 +1,24 @@ +{ + "type": "tiedup:bondage_item", + "display_name": "Component Test Gag", + "model": "tiedup:models/gltf/v2/handcuffs/cuffs_prototype.glb", + "regions": ["MOUTH"], + "pose_priority": 10, + "escape_difficulty": 3, + "lockable": true, + "animation_bones": { + "idle": [] + }, + "components": { + "lockable": { + "lock_resistance": 200 + }, + "resistance": { + "base": 80 + }, + "gagging": { + "comprehension": 0.15, + "range": 8.0 + } + } +}