22 Commits

Author SHA1 Message Date
NotEvil
7bd840705a docs: add components section to ARTIST_GUIDE.md
Documents the 8 available gameplay components (lockable, resistance,
gagging, blinding, shock, gps, choking, adjustable) with config fields,
examples (GPS shock collar, adjustable blindfold), and usage tips.
2026-04-14 02:51:37 +02:00
NotEvil
90bc890b95 fix(D-01): synchronize reload paths and capture snapshot locally (RISK-001, RISK-002) 2026-04-14 02:46:09 +02:00
NotEvil
185ac63a44 feat(D-01): implement 5 remaining components (blinding, shock, gps, choking, adjustable) 2026-04-14 02:37:14 +02:00
NotEvil
dcc8493e5e fix(D-01): pre-built map for O(1) ComponentType.fromKey() lookup (RISK-005)
Replace linear values() scan with a static unmodifiable HashMap lookup.
While only 3 entries currently exist, this establishes the correct
pattern for when more component types are added.
2026-04-14 02:29:46 +02:00
NotEvil
bfcc20d242 fix(D-01): compact constructor defaults null componentConfigs to empty (RISK-004)
Add compact constructor to DataDrivenItemDefinition that defaults null
componentConfigs to Map.of(). This makes the field guaranteed non-null,
allowing removal of null checks in hasComponent() and
DataDrivenItemRegistry.buildComponentHolders().
2026-04-14 02:29:25 +02:00
NotEvil
3a81bb6e12 fix(D-01): clamp component config values to valid ranges (RISK-003)
- LockableComponent: lock_resistance clamped to >= 0
- ResistanceComponent: base resistance clamped to >= 0
- GaggingComponent: comprehension clamped to [0.0, 1.0], range to >= 0.0

Prevents nonsensical negative values from malformed JSON configs.
2026-04-14 02:28:42 +02:00
NotEvil
bb589d44f8 fix(D-01): warn on non-object component config, deep-copy configs (RISK-001, RISK-002)
- Deep-copy JsonObject configs via deepCopy() before storing in the
  definition to prevent external mutation of the parsed JSON tree
- Log a warning when a component config value is not a JsonObject,
  making misconfigured JSON easier to diagnose
2026-04-14 02:27:59 +02:00
NotEvil
456335e0dd fix(D-01): wire LockableComponent.lockResistance via getItemLockResistance() (BUG-003)
- Remove redundant blocksUnequip() from LockableComponent since
  AbstractV2BondageItem.canUnequip() already checks ILockable.isLocked()
- Add DataDrivenBondageItem.getItemLockResistance(ItemStack) that reads
  the per-item lock resistance from the LockableComponent, falling back
  to the global config value when absent
2026-04-14 02:27:37 +02:00
NotEvil
bb209bcd8e fix(D-01): remove dead onWornTick() until V2 tick mechanism exists (BUG-002)
Remove onWornTick() from IItemComponent (default method) and
ComponentHolder (aggregate method). No V2 tick caller invokes these,
so they create a broken contract. Can be re-added when a tick
mechanism is implemented.
2026-04-14 02:26:31 +02:00
NotEvil
1327e3bfc3 fix(D-01): atomic snapshot for registry to prevent torn reads (BUG-001)
Replace two separate volatile fields (DEFINITIONS, COMPONENT_HOLDERS)
with a single RegistrySnapshot record swapped atomically. This prevents
race conditions where a reader thread could see new definitions paired
with stale/empty component holders between the two volatile writes.
2026-04-14 02:25:57 +02:00
NotEvil
dbacef66d5 feat(D-01): add test_component_gag.json demonstrating component system
JSON item using all 3 implemented components: lockable (lock_resistance: 200),
resistance (base: 80), and gagging (comprehension: 0.15, range: 8.0).
2026-04-14 02:03:50 +02:00
NotEvil
231522c68e feat(D-01): implement GaggingComponent with comprehension and range 2026-04-14 02:01:50 +02:00
NotEvil
84f4c3a53f feat(D-01): implement ResistanceComponent, improve stack-aware resistance lookup 2026-04-14 02:01:46 +02:00
NotEvil
caeb4469b1 feat(D-01): implement LockableComponent with configurable lock resistance 2026-04-14 02:01:41 +02:00
NotEvil
3a1f401ccf feat(D-01): delegate DataDrivenBondageItem lifecycle to components
Override onEquipped(), onUnequipped(), and canUnequip() in
DataDrivenBondageItem to delegate to the item's ComponentHolder.
The canUnequip() override preserves the existing lock check from
AbstractV2BondageItem via super.canUnequip().

Add a static getComponent() helper for external code to retrieve
a typed component from any data-driven item stack.
2026-04-14 01:47:19 +02:00
NotEvil
a781dad597 feat(D-01): instantiate ComponentHolder per item definition on reload
Add a parallel COMPONENT_HOLDERS volatile cache to DataDrivenItemRegistry,
rebuilt from raw componentConfigs every time definitions are loaded via
reload() or mergeAll(). Cleared alongside DEFINITIONS in clear().

Two accessor methods allow looking up a ComponentHolder by ItemStack
(reads tiedup_item_id NBT) or by ResourceLocation directly.
2026-04-14 01:46:19 +02:00
NotEvil
750be66d80 feat(D-01): parse component configs from item JSON definitions
Add componentConfigs field (Map<ComponentType, JsonObject>) to
DataDrivenItemDefinition record. The parser now reads an optional
"components" JSON block, resolves each key via ComponentType.fromKey(),
and stores the raw JsonObject configs for later instantiation.
2026-04-14 01:44:19 +02:00
NotEvil
1b70041c36 feat(D-01): add ComponentHolder container for item components 2026-04-14 01:38:09 +02:00
NotEvil
b8a0d839f5 feat(D-01): add ComponentType enum with stub component classes 2026-04-14 01:33:37 +02:00
NotEvil
edfc3c6506 feat(D-01): add IItemComponent interface for data-driven item behaviors 2026-04-14 01:29:24 +02:00
3fe3e16e0a Merge pull request 'feature/item-tooltip-creator' (#4) from feature/item-tooltip-creator into develop
Reviewed-on: #4
2026-04-13 02:01:21 +00:00
NotEvil
e17998933c Add creator field and enriched tooltip for data-driven items
- Add optional "creator" JSON field to display author name in tooltip
- Show body regions, movement style, lock status, and escape difficulty
- Show pose priority and item ID in advanced mode (F3+H)
- Update ARTIST_GUIDE.md field reference and test JSON
2026-04-13 04:00:27 +02:00
20 changed files with 959 additions and 25 deletions

View File

@@ -105,7 +105,7 @@ Some dependencies are included as local JARs in `libs/` because they are not ava
GPL-3.0 with Commons Clause - see [LICENSE](LICENSE) for details.
**TL;DR:** Free to use, modify, and distribute. Cannot be sold or put behind a paywall.
The 3D models are the **property of their creators**; if their names are listed, please ask them for permission.
The 3D models are the **property of their creators**; if their names are listed, please ask them for permission otherwise me.
## Status

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)
@@ -762,7 +762,9 @@ The `movement_style` changes how the player physically moves — slower speed, d
| `animations` | string/object | No | `"auto"` (default) or explicit name mapping |
| `movement_style` | string | No | Movement restriction: `"waddle"`, `"shuffle"`, `"hop"`, or `"crawl"` |
| `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)
@@ -784,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,13 +4,23 @@ 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;
import java.util.Set;
import java.util.stream.Collectors;
import net.minecraft.ChatFormatting;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.TooltipFlag;
import net.minecraft.world.level.Level;
import org.jetbrains.annotations.Nullable;
/**
@@ -142,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) {
@@ -177,6 +202,69 @@ public class DataDrivenBondageItem extends AbstractV2BondageItem {
);
}
// ===== TOOLTIPS =====
@Override
public void appendHoverText(
ItemStack stack,
@Nullable Level level,
List<Component> tooltip,
TooltipFlag flag
) {
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
if (def != null) {
// Creator
if (def.creator() != null && !def.creator().isEmpty()) {
tooltip.add(
Component.translatable(
"item.tiedup.tooltip.creator",
Component.literal(def.creator())
.withStyle(ChatFormatting.WHITE)
).withStyle(ChatFormatting.GRAY)
);
}
// Regions
String regions = def.occupiedRegions().stream()
.map(r -> r.name().toLowerCase())
.collect(Collectors.joining(", "));
tooltip.add(
Component.translatable(
"item.tiedup.tooltip.regions",
regions
).withStyle(ChatFormatting.DARK_AQUA)
);
// Movement style
if (def.movementStyle() != null) {
tooltip.add(
Component.translatable(
"item.tiedup.tooltip.movement_style",
def.movementStyle().name().toLowerCase()
).withStyle(ChatFormatting.DARK_PURPLE)
);
}
}
// Lock status + escape difficulty (from AbstractV2BondageItem)
super.appendHoverText(stack, level, tooltip, flag);
if (def != null && flag.isAdvanced()) {
// Advanced info (F3+H): pose priority + item ID
tooltip.add(
Component.translatable(
"item.tiedup.tooltip.pose_priority",
def.posePriority()
).withStyle(ChatFormatting.DARK_GRAY)
);
tooltip.add(
Component.literal(
def.id().toString()
).withStyle(ChatFormatting.DARK_GRAY)
);
}
}
// ===== DISPLAY NAME =====
@Override
@@ -189,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;
@@ -81,6 +83,9 @@ public record DataDrivenItemDefinition(
@Nullable
com.tiedup.remake.v2.bondage.movement.MovementModifier movementModifier,
/** Optional creator/author name displayed in the item tooltip. */
@Nullable String creator,
/**
* Per-animation bone whitelist. Maps animation name (e.g. "idle", "struggle")
* to the set of PlayerAnimator bone names this item is allowed to animate.
@@ -94,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;
@@ -250,6 +252,9 @@ public final class DataDrivenItemParser {
);
}
// Optional: creator (author name for tooltip)
String creator = getStringOrNull(root, "creator");
// Required: animation_bones (per-animation bone whitelist)
Map<String, Set<String>> animationBones = parseAnimationBones(
root,
@@ -263,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"
@@ -298,7 +336,9 @@ public final class DataDrivenItemParser {
icon,
movementStyle,
movementModifier,
animationBones
creator,
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
) {
Map<ResourceLocation, DataDrivenItemDefinition> merged = new HashMap<>(
DEFINITIONS
);
merged.putAll(newDefs);
DEFINITIONS = Collections.unmodifiableMap(merged);
synchronized (RELOAD_LOCK) {
Map<ResourceLocation, DataDrivenItemDefinition> merged = new HashMap<>(
SNAPSHOT.definitions
);
merged.putAll(newDefs);
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

@@ -229,6 +229,10 @@
"item.tiedup.tooltip.lockable": "Lockable (has padlock)",
"item.tiedup.tooltip.jammed": "Jammed (lockpick blocked)",
"item.tiedup.tooltip.escape_difficulty": "Escape Difficulty: %s",
"item.tiedup.tooltip.creator": "By %s",
"item.tiedup.tooltip.regions": "Regions: %s",
"item.tiedup.tooltip.movement_style": "Movement: %s",
"item.tiedup.tooltip.pose_priority": "Pose Priority: %s",
"item.tiedup.v2_handcuffs": "Handcuffs",

View File

@@ -1,6 +1,7 @@
{
"type": "tiedup:bondage_item",
"display_name": "Data-Driven Handcuffs",
"creator": "TiedUp! Team",
"model": "tiedup:models/gltf/v2/handcuffs/cuffs_prototype.glb",
"regions": ["ARMS"],
"pose_priority": 30,

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