Merge pull request 'feature/d01-component-system' (#5) from feature/d01-component-system into develop

Reviewed-on: #5
This commit was merged in pull request #5.
This commit is contained in:
2026-04-14 00:54:16 +00:00
17 changed files with 877 additions and 24 deletions

View File

@@ -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.01.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.

View File

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

View File

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

View File

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

View File

@@ -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<ComponentType, IItemComponent> components;
public ComponentHolder(Map<ComponentType, IItemComponent> 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 extends IItemComponent> T get(
ComponentType type,
Class<T> 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();
}
}

View File

@@ -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<JsonObject, IItemComponent> factory;
/** Pre-built lookup map for O(1) fromKey() instead of linear scan. */
private static final Map<String, ComponentType> BY_KEY;
static {
Map<String, ComponentType> map = new HashMap<>();
for (ComponentType type : values()) {
map.put(type.jsonKey, type);
}
BY_KEY = Collections.unmodifiableMap(map);
}
ComponentType(
String jsonKey,
Function<JsonObject, IItemComponent> 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);
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,44 @@
package com.tiedup.remake.v2.bondage.component;
import com.google.gson.JsonObject;
/**
* Component: lockable behavior for data-driven items.
*
* <p>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.</p>
*
* <p>Consumers retrieve the per-item lock resistance via
* {@link com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem#getItemLockResistance(net.minecraft.world.item.ItemStack)}.</p>
*
* 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;
}
}

View File

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

View File

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

View File

@@ -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 <T> the component type
* @return the component, or null if the item is not data-driven or lacks this component
*/
@Nullable
public static <T extends IItemComponent> T getComponent(
ItemStack stack,
ComponentType type,
Class<T> 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.
*
* <p>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}.</p>
*
* @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 =====
/**

View File

@@ -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(
*
* <p>This field is required in the JSON definition. Never null, never empty.</p>
*/
Map<String, Set<String>> animationBones
) {}
Map<String, Set<String>> animationBones,
/** Raw component configs from JSON, keyed by ComponentType. */
Map<ComponentType, JsonObject> 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);
}
}

View File

@@ -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<ComponentType, JsonObject> componentConfigs =
new EnumMap<>(ComponentType.class);
if (root.has("components")) {
JsonObject componentsObj = root.getAsJsonObject("components");
for (Map.Entry<String, JsonElement> 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
);
}

View File

@@ -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.
*
* <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>
* 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.</p>
*
* <p>Lookup methods accept either a {@link ResourceLocation} ID directly
* or an {@link ItemStack} (reads the {@code tiedup_item_id} NBT tag).</p>
@@ -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<ResourceLocation, DataDrivenItemDefinition> definitions,
Map<ResourceLocation, ComponentHolder> 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<ResourceLocation, DataDrivenItemDefinition> newDefs
) {
DEFINITIONS = Collections.unmodifiableMap(new HashMap<>(newDefs));
synchronized (RELOAD_LOCK) {
Map<ResourceLocation, DataDrivenItemDefinition> defs =
Collections.unmodifiableMap(new HashMap<>(newDefs));
SNAPSHOT = new RegistrySnapshot(defs, buildComponentHolders(defs));
}
}
/**
@@ -54,18 +75,23 @@ public final class DataDrivenItemRegistry {
* <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>
* from the existing snapshot + the new entries, then swaps atomically as a
* single snapshot to prevent torn reads.</p>
*
* @param newDefs the definitions to merge (will overwrite existing entries with same key)
*/
public static void mergeAll(
Map<ResourceLocation, DataDrivenItemDefinition> newDefs
) {
synchronized (RELOAD_LOCK) {
Map<ResourceLocation, DataDrivenItemDefinition> merged = new HashMap<>(
DEFINITIONS
SNAPSHOT.definitions
);
merged.putAll(newDefs);
DEFINITIONS = Collections.unmodifiableMap(merged);
Map<ResourceLocation, DataDrivenItemDefinition> 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<DataDrivenItemDefinition> 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.
*
* <p>Captures the snapshot reference once to ensure consistent reads
* between the definition lookup and the holder lookup.</p>
*
* @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<ResourceLocation, ComponentHolder> buildComponentHolders(
Map<ResourceLocation, DataDrivenItemDefinition> definitions
) {
Map<ResourceLocation, ComponentHolder> holders = new HashMap<>();
for (Map.Entry<ResourceLocation, DataDrivenItemDefinition> entry :
definitions.entrySet()) {
DataDrivenItemDefinition def = entry.getValue();
Map<ComponentType, IItemComponent> components =
new EnumMap<>(ComponentType.class);
for (Map.Entry<ComponentType, JsonObject> 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);
}
}

View File

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