Clean repo for open source release
Remove build artifacts, dev tool configs, unused dependencies, and third-party source dumps. Add proper README, update .gitignore, clean up Makefile.
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
package com.tiedup.remake.v2.bondage;
|
||||
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraftforge.common.capabilities.AutoRegisterCapability;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
/**
|
||||
* Capability interface for V2 bondage equipment storage.
|
||||
*
|
||||
* Stores ItemStacks keyed by {@link BodyRegionV2}. Multi-region items
|
||||
* share the same ItemStack reference across all occupied regions.
|
||||
*
|
||||
* CRITICAL: The {@link AutoRegisterCapability} annotation is required
|
||||
* for Forge to register this capability automatically.
|
||||
*/
|
||||
@AutoRegisterCapability
|
||||
public interface IV2BondageEquipment {
|
||||
|
||||
/**
|
||||
* Get the item equipped in the given region.
|
||||
* @return The ItemStack in that region, or {@link ItemStack#EMPTY} — never null.
|
||||
*/
|
||||
ItemStack getInRegion(BodyRegionV2 region);
|
||||
|
||||
/**
|
||||
* Set the item in a specific region. Pass {@link ItemStack#EMPTY} to clear.
|
||||
*/
|
||||
void setInRegion(BodyRegionV2 region, ItemStack stack);
|
||||
|
||||
/**
|
||||
* Get all equipped items, de-duplicated. Multi-region items appear once.
|
||||
* @return Unmodifiable map from one representative region to the ItemStack.
|
||||
*/
|
||||
Map<BodyRegionV2, ItemStack> getAllEquipped();
|
||||
|
||||
/**
|
||||
* Check if a region has an item directly equipped in it.
|
||||
*/
|
||||
boolean isRegionOccupied(BodyRegionV2 region);
|
||||
|
||||
/**
|
||||
* Check if a region is blocked by any equipped item's {@link IV2BondageItem#getBlockedRegions()}.
|
||||
* For example, if a Hood (blocks EYES/EARS/MOUTH) is equipped, EYES is blocked.
|
||||
*/
|
||||
boolean isRegionBlocked(BodyRegionV2 region);
|
||||
|
||||
/**
|
||||
* Count distinct equipped items (de-duplicated for multi-region items).
|
||||
*/
|
||||
int getEquippedCount();
|
||||
|
||||
/**
|
||||
* Clear all regions, removing all equipped items.
|
||||
*/
|
||||
void clearAll();
|
||||
|
||||
/**
|
||||
* Serialize all equipped items to NBT.
|
||||
*/
|
||||
CompoundTag serializeNBT();
|
||||
|
||||
/**
|
||||
* Deserialize equipped items from NBT, replacing current state.
|
||||
*/
|
||||
void deserializeNBT(CompoundTag tag);
|
||||
|
||||
// ========================================
|
||||
// Pole leash persistence
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Whether the player was leashed to a pole when they disconnected.
|
||||
*/
|
||||
boolean wasLeashedToPole();
|
||||
|
||||
/**
|
||||
* Get the saved pole position, or null if none.
|
||||
*/
|
||||
@Nullable BlockPos getSavedPolePosition();
|
||||
|
||||
/**
|
||||
* Get the saved pole dimension, or null if none.
|
||||
*/
|
||||
@Nullable ResourceKey<Level> getSavedPoleDimension();
|
||||
|
||||
/**
|
||||
* Save the pole leash state for restoration on reconnect.
|
||||
*/
|
||||
void savePoleLeash(BlockPos pos, ResourceKey<Level> dimension);
|
||||
|
||||
/**
|
||||
* Clear saved pole leash state.
|
||||
*/
|
||||
void clearSavedPoleLeash();
|
||||
|
||||
// ========================================
|
||||
// Captor persistence
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Whether the player had a saved captor when they disconnected.
|
||||
*/
|
||||
boolean hasSavedCaptor();
|
||||
|
||||
/**
|
||||
* Get the saved captor UUID, or null if none.
|
||||
*/
|
||||
@Nullable UUID getSavedCaptorUUID();
|
||||
|
||||
/**
|
||||
* Save the captor UUID for restoration on reconnect.
|
||||
*/
|
||||
void saveCaptorUUID(UUID uuid);
|
||||
|
||||
/**
|
||||
* Clear saved captor state.
|
||||
*/
|
||||
void clearSavedCaptor();
|
||||
}
|
||||
167
src/main/java/com/tiedup/remake/v2/bondage/IV2BondageItem.java
Normal file
167
src/main/java/com/tiedup/remake/v2/bondage/IV2BondageItem.java
Normal file
@@ -0,0 +1,167 @@
|
||||
package com.tiedup.remake.v2.bondage;
|
||||
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
|
||||
import java.util.Set;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
/**
|
||||
* Core interface for V2 bondage items.
|
||||
*
|
||||
* Implemented by Item classes that can be equipped into V2 body regions.
|
||||
* Provides region occupation, 3D model info, pose/animation data,
|
||||
* and lifecycle hooks.
|
||||
*/
|
||||
public interface IV2BondageItem {
|
||||
|
||||
// ===== REGIONS =====
|
||||
|
||||
/**
|
||||
* Which body regions this item occupies.
|
||||
* Example: Armbinder returns {ARMS, HANDS, TORSO}.
|
||||
*/
|
||||
Set<BodyRegionV2> getOccupiedRegions();
|
||||
|
||||
/**
|
||||
* Stack-aware variant. Data-driven items override this to read regions from NBT/registry.
|
||||
* Default delegates to the no-arg version (backward compatible for hardcoded items).
|
||||
*/
|
||||
default Set<BodyRegionV2> getOccupiedRegions(ItemStack stack) {
|
||||
return getOccupiedRegions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Which regions this item blocks from having other items.
|
||||
* Usually same as occupied, but could differ.
|
||||
* Example: Hood occupies HEAD but blocks {HEAD, EYES, EARS, MOUTH}.
|
||||
* Defaults to same as {@link #getOccupiedRegions()}.
|
||||
*/
|
||||
default Set<BodyRegionV2> getBlockedRegions() {
|
||||
return getOccupiedRegions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stack-aware variant. Data-driven items override this to read blocked regions from NBT/registry.
|
||||
*/
|
||||
default Set<BodyRegionV2> getBlockedRegions(ItemStack stack) {
|
||||
return getBlockedRegions();
|
||||
}
|
||||
|
||||
// ===== 3D MODELS =====
|
||||
|
||||
/**
|
||||
* Get the glTF model location (.glb file).
|
||||
* Returns null for items without a 3D model (e.g., clothes-only items).
|
||||
*/
|
||||
@Nullable
|
||||
ResourceLocation getModelLocation();
|
||||
|
||||
/**
|
||||
* Stack-aware variant. Data-driven items override this to read model from NBT/registry.
|
||||
*/
|
||||
@Nullable
|
||||
default ResourceLocation getModelLocation(ItemStack stack) {
|
||||
return getModelLocation();
|
||||
}
|
||||
|
||||
// ===== POSES & ANIMATIONS =====
|
||||
|
||||
/**
|
||||
* Priority for pose conflicts. Higher value wins.
|
||||
* Example: Fullbind (100) > Armbinder (50) > Handcuffs (30) > Collar (10) > None (0).
|
||||
*/
|
||||
int getPosePriority();
|
||||
|
||||
/**
|
||||
* Stack-aware variant for pose priority.
|
||||
*/
|
||||
default int getPosePriority(ItemStack stack) {
|
||||
return getPosePriority();
|
||||
}
|
||||
|
||||
// ===== ITEM STATE =====
|
||||
|
||||
/**
|
||||
* Difficulty to escape (for struggle minigame). Higher = harder.
|
||||
*/
|
||||
int getEscapeDifficulty();
|
||||
|
||||
/**
|
||||
* Stack-aware variant for escape difficulty.
|
||||
*/
|
||||
default int getEscapeDifficulty(ItemStack stack) {
|
||||
return getEscapeDifficulty();
|
||||
}
|
||||
|
||||
// ===== RENDERING =====
|
||||
|
||||
/**
|
||||
* Whether this item supports color variants (texture_red.png, etc.).
|
||||
*/
|
||||
boolean supportsColor();
|
||||
|
||||
/**
|
||||
* Stack-aware variant for color support.
|
||||
*/
|
||||
default boolean supportsColor(ItemStack stack) {
|
||||
return supportsColor();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this item supports a slim model variant (Alex-style 3px arms).
|
||||
*/
|
||||
boolean supportsSlimModel();
|
||||
|
||||
/**
|
||||
* Stack-aware variant for slim model support.
|
||||
*/
|
||||
default boolean supportsSlimModel(ItemStack stack) {
|
||||
return supportsSlimModel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the slim model location (.glb) for Alex-style players.
|
||||
* Only meaningful if {@link #supportsSlimModel()} returns true.
|
||||
*/
|
||||
@Nullable
|
||||
default ResourceLocation getSlimModelLocation() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stack-aware variant for slim model location.
|
||||
*/
|
||||
@Nullable
|
||||
default ResourceLocation getSlimModelLocation(ItemStack stack) {
|
||||
return getSlimModelLocation();
|
||||
}
|
||||
|
||||
// ===== LIFECYCLE HOOKS =====
|
||||
|
||||
/**
|
||||
* Called when this item is equipped onto an entity.
|
||||
*/
|
||||
default void onEquipped(ItemStack stack, LivingEntity entity) {}
|
||||
|
||||
/**
|
||||
* Called when this item is unequipped from an entity.
|
||||
*/
|
||||
default void onUnequipped(ItemStack stack, LivingEntity entity) {}
|
||||
|
||||
/**
|
||||
* Whether this item can be equipped on the given entity right now.
|
||||
*/
|
||||
default boolean canEquip(ItemStack stack, LivingEntity entity) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this item can be unequipped from the given entity right now.
|
||||
*/
|
||||
default boolean canUnequip(ItemStack stack, LivingEntity entity) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.tiedup.remake.v2.bondage;
|
||||
|
||||
/**
|
||||
* Interface for entities that hold V2 bondage equipment internally
|
||||
* (not via Forge capabilities).
|
||||
*
|
||||
* Implemented by EntityDamsel (Epic 4B) to allow V2EquipmentHelper
|
||||
* to dispatch equipment operations without a circular dependency
|
||||
* between v2.bondage and entities packages.
|
||||
*
|
||||
* Players use Forge capabilities instead — they do NOT implement this.
|
||||
*/
|
||||
public interface IV2EquipmentHolder {
|
||||
|
||||
/**
|
||||
* Get the V2 equipment storage for this entity.
|
||||
* @return The equipment instance, never null.
|
||||
*/
|
||||
IV2BondageEquipment getV2Equipment();
|
||||
|
||||
/**
|
||||
* Sync the internal V2 equipment state to the entity's SynchedEntityData.
|
||||
* Called by V2EquipmentHelper after write operations (equip/unequip/clear).
|
||||
*/
|
||||
void syncEquipmentToData();
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.tiedup.remake.v2.bondage;
|
||||
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem;
|
||||
import com.tiedup.remake.v2.bondage.items.V2Handcuffs;
|
||||
import com.tiedup.remake.v2.furniture.FurniturePlacerItem;
|
||||
import net.minecraft.world.item.Item;
|
||||
import net.minecraftforge.registries.DeferredRegister;
|
||||
import net.minecraftforge.registries.ForgeRegistries;
|
||||
import net.minecraftforge.registries.RegistryObject;
|
||||
|
||||
/**
|
||||
* DeferredRegister for V2 bondage items.
|
||||
* Separate from V2Items (which registers block items).
|
||||
*/
|
||||
public class V2BondageItems {
|
||||
|
||||
public static final DeferredRegister<Item> ITEMS = DeferredRegister.create(
|
||||
ForgeRegistries.ITEMS, TiedUpMod.MOD_ID
|
||||
);
|
||||
|
||||
public static final RegistryObject<Item> V2_HANDCUFFS = ITEMS.register(
|
||||
"v2_handcuffs", V2Handcuffs::new
|
||||
);
|
||||
|
||||
/**
|
||||
* Generic data-driven bondage item. A single registered Item whose
|
||||
* behavior varies per-stack via the {@code tiedup_item_id} NBT tag.
|
||||
*/
|
||||
public static final RegistryObject<Item> DATA_DRIVEN_ITEM = ITEMS.register(
|
||||
"data_driven_item", DataDrivenBondageItem::new
|
||||
);
|
||||
|
||||
/**
|
||||
* Furniture placer item. A single registered Item that spawns
|
||||
* {@link com.tiedup.remake.v2.furniture.EntityFurniture} on right-click.
|
||||
* The specific furniture type is determined by the {@code tiedup_furniture_id}
|
||||
* NBT tag on each stack.
|
||||
*/
|
||||
public static final RegistryObject<Item> FURNITURE_PLACER = ITEMS.register(
|
||||
"furniture_placer", FurniturePlacerItem::new
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.tiedup.remake.v2.bondage;
|
||||
|
||||
import java.util.List;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
|
||||
/**
|
||||
* Result of attempting to equip a V2 bondage item.
|
||||
* Carries displaced stacks for swap/supersede cases.
|
||||
*/
|
||||
public record V2EquipResult(Type type, List<ItemStack> displaced) {
|
||||
|
||||
public enum Type {
|
||||
/** Item equipped successfully into empty regions. */
|
||||
SUCCESS,
|
||||
/** Single conflicting item was swapped out. */
|
||||
SWAPPED,
|
||||
/** Global item superseded multiple sub-region items. */
|
||||
SUPERSEDED,
|
||||
/** Item could not be equipped due to unresolvable conflicts. */
|
||||
BLOCKED
|
||||
}
|
||||
|
||||
/** Convenience: check if equip was blocked. */
|
||||
public boolean isBlocked() { return type == Type.BLOCKED; }
|
||||
|
||||
/** Convenience: check if equip succeeded (any non-blocked result). */
|
||||
public boolean isSuccess() { return type != Type.BLOCKED; }
|
||||
|
||||
// ===== Factory methods =====
|
||||
|
||||
public static final V2EquipResult SUCCESS = new V2EquipResult(Type.SUCCESS, List.of());
|
||||
public static final V2EquipResult BLOCKED = new V2EquipResult(Type.BLOCKED, List.of());
|
||||
|
||||
public static V2EquipResult swapped(ItemStack displaced) {
|
||||
return new V2EquipResult(Type.SWAPPED, List.of(displaced));
|
||||
}
|
||||
|
||||
public static V2EquipResult superseded(List<ItemStack> displaced) {
|
||||
return new V2EquipResult(Type.SUPERSEDED, List.copyOf(displaced));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package com.tiedup.remake.v2.bondage;
|
||||
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
import java.util.ArrayList;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import org.jetbrains.annotations.ApiStatus;
|
||||
|
||||
/**
|
||||
* Static utility for V2 equipment conflict resolution and pose management.
|
||||
*
|
||||
* Conflict rules:
|
||||
* 1. One item per region — cannot equip two items on the same region
|
||||
* 2. Items declare which regions they block via getBlockedRegions()
|
||||
* 3. Swap: single conflict -> auto-swap if canUnequip
|
||||
* 4. Supersede: new item with a global region replaces all conflicting items
|
||||
*
|
||||
* NOTE: Global regions do NOT automatically block sub-regions.
|
||||
* Blocking is always explicit via getBlockedRegions().
|
||||
* Example: Hood blocks {HEAD, EYES, EARS, MOUTH}. Handcuffs block {ARMS} only.
|
||||
*/
|
||||
public final class V2EquipmentManager {
|
||||
|
||||
private V2EquipmentManager() {}
|
||||
|
||||
/**
|
||||
* A conflict between a new item and an existing equipped item.
|
||||
*/
|
||||
public record ConflictEntry(BodyRegionV2 region, ItemStack stack) {}
|
||||
|
||||
/**
|
||||
* Check if an item can be equipped without any conflicts (stack-aware).
|
||||
*/
|
||||
public static boolean canEquip(IV2BondageEquipment equip, IV2BondageItem item, ItemStack newStack) {
|
||||
return findAllConflicts(equip, item, newStack).isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all conflicts that would occur if the given item were equipped.
|
||||
* Checks direct occupation, existing items' blocked regions, and new item's blocked regions.
|
||||
*
|
||||
* @param equip The equipment capability
|
||||
* @param item The V2 bondage item interface
|
||||
* @param newStack The ItemStack being equipped (used for stack-aware property lookups)
|
||||
*/
|
||||
public static List<ConflictEntry> findAllConflicts(
|
||||
IV2BondageEquipment equip,
|
||||
IV2BondageItem item,
|
||||
ItemStack newStack
|
||||
) {
|
||||
List<ConflictEntry> conflicts = new ArrayList<>();
|
||||
IdentityHashMap<ItemStack, Boolean> seen = new IdentityHashMap<>();
|
||||
|
||||
// 1. Direct occupation conflict: new item's regions vs existing items
|
||||
for (BodyRegionV2 region : item.getOccupiedRegions(newStack)) {
|
||||
ItemStack existing = equip.getInRegion(region);
|
||||
if (!existing.isEmpty() && !seen.containsKey(existing)) {
|
||||
seen.put(existing, Boolean.TRUE);
|
||||
conflicts.add(new ConflictEntry(region, existing));
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Existing items' getBlockedRegions() block new item's regions
|
||||
for (Map.Entry<BodyRegionV2, ItemStack> entry : equip.getAllEquipped().entrySet()) {
|
||||
ItemStack equipped = entry.getValue();
|
||||
if (seen.containsKey(equipped)) continue;
|
||||
if (equipped.getItem() instanceof IV2BondageItem equippedItem) {
|
||||
for (BodyRegionV2 newRegion : item.getOccupiedRegions(newStack)) {
|
||||
if (equippedItem.getBlockedRegions(equipped).contains(newRegion)) {
|
||||
seen.put(equipped, Boolean.TRUE);
|
||||
conflicts.add(new ConflictEntry(entry.getKey(), equipped));
|
||||
break; // One conflict per item is enough
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. New item's getBlockedRegions() conflict with existing items
|
||||
for (BodyRegionV2 blocked : item.getBlockedRegions(newStack)) {
|
||||
ItemStack existing = equip.getInRegion(blocked);
|
||||
if (!existing.isEmpty() && !seen.containsKey(existing)) {
|
||||
seen.put(existing, Boolean.TRUE);
|
||||
conflicts.add(new ConflictEntry(blocked, existing));
|
||||
}
|
||||
}
|
||||
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to equip an item, handling conflicts via swap or supersede.
|
||||
*
|
||||
* @param equip The equipment capability
|
||||
* @param item The V2 bondage item interface
|
||||
* @param stack The ItemStack being equipped
|
||||
* @param entity The entity being equipped (never null)
|
||||
* @return The result of the equip attempt
|
||||
*/
|
||||
@ApiStatus.Internal
|
||||
public static V2EquipResult tryEquip(
|
||||
IV2BondageEquipment equip,
|
||||
IV2BondageItem item,
|
||||
ItemStack stack,
|
||||
LivingEntity entity
|
||||
) {
|
||||
// Fast path: no conflicts
|
||||
List<ConflictEntry> conflicts = findAllConflicts(equip, item, stack);
|
||||
if (conflicts.isEmpty()) {
|
||||
doEquip(equip, item, stack);
|
||||
return V2EquipResult.SUCCESS;
|
||||
}
|
||||
|
||||
// De-duplicate conflicts by stack identity
|
||||
IdentityHashMap<ItemStack, ConflictEntry> uniqueConflicts = new IdentityHashMap<>();
|
||||
for (ConflictEntry c : conflicts) {
|
||||
uniqueConflicts.putIfAbsent(c.stack(), c);
|
||||
}
|
||||
|
||||
// Single conflict -> attempt swap
|
||||
if (uniqueConflicts.size() == 1) {
|
||||
ConflictEntry conflict = uniqueConflicts.values().iterator().next();
|
||||
ItemStack conflictStack = conflict.stack();
|
||||
if (conflictStack.getItem() instanceof IV2BondageItem conflictItem) {
|
||||
if (conflictItem.canUnequip(conflictStack, entity)) {
|
||||
removeAllRegionsOf(equip, conflictStack);
|
||||
conflictItem.onUnequipped(conflictStack, entity);
|
||||
doEquip(equip, item, stack);
|
||||
return V2EquipResult.swapped(conflictStack);
|
||||
}
|
||||
} else {
|
||||
// Non-V2 item in region — log warning and remove
|
||||
TiedUpMod.LOGGER.warn(
|
||||
"V2EquipmentManager: swapping out non-V2 item {} from equipment",
|
||||
conflictStack
|
||||
);
|
||||
removeAllRegionsOf(equip, conflictStack);
|
||||
doEquip(equip, item, stack);
|
||||
return V2EquipResult.swapped(conflictStack);
|
||||
}
|
||||
return V2EquipResult.BLOCKED;
|
||||
}
|
||||
|
||||
// Multiple conflicts + new item occupies a global region -> supersede
|
||||
boolean newItemHasGlobal = false;
|
||||
for (BodyRegionV2 region : item.getOccupiedRegions(stack)) {
|
||||
if (region.isGlobal()) {
|
||||
newItemHasGlobal = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (newItemHasGlobal) {
|
||||
// Check all conflicting items can be unequipped
|
||||
for (ConflictEntry c : uniqueConflicts.values()) {
|
||||
if (c.stack().getItem() instanceof IV2BondageItem ci) {
|
||||
if (!ci.canUnequip(c.stack(), entity)) {
|
||||
return V2EquipResult.BLOCKED;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Collect displaced stacks, then unequip all conflicts
|
||||
List<ItemStack> displaced = new ArrayList<>();
|
||||
for (ConflictEntry c : uniqueConflicts.values()) {
|
||||
ItemStack cs = c.stack();
|
||||
removeAllRegionsOf(equip, cs);
|
||||
if (cs.getItem() instanceof IV2BondageItem ci) {
|
||||
ci.onUnequipped(cs, entity);
|
||||
} else {
|
||||
TiedUpMod.LOGGER.warn("[V2] Supersede removed non-V2 item {} from equipment", cs);
|
||||
}
|
||||
displaced.add(cs);
|
||||
}
|
||||
doEquip(equip, item, stack);
|
||||
return V2EquipResult.superseded(displaced);
|
||||
}
|
||||
|
||||
return V2EquipResult.BLOCKED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Place an item into all its occupied regions.
|
||||
*/
|
||||
private static void doEquip(
|
||||
IV2BondageEquipment equip,
|
||||
IV2BondageItem item,
|
||||
ItemStack stack
|
||||
) {
|
||||
for (BodyRegionV2 region : item.getOccupiedRegions(stack)) {
|
||||
equip.setInRegion(region, stack);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an item from all regions by identity scan.
|
||||
* Uses full BodyRegionV2.values() scan to prevent orphan stacks.
|
||||
*/
|
||||
public static void removeAllRegionsOf(IV2BondageEquipment equip, ItemStack stack) {
|
||||
for (BodyRegionV2 region : BodyRegionV2.values()) {
|
||||
//noinspection ObjectEquality — intentional identity comparison
|
||||
if (equip.getInRegion(region) == stack) {
|
||||
equip.setInRegion(region, ItemStack.EMPTY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
package com.tiedup.remake.v2.bondage.capability;
|
||||
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import com.tiedup.remake.v2.bondage.IV2BondageEquipment;
|
||||
import com.tiedup.remake.v2.bondage.IV2BondageItem;
|
||||
import java.util.Collections;
|
||||
import java.util.EnumMap;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.nbt.ListTag;
|
||||
import net.minecraft.nbt.StringTag;
|
||||
import net.minecraft.nbt.Tag;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.level.Level;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
/**
|
||||
* Concrete implementation of {@link IV2BondageEquipment}.
|
||||
*
|
||||
* Storage: EnumMap with 14 regions, each initialized to {@link ItemStack#EMPTY}.
|
||||
* Multi-region items share the same ItemStack reference across all occupied regions.
|
||||
*
|
||||
* NBT format:
|
||||
* - Root key: "V2BondageRegions" CompoundTag
|
||||
* - Per item: "REGION_NAME" -> ItemStack NBT
|
||||
* - Multi-region secondary slots: "REGION_NAME_also" -> ListTag of StringTag (other region names)
|
||||
* - Only non-empty regions are persisted
|
||||
*/
|
||||
public class V2BondageEquipment implements IV2BondageEquipment {
|
||||
|
||||
private static final String NBT_ROOT_KEY = "V2BondageRegions";
|
||||
private static final String NBT_ALSO_SUFFIX = "_also";
|
||||
|
||||
private final EnumMap<BodyRegionV2, ItemStack> regions;
|
||||
|
||||
// Pole leash persistence
|
||||
@Nullable private BlockPos savedPolePosition;
|
||||
@Nullable private ResourceKey<Level> savedPoleDimension;
|
||||
|
||||
// Captor persistence
|
||||
@Nullable private UUID savedCaptorUUID;
|
||||
|
||||
public V2BondageEquipment() {
|
||||
this.regions = new EnumMap<>(BodyRegionV2.class);
|
||||
for (BodyRegionV2 region : BodyRegionV2.values()) {
|
||||
regions.put(region, ItemStack.EMPTY);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ItemStack getInRegion(BodyRegionV2 region) {
|
||||
if (region == null) return ItemStack.EMPTY;
|
||||
ItemStack stack = regions.get(region);
|
||||
return stack != null ? stack : ItemStack.EMPTY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setInRegion(BodyRegionV2 region, ItemStack stack) {
|
||||
if (region == null) return;
|
||||
regions.put(region, stack == null ? ItemStack.EMPTY : stack);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<BodyRegionV2, ItemStack> getAllEquipped() {
|
||||
// De-duplicate: multi-region items should appear only once.
|
||||
// Uses IdentityHashMap to track already-seen stack references.
|
||||
IdentityHashMap<ItemStack, BodyRegionV2> seen = new IdentityHashMap<>();
|
||||
Map<BodyRegionV2, ItemStack> result = new LinkedHashMap<>();
|
||||
|
||||
for (BodyRegionV2 region : BodyRegionV2.values()) {
|
||||
ItemStack stack = regions.get(region);
|
||||
if (stack != null && !stack.isEmpty() && !seen.containsKey(stack)) {
|
||||
seen.put(stack, region);
|
||||
result.put(region, stack);
|
||||
}
|
||||
}
|
||||
|
||||
return Collections.unmodifiableMap(result);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRegionOccupied(BodyRegionV2 region) {
|
||||
if (region == null) return false;
|
||||
ItemStack stack = regions.get(region);
|
||||
return stack != null && !stack.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a region is blocked by any equipped item's {@link IV2BondageItem#getBlockedRegions()}.
|
||||
* <p>
|
||||
* Scans all equipped items and returns true if any item blocks this region
|
||||
* (excluding self-blocking via the item's own occupied regions).
|
||||
* This replaces the old O(1) parent-global hierarchy check.
|
||||
*/
|
||||
@Override
|
||||
public boolean isRegionBlocked(BodyRegionV2 region) {
|
||||
if (region == null) return false;
|
||||
// Check if any equipped item's getBlockedRegions() includes this region
|
||||
for (Map.Entry<BodyRegionV2, ItemStack> entry : getAllEquipped().entrySet()) {
|
||||
ItemStack stack = entry.getValue();
|
||||
if (stack.getItem() instanceof IV2BondageItem item) {
|
||||
if (item.getBlockedRegions(stack).contains(region)
|
||||
&& !item.getOccupiedRegions(stack).contains(region)) {
|
||||
// Blocked by another item (not self-blocking via occupation)
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getEquippedCount() {
|
||||
// Count unique non-empty stacks directly, avoiding the 2-map allocation
|
||||
// of getAllEquipped(). Uses identity-based dedup for multi-region items.
|
||||
IdentityHashMap<ItemStack, Boolean> seen = new IdentityHashMap<>();
|
||||
for (ItemStack stack : regions.values()) {
|
||||
if (stack != null && !stack.isEmpty()) {
|
||||
seen.put(stack, Boolean.TRUE);
|
||||
}
|
||||
}
|
||||
return seen.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearAll() {
|
||||
for (BodyRegionV2 region : BodyRegionV2.values()) {
|
||||
regions.put(region, ItemStack.EMPTY);
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Pole leash persistence
|
||||
// ========================================
|
||||
|
||||
@Override
|
||||
public boolean wasLeashedToPole() {
|
||||
return savedPolePosition != null && savedPoleDimension != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Nullable
|
||||
public BlockPos getSavedPolePosition() {
|
||||
return savedPolePosition;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Nullable
|
||||
public ResourceKey<Level> getSavedPoleDimension() {
|
||||
return savedPoleDimension;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void savePoleLeash(BlockPos pos, ResourceKey<Level> dimension) {
|
||||
this.savedPolePosition = pos;
|
||||
this.savedPoleDimension = dimension;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearSavedPoleLeash() {
|
||||
this.savedPolePosition = null;
|
||||
this.savedPoleDimension = null;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Captor persistence
|
||||
// ========================================
|
||||
|
||||
@Override
|
||||
public boolean hasSavedCaptor() {
|
||||
return savedCaptorUUID != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Nullable
|
||||
public UUID getSavedCaptorUUID() {
|
||||
return savedCaptorUUID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveCaptorUUID(UUID uuid) {
|
||||
this.savedCaptorUUID = uuid;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearSavedCaptor() {
|
||||
this.savedCaptorUUID = null;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// NBT serialization
|
||||
// ========================================
|
||||
|
||||
@Override
|
||||
public CompoundTag serializeNBT() {
|
||||
CompoundTag root = new CompoundTag();
|
||||
CompoundTag regionsTag = new CompoundTag();
|
||||
|
||||
// Track which stacks we've already serialized (identity-based)
|
||||
IdentityHashMap<ItemStack, String> serialized = new IdentityHashMap<>();
|
||||
|
||||
for (BodyRegionV2 region : BodyRegionV2.values()) {
|
||||
ItemStack stack = regions.get(region);
|
||||
if (stack == null || stack.isEmpty()) continue;
|
||||
|
||||
String regionName = region.name();
|
||||
|
||||
if (serialized.containsKey(stack)) {
|
||||
// This stack was already serialized under another region.
|
||||
// Add this region to the _also list of the primary region.
|
||||
String primaryRegion = serialized.get(stack);
|
||||
String alsoKey = primaryRegion + NBT_ALSO_SUFFIX;
|
||||
ListTag alsoList;
|
||||
if (regionsTag.contains(alsoKey, Tag.TAG_LIST)) {
|
||||
alsoList = regionsTag.getList(alsoKey, Tag.TAG_STRING);
|
||||
} else {
|
||||
alsoList = new ListTag();
|
||||
regionsTag.put(alsoKey, alsoList);
|
||||
}
|
||||
alsoList.add(StringTag.valueOf(regionName));
|
||||
} else {
|
||||
// First time seeing this stack — serialize it
|
||||
regionsTag.put(regionName, stack.save(new CompoundTag()));
|
||||
serialized.put(stack, regionName);
|
||||
}
|
||||
}
|
||||
|
||||
root.put(NBT_ROOT_KEY, regionsTag);
|
||||
|
||||
// Pole leash persistence
|
||||
if (savedPolePosition != null && savedPoleDimension != null) {
|
||||
root.putLong("pole_position", savedPolePosition.asLong());
|
||||
root.putString("pole_dimension", savedPoleDimension.location().toString());
|
||||
}
|
||||
|
||||
// Captor persistence
|
||||
if (savedCaptorUUID != null) {
|
||||
root.putUUID("captor_uuid", savedCaptorUUID);
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deserializeNBT(CompoundTag tag) {
|
||||
clearAll();
|
||||
|
||||
if (!tag.contains(NBT_ROOT_KEY, Tag.TAG_COMPOUND)) return;
|
||||
CompoundTag regionsTag = tag.getCompound(NBT_ROOT_KEY);
|
||||
|
||||
// First pass: load primary region stacks
|
||||
Set<String> allKeys = regionsTag.getAllKeys();
|
||||
Map<String, ItemStack> loadedStacks = new LinkedHashMap<>();
|
||||
|
||||
for (String key : allKeys) {
|
||||
if (key.endsWith(NBT_ALSO_SUFFIX)) continue; // Handle in second pass
|
||||
|
||||
BodyRegionV2 region = BodyRegionV2.fromName(key);
|
||||
if (region == null) continue;
|
||||
|
||||
CompoundTag stackTag = regionsTag.getCompound(key);
|
||||
ItemStack stack = ItemStack.of(stackTag);
|
||||
if (stack.isEmpty()) continue;
|
||||
|
||||
regions.put(region, stack);
|
||||
loadedStacks.put(key, stack);
|
||||
}
|
||||
|
||||
// Second pass: process _also entries to share the same ItemStack reference
|
||||
for (String key : allKeys) {
|
||||
if (!key.endsWith(NBT_ALSO_SUFFIX)) continue;
|
||||
|
||||
String primaryRegionName = key.substring(
|
||||
0, key.length() - NBT_ALSO_SUFFIX.length()
|
||||
);
|
||||
ItemStack primaryStack = loadedStacks.get(primaryRegionName);
|
||||
if (primaryStack == null) continue;
|
||||
|
||||
ListTag alsoList = regionsTag.getList(key, Tag.TAG_STRING);
|
||||
for (int i = 0; i < alsoList.size(); i++) {
|
||||
String alsoRegionName = alsoList.getString(i);
|
||||
BodyRegionV2 alsoRegion = BodyRegionV2.fromName(alsoRegionName);
|
||||
if (alsoRegion != null) {
|
||||
regions.put(alsoRegion, primaryStack); // Same reference
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pole leash persistence
|
||||
if (tag.contains("pole_position") && tag.contains("pole_dimension")) {
|
||||
try {
|
||||
savedPolePosition = BlockPos.of(tag.getLong("pole_position"));
|
||||
savedPoleDimension = ResourceKey.create(Registries.DIMENSION,
|
||||
new ResourceLocation(tag.getString("pole_dimension")));
|
||||
} catch (net.minecraft.ResourceLocationException e) {
|
||||
com.tiedup.remake.core.TiedUpMod.LOGGER.warn(
|
||||
"Invalid pole dimension in NBT, clearing saved pole data: {}", e.getMessage());
|
||||
savedPolePosition = null;
|
||||
savedPoleDimension = null;
|
||||
}
|
||||
} else {
|
||||
savedPolePosition = null;
|
||||
savedPoleDimension = null;
|
||||
}
|
||||
|
||||
// Captor persistence
|
||||
if (tag.hasUUID("captor_uuid")) {
|
||||
savedCaptorUUID = tag.getUUID("captor_uuid");
|
||||
} else {
|
||||
savedCaptorUUID = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.tiedup.remake.v2.bondage.capability;
|
||||
|
||||
import com.tiedup.remake.v2.bondage.IV2BondageEquipment;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraftforge.common.capabilities.Capability;
|
||||
import net.minecraftforge.common.capabilities.CapabilityManager;
|
||||
import net.minecraftforge.common.capabilities.CapabilityToken;
|
||||
import net.minecraftforge.common.capabilities.ICapabilitySerializable;
|
||||
import net.minecraftforge.common.util.LazyOptional;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
/**
|
||||
* Forge capability provider for V2 bondage equipment.
|
||||
* Handles capability token, lazy optional lifecycle, and NBT serialization.
|
||||
*/
|
||||
public class V2BondageEquipmentProvider
|
||||
implements ICapabilitySerializable<CompoundTag>
|
||||
{
|
||||
|
||||
public static final Capability<IV2BondageEquipment> V2_BONDAGE_EQUIPMENT =
|
||||
CapabilityManager.get(new CapabilityToken<>() {});
|
||||
|
||||
private final V2BondageEquipment equipment;
|
||||
private final LazyOptional<IV2BondageEquipment> optional;
|
||||
|
||||
public V2BondageEquipmentProvider() {
|
||||
this.equipment = new V2BondageEquipment();
|
||||
this.optional = LazyOptional.of(() -> equipment);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull <T> LazyOptional<T> getCapability(
|
||||
@NotNull Capability<T> cap,
|
||||
@Nullable Direction side
|
||||
) {
|
||||
if (cap == V2_BONDAGE_EQUIPMENT) {
|
||||
return optional.cast();
|
||||
}
|
||||
return LazyOptional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("null")
|
||||
public @NotNull CompoundTag serializeNBT() {
|
||||
return equipment.serializeNBT();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deserializeNBT(CompoundTag tag) {
|
||||
equipment.deserializeNBT(tag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the LazyOptional. Call when the entity is removed
|
||||
* or during player clone to prevent memory leaks.
|
||||
*/
|
||||
public void invalidate() {
|
||||
optional.invalidate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package com.tiedup.remake.v2.bondage.capability;
|
||||
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
import com.tiedup.remake.network.ModNetwork;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import com.tiedup.remake.v2.bondage.IV2BondageEquipment;
|
||||
import com.tiedup.remake.v2.bondage.IV2BondageItem;
|
||||
import com.tiedup.remake.v2.bondage.V2EquipResult;
|
||||
import com.tiedup.remake.v2.bondage.V2EquipmentManager;
|
||||
import com.tiedup.remake.v2.bondage.IV2EquipmentHolder;
|
||||
import com.tiedup.remake.v2.bondage.network.PacketSyncV2Equipment;
|
||||
import java.util.Map;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
/**
|
||||
* Static API for V2 bondage equipment operations.
|
||||
*
|
||||
* READ methods work on any side. WRITE methods are server-only and return
|
||||
* early if called on the client. This prevents desync and ensures the server
|
||||
* is the authority for equipment state.
|
||||
*
|
||||
* Currently dispatches only for Players (via Forge capability).
|
||||
* Phase 6 will add Damsel/ArmorStand support.
|
||||
*/
|
||||
public final class V2EquipmentHelper {
|
||||
|
||||
private V2EquipmentHelper() {}
|
||||
|
||||
// ==================== READ ====================
|
||||
|
||||
/**
|
||||
* Get the V2 equipment capability for an entity.
|
||||
* @return The capability, or null if the entity doesn't support V2 equipment.
|
||||
*/
|
||||
@Nullable
|
||||
public static IV2BondageEquipment getEquipment(LivingEntity entity) {
|
||||
if (entity == null) return null;
|
||||
if (entity instanceof Player player) {
|
||||
return player.getCapability(V2BondageEquipmentProvider.V2_BONDAGE_EQUIPMENT)
|
||||
.orElse(null);
|
||||
}
|
||||
// V2 equipment holders (EntityDamsel, etc.)
|
||||
if (entity instanceof IV2EquipmentHolder holder) {
|
||||
return holder.getV2Equipment();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the item in a specific region for an entity.
|
||||
* @return The ItemStack, or {@link ItemStack#EMPTY} if empty or entity unsupported.
|
||||
*/
|
||||
public static ItemStack getInRegion(LivingEntity entity, BodyRegionV2 region) {
|
||||
IV2BondageEquipment equip = getEquipment(entity);
|
||||
if (equip == null) return ItemStack.EMPTY;
|
||||
return equip.getInRegion(region);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a region is directly occupied on the given entity.
|
||||
*/
|
||||
public static boolean isRegionOccupied(LivingEntity entity, BodyRegionV2 region) {
|
||||
IV2BondageEquipment equip = getEquipment(entity);
|
||||
if (equip == null) return false;
|
||||
return equip.isRegionOccupied(region);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a region is blocked by any equipped item's {@link IV2BondageItem#getBlockedRegions()}.
|
||||
*/
|
||||
public static boolean isRegionBlocked(LivingEntity entity, BodyRegionV2 region) {
|
||||
IV2BondageEquipment equip = getEquipment(entity);
|
||||
if (equip == null) return false;
|
||||
return equip.isRegionBlocked(region);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all equipped items (de-duplicated) for an entity.
|
||||
* @return Unmodifiable map, or empty map if entity unsupported.
|
||||
*/
|
||||
public static Map<BodyRegionV2, ItemStack> getAllEquipped(LivingEntity entity) {
|
||||
IV2BondageEquipment equip = getEquipment(entity);
|
||||
if (equip == null) return Map.of();
|
||||
return equip.getAllEquipped();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the entity has any V2 equipment at all.
|
||||
*/
|
||||
public static boolean hasAnyEquipment(LivingEntity entity) {
|
||||
IV2BondageEquipment equip = getEquipment(entity);
|
||||
if (equip == null) return false;
|
||||
return equip.getEquippedCount() > 0;
|
||||
}
|
||||
|
||||
// ==================== WRITE (server-only) ====================
|
||||
|
||||
/**
|
||||
* Equip a V2 bondage item onto an entity.
|
||||
*
|
||||
* Validates the item implements {@link IV2BondageItem}, copies the stack,
|
||||
* runs conflict resolution via {@link V2EquipmentManager#tryEquip},
|
||||
* and fires lifecycle hooks.
|
||||
*
|
||||
* @param entity The target entity (must be server-side)
|
||||
* @param stack The ItemStack to equip (must implement IV2BondageItem)
|
||||
* @return The equip result, or {@link V2EquipResult#BLOCKED} if invalid
|
||||
*/
|
||||
public static V2EquipResult equipItem(LivingEntity entity, ItemStack stack) {
|
||||
if (entity.level().isClientSide) return V2EquipResult.BLOCKED;
|
||||
if (stack == null || stack.isEmpty()) return V2EquipResult.BLOCKED;
|
||||
if (!(stack.getItem() instanceof IV2BondageItem item)) return V2EquipResult.BLOCKED;
|
||||
|
||||
IV2BondageEquipment equip = getEquipment(entity);
|
||||
if (equip == null) return V2EquipResult.BLOCKED;
|
||||
|
||||
if (!item.canEquip(stack, entity)) return V2EquipResult.BLOCKED;
|
||||
|
||||
// Copy the stack so the original isn't mutated
|
||||
ItemStack equipCopy = stack.copy();
|
||||
V2EquipResult result = V2EquipmentManager.tryEquip(equip, item, equipCopy, entity);
|
||||
|
||||
if (result.isSuccess()) {
|
||||
item.onEquipped(equipCopy, entity);
|
||||
sync(entity);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unequip the item from a region, respecting canUnequip.
|
||||
*
|
||||
* @param entity The target entity (must be server-side)
|
||||
* @param region The region to unequip from
|
||||
* @return The removed ItemStack, or {@link ItemStack#EMPTY} if nothing removed
|
||||
*/
|
||||
public static ItemStack unequipFromRegion(LivingEntity entity, BodyRegionV2 region) {
|
||||
return unequipFromRegion(entity, region, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unequip the item from a region, optionally forcing removal.
|
||||
*
|
||||
* @param entity The target entity (must be server-side)
|
||||
* @param region The region to unequip from
|
||||
* @param force If true, bypass canUnequip check
|
||||
* @return The removed ItemStack, or {@link ItemStack#EMPTY} if nothing removed
|
||||
*/
|
||||
public static ItemStack unequipFromRegion(
|
||||
LivingEntity entity,
|
||||
BodyRegionV2 region,
|
||||
boolean force
|
||||
) {
|
||||
if (entity.level().isClientSide) return ItemStack.EMPTY;
|
||||
|
||||
IV2BondageEquipment equip = getEquipment(entity);
|
||||
if (equip == null) return ItemStack.EMPTY;
|
||||
|
||||
ItemStack stack = equip.getInRegion(region);
|
||||
if (stack.isEmpty()) return ItemStack.EMPTY;
|
||||
|
||||
if (!force && stack.getItem() instanceof IV2BondageItem item) {
|
||||
if (!item.canUnequip(stack, entity)) {
|
||||
return ItemStack.EMPTY;
|
||||
}
|
||||
}
|
||||
|
||||
// Full scan to remove all region references to this stack (identity-based)
|
||||
V2EquipmentManager.removeAllRegionsOf(equip, stack);
|
||||
|
||||
if (stack.getItem() instanceof IV2BondageItem item) {
|
||||
item.onUnequipped(stack, entity);
|
||||
}
|
||||
|
||||
sync(entity);
|
||||
return stack;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all V2 equipment from an entity, firing onUnequipped for each.
|
||||
*
|
||||
* @param entity The target entity (must be server-side)
|
||||
*/
|
||||
public static void clearAll(LivingEntity entity) {
|
||||
if (entity.level().isClientSide) return;
|
||||
|
||||
IV2BondageEquipment equip = getEquipment(entity);
|
||||
if (equip == null) return;
|
||||
|
||||
// Fire lifecycle hooks for each unique item before clearing
|
||||
Map<BodyRegionV2, ItemStack> equipped = equip.getAllEquipped();
|
||||
for (Map.Entry<BodyRegionV2, ItemStack> entry : equipped.entrySet()) {
|
||||
ItemStack stack = entry.getValue();
|
||||
if (stack.getItem() instanceof IV2BondageItem item) {
|
||||
item.onUnequipped(stack, entity);
|
||||
}
|
||||
}
|
||||
|
||||
equip.clearAll();
|
||||
sync(entity);
|
||||
}
|
||||
|
||||
// ==================== SYNC ====================
|
||||
|
||||
/**
|
||||
* Sync V2 equipment state to tracking clients.
|
||||
* Sends the full serialized capability to the player and all trackers.
|
||||
*/
|
||||
public static void sync(LivingEntity entity) {
|
||||
if (entity.level().isClientSide) return;
|
||||
|
||||
IV2BondageEquipment equip = getEquipment(entity);
|
||||
if (equip == null) return;
|
||||
|
||||
// IV2EquipmentHolder entities (Damsels) sync via EntityDataAccessor,
|
||||
// not via packet. Trigger their EntityData sync instead.
|
||||
if (entity instanceof IV2EquipmentHolder holder) {
|
||||
holder.syncEquipmentToData();
|
||||
return;
|
||||
}
|
||||
|
||||
PacketSyncV2Equipment packet = new PacketSyncV2Equipment(
|
||||
entity.getId(), equip.serializeNBT()
|
||||
);
|
||||
|
||||
if (entity instanceof ServerPlayer serverPlayer) {
|
||||
ModNetwork.sendToAllTrackingAndSelf(packet, serverPlayer);
|
||||
} else {
|
||||
// Phase 6: NPC support — send to all tracking the entity
|
||||
ModNetwork.sendToAllTrackingEntity(packet, entity);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync V2 equipment to a specific player (used on login/start-tracking).
|
||||
*/
|
||||
public static void syncTo(LivingEntity entity, ServerPlayer target) {
|
||||
// IV2EquipmentHolder entities sync via SynchedEntityData, not packets
|
||||
if (entity instanceof IV2EquipmentHolder holder) {
|
||||
holder.syncEquipmentToData();
|
||||
return;
|
||||
}
|
||||
|
||||
IV2BondageEquipment equip = getEquipment(entity);
|
||||
if (equip == null) return;
|
||||
|
||||
PacketSyncV2Equipment packet = new PacketSyncV2Equipment(
|
||||
entity.getId(), equip.serializeNBT()
|
||||
);
|
||||
ModNetwork.sendToPlayer(packet, target);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.tiedup.remake.v2.bondage.client;
|
||||
|
||||
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemDefinition;
|
||||
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.nbt.Tag;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Resolves per-item tint colors by merging definition defaults with NBT overrides.
|
||||
*
|
||||
* <p>Priority (highest wins):
|
||||
* <ol>
|
||||
* <li>NBT tag {@code tint_colors} on the ItemStack (player dye overrides)</li>
|
||||
* <li>Default tint channels from the {@link DataDrivenItemDefinition}</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>Returns a map of tint channel name (e.g. "tintable_0") to RGB int (0xRRGGBB).
|
||||
* An empty map means no tint — the renderer should use white (no color modification).</p>
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public final class TintColorResolver {
|
||||
|
||||
private TintColorResolver() {}
|
||||
|
||||
/**
|
||||
* Resolve tint colors for an ItemStack.
|
||||
*
|
||||
* @param stack the equipped bondage item
|
||||
* @return channel-to-color map; empty if no tint channels defined or found
|
||||
*/
|
||||
public static Map<String, Integer> resolve(ItemStack stack) {
|
||||
Map<String, Integer> result = new LinkedHashMap<>();
|
||||
|
||||
// 1. Load defaults from DataDrivenItemDefinition
|
||||
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
|
||||
if (def != null && def.tintChannels() != null) {
|
||||
result.putAll(def.tintChannels());
|
||||
}
|
||||
|
||||
// 2. Override with NBT "tint_colors" (player dye overrides)
|
||||
CompoundTag tag = stack.getTag();
|
||||
if (tag != null && tag.contains("tint_colors", Tag.TAG_COMPOUND)) {
|
||||
CompoundTag tints = tag.getCompound("tint_colors");
|
||||
for (String key : tints.getAllKeys()) {
|
||||
result.put(key, tints.getInt(key));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package com.tiedup.remake.v2.bondage.client;
|
||||
|
||||
import com.mojang.blaze3d.vertex.PoseStack;
|
||||
import com.tiedup.remake.client.gltf.GltfCache;
|
||||
import com.tiedup.remake.client.gltf.GltfData;
|
||||
import com.tiedup.remake.client.gltf.GltfLiveBoneReader;
|
||||
import com.tiedup.remake.client.gltf.GltfMeshRenderer;
|
||||
import com.tiedup.remake.client.gltf.GltfSkinningEngine;
|
||||
import com.tiedup.remake.entities.AbstractTiedUpNpc;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import com.tiedup.remake.v2.bondage.IV2BondageEquipment;
|
||||
import com.tiedup.remake.v2.bondage.IV2BondageItem;
|
||||
import com.tiedup.remake.v2.bondage.IV2EquipmentHolder;
|
||||
import com.tiedup.remake.v2.bondage.capability.V2BondageEquipmentProvider;
|
||||
import com.tiedup.remake.v2.furniture.ISeatProvider;
|
||||
import com.tiedup.remake.v2.furniture.SeatDefinition;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import net.minecraft.client.model.HumanoidModel;
|
||||
import net.minecraft.client.renderer.RenderType;
|
||||
import net.minecraft.client.player.AbstractClientPlayer;
|
||||
import net.minecraft.client.renderer.MultiBufferSource;
|
||||
import net.minecraft.client.renderer.entity.LivingEntityRenderer;
|
||||
import net.minecraft.client.renderer.entity.RenderLayerParent;
|
||||
import net.minecraft.client.renderer.entity.layers.RenderLayer;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.joml.Matrix4f;
|
||||
|
||||
/**
|
||||
* Production render layer for V2 bondage equipment.
|
||||
* Renders ALL equipped V2 bondage items as GLB meshes on any entity
|
||||
* with a {@link HumanoidModel}.
|
||||
*
|
||||
* <p>Works for both players and NPCs. For players, reads the V2 bondage
|
||||
* equipment capability. For NPCs implementing {@link IV2EquipmentHolder}
|
||||
* (e.g., EntityDamsel), reads directly from the holder.
|
||||
*
|
||||
* <p>Each equipped item with a non-null model location gets its own
|
||||
* pushPose/popPose pair. Joint matrices are computed per-GLB-model
|
||||
* because different GLB models have different skeletons.
|
||||
*
|
||||
* <p>Unlike {@link com.tiedup.remake.client.gltf.GltfRenderLayer},
|
||||
* this layer is always active (no F9 toggle guard) and renders on
|
||||
* ALL entities (no local-player-only guard).
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class V2BondageRenderLayer<T extends LivingEntity, M extends HumanoidModel<T>>
|
||||
extends RenderLayer<T, M> {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
|
||||
|
||||
/**
|
||||
* Y alignment offset to place glTF meshes in the MC PoseStack.
|
||||
* After LivingEntityRenderer's scale(-1,-1,1) + translate(0,-1.501,0),
|
||||
* the PoseStack origin is at model top (1.501 blocks above feet), Y-down.
|
||||
* Translating by 1.501 maps glTF feet to PoseStack feet.
|
||||
*/
|
||||
private static final float ALIGNMENT_Y = 1.501f;
|
||||
|
||||
public V2BondageRenderLayer(RenderLayerParent<T, M> renderer) {
|
||||
super(renderer);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(
|
||||
PoseStack poseStack,
|
||||
MultiBufferSource buffer,
|
||||
int packedLight,
|
||||
T entity,
|
||||
float limbSwing,
|
||||
float limbSwingAmount,
|
||||
float partialTick,
|
||||
float ageInTicks,
|
||||
float netHeadYaw,
|
||||
float headPitch
|
||||
) {
|
||||
// Get V2 equipment via capability (Players) or IV2EquipmentHolder (Damsels)
|
||||
IV2BondageEquipment equipment = null;
|
||||
if (entity instanceof Player player) {
|
||||
equipment = player.getCapability(
|
||||
V2BondageEquipmentProvider.V2_BONDAGE_EQUIPMENT
|
||||
).orElse(null);
|
||||
} else if (entity instanceof IV2EquipmentHolder holder) {
|
||||
equipment = holder.getV2Equipment();
|
||||
}
|
||||
if (equipment == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all equipped items (de-duplicated map)
|
||||
Map<BodyRegionV2, ItemStack> equipped = equipment.getAllEquipped();
|
||||
if (equipped.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip rendering items in regions blocked by furniture/seat provider
|
||||
Set<BodyRegionV2> furnitureBlocked = Set.of();
|
||||
if (entity.isPassenger() && entity.getVehicle() instanceof ISeatProvider provider) {
|
||||
SeatDefinition seat = provider.getSeatForPassenger(entity);
|
||||
if (seat != null) {
|
||||
furnitureBlocked = seat.blockedRegions();
|
||||
}
|
||||
}
|
||||
|
||||
M parentModel = this.getParentModel();
|
||||
int packedOverlay = LivingEntityRenderer.getOverlayCoords(entity, 0.0f);
|
||||
|
||||
for (Map.Entry<BodyRegionV2, ItemStack> entry : equipped.entrySet()) {
|
||||
ItemStack stack = entry.getValue();
|
||||
if (stack.isEmpty()) continue;
|
||||
|
||||
// Furniture blocks this region — skip rendering
|
||||
if (furnitureBlocked.contains(entry.getKey())) continue;
|
||||
|
||||
// Check if the item implements IV2BondageItem
|
||||
if (!(stack.getItem() instanceof IV2BondageItem bondageItem)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Select slim model variant for Alex-style players or slim Damsels
|
||||
boolean isSlim;
|
||||
if (entity instanceof AbstractClientPlayer acp) {
|
||||
isSlim = "slim".equals(acp.getModelName());
|
||||
} else if (entity instanceof AbstractTiedUpNpc npc) {
|
||||
isSlim = npc.hasSlimArms();
|
||||
} else {
|
||||
isSlim = false;
|
||||
}
|
||||
ResourceLocation modelLocation = (isSlim && bondageItem.supportsSlimModel(stack))
|
||||
? bondageItem.getSlimModelLocation(stack)
|
||||
: null;
|
||||
if (modelLocation == null) {
|
||||
modelLocation = bondageItem.getModelLocation(stack);
|
||||
}
|
||||
if (modelLocation == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Load GLB data from cache
|
||||
GltfData data = GltfCache.get(modelLocation);
|
||||
if (data == null) {
|
||||
LOGGER.debug("[V2Render] Failed to load GLB for item {}: {}",
|
||||
stack.getItem(), modelLocation);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Compute joint matrices for this specific GLB model
|
||||
// Each GLB has its own skeleton, so matrices are per-item
|
||||
Matrix4f[] joints = GltfLiveBoneReader.computeJointMatricesFromModel(
|
||||
parentModel, data, entity
|
||||
);
|
||||
if (joints == null) {
|
||||
// Fallback to GLB-internal skinning
|
||||
joints = GltfSkinningEngine.computeJointMatrices(data);
|
||||
}
|
||||
|
||||
// Render this item's mesh
|
||||
poseStack.pushPose();
|
||||
poseStack.translate(0, ALIGNMENT_Y, 0);
|
||||
|
||||
// Check for tint channels — use per-primitive tinted rendering if present
|
||||
Map<String, Integer> tintColors = TintColorResolver.resolve(stack);
|
||||
if (!tintColors.isEmpty() && data.primitives().size() > 1) {
|
||||
// Multi-primitive mesh with tint data: render per-primitive with colors
|
||||
RenderType renderType = GltfMeshRenderer.getRenderTypeForDefaultTexture();
|
||||
GltfMeshRenderer.renderSkinnedTinted(
|
||||
data, joints, poseStack, buffer,
|
||||
packedLight, packedOverlay, renderType, tintColors
|
||||
);
|
||||
} else {
|
||||
// Standard path: single primitive or no tint data
|
||||
GltfMeshRenderer.renderSkinned(
|
||||
data, joints, poseStack, buffer,
|
||||
packedLight, packedOverlay
|
||||
);
|
||||
}
|
||||
|
||||
poseStack.popPose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package com.tiedup.remake.v2.bondage.datadriven;
|
||||
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import com.tiedup.remake.v2.bondage.IV2BondageEquipment;
|
||||
import com.tiedup.remake.v2.bondage.V2BondageItems;
|
||||
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
|
||||
import com.tiedup.remake.v2.bondage.items.AbstractV2BondageItem;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
/**
|
||||
* Generic Item class for all data-driven bondage items.
|
||||
*
|
||||
* <p>A single Forge-registered Item. Each ItemStack carries a {@code tiedup_item_id}
|
||||
* NBT tag that points to a {@link DataDrivenItemDefinition} in the
|
||||
* {@link DataDrivenItemRegistry}. All property methods are overridden to read
|
||||
* from the definition via the stack-aware interface methods.</p>
|
||||
*
|
||||
* <p>The no-arg methods return safe defaults because the singleton item cannot
|
||||
* know which definition to use without an ItemStack. The real values come
|
||||
* exclusively from the stack-aware overrides.</p>
|
||||
*/
|
||||
public class DataDrivenBondageItem extends AbstractV2BondageItem {
|
||||
|
||||
public DataDrivenBondageItem() {
|
||||
super(new Properties().stacksTo(1));
|
||||
}
|
||||
|
||||
// ===== REGIONS (stack-aware overrides) =====
|
||||
|
||||
@Override
|
||||
public Set<BodyRegionV2> getOccupiedRegions() {
|
||||
// Safe default for the singleton — real value comes from stack-aware override
|
||||
return Set.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<BodyRegionV2> getOccupiedRegions(ItemStack stack) {
|
||||
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
|
||||
return def != null ? def.occupiedRegions() : Set.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<BodyRegionV2> getBlockedRegions(ItemStack stack) {
|
||||
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
|
||||
return def != null ? def.blockedRegions() : Set.of();
|
||||
}
|
||||
|
||||
// ===== 3D MODELS (stack-aware overrides) =====
|
||||
|
||||
@Override
|
||||
@Nullable
|
||||
public ResourceLocation getModelLocation() {
|
||||
return null; // Safe default
|
||||
}
|
||||
|
||||
@Override
|
||||
@Nullable
|
||||
public ResourceLocation getModelLocation(ItemStack stack) {
|
||||
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
|
||||
return def != null ? def.modelLocation() : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Nullable
|
||||
public ResourceLocation getSlimModelLocation(ItemStack stack) {
|
||||
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
|
||||
return def != null ? def.slimModelLocation() : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsSlimModel(ItemStack stack) {
|
||||
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
|
||||
return def != null && def.slimModelLocation() != null;
|
||||
}
|
||||
|
||||
// ===== POSES & ANIMATIONS (stack-aware overrides) =====
|
||||
|
||||
@Override
|
||||
public int getPosePriority() {
|
||||
return 0; // Safe default
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getPosePriority(ItemStack stack) {
|
||||
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
|
||||
return def != null ? def.posePriority() : 0;
|
||||
}
|
||||
|
||||
// ===== ITEM STATE (stack-aware overrides) =====
|
||||
|
||||
@Override
|
||||
public int getEscapeDifficulty(ItemStack stack) {
|
||||
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
|
||||
return def != null ? def.escapeDifficulty() : 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsColor(ItemStack stack) {
|
||||
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
|
||||
return def != null && def.supportsColor();
|
||||
}
|
||||
|
||||
// ===== IHasResistance IMPLEMENTATION =====
|
||||
|
||||
@Override
|
||||
public String getResistanceId() {
|
||||
// Safe default for the singleton -- the real resistance comes from
|
||||
// getBaseResistance() which bypasses the GameRules switch entirely.
|
||||
return "data_driven";
|
||||
}
|
||||
|
||||
/**
|
||||
* Bypass the GameRules switch lookup entirely for data-driven items.
|
||||
*
|
||||
* <p>The default IHasResistance implementation calls
|
||||
* {@code ModGameRules.getResistance(gameRules, getResistanceId())} which has
|
||||
* a hardcoded switch for "rope", "gag", "blindfold", "collar" and defaults
|
||||
* to 100 for everything else. This makes the JSON {@code escape_difficulty}
|
||||
* field useless.</p>
|
||||
*
|
||||
* <p>Instead, we scan the entity's equipped items to find ALL data-driven items
|
||||
* and return the MAX escape difficulty. This is because IHasResistance has no
|
||||
* ItemStack parameter, so we cannot distinguish which specific data-driven item
|
||||
* is being queried when multiple are equipped (they all share the same Item
|
||||
* singleton). Returning the MAX is the safe choice: it prevents the struggle
|
||||
* system from underestimating resistance.</p>
|
||||
*/
|
||||
@Override
|
||||
public int getBaseResistance(LivingEntity entity) {
|
||||
if (entity != null) {
|
||||
IV2BondageEquipment equip = V2EquipmentHelper.getEquipment(entity);
|
||||
if (equip != null) {
|
||||
int maxDifficulty = -1;
|
||||
for (Map.Entry<BodyRegionV2, ItemStack> entry : equip.getAllEquipped().entrySet()) {
|
||||
ItemStack stack = entry.getValue();
|
||||
if (stack.getItem() == this) {
|
||||
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
|
||||
if (def != null) {
|
||||
maxDifficulty = Math.max(maxDifficulty, def.escapeDifficulty());
|
||||
}
|
||||
}
|
||||
}
|
||||
if (maxDifficulty >= 0) {
|
||||
return maxDifficulty;
|
||||
}
|
||||
}
|
||||
}
|
||||
return 100; // safe fallback
|
||||
}
|
||||
|
||||
@Override
|
||||
public void notifyStruggle(LivingEntity entity) {
|
||||
// Play a generic chain sound for data-driven items
|
||||
entity.level().playSound(
|
||||
null, entity.getX(), entity.getY(), entity.getZ(),
|
||||
net.minecraft.sounds.SoundEvents.CHAIN_STEP,
|
||||
net.minecraft.sounds.SoundSource.PLAYERS,
|
||||
0.4f, 1.0f
|
||||
);
|
||||
}
|
||||
|
||||
// ===== DISPLAY NAME =====
|
||||
|
||||
@Override
|
||||
public Component getName(ItemStack stack) {
|
||||
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
|
||||
if (def == null) return super.getName(stack);
|
||||
if (def.translationKey() != null) {
|
||||
return Component.translatable(def.translationKey());
|
||||
}
|
||||
return Component.literal(def.displayName());
|
||||
}
|
||||
|
||||
// ===== FACTORY =====
|
||||
|
||||
/**
|
||||
* Create an ItemStack for a data-driven bondage item.
|
||||
*
|
||||
* @param itemId the definition ID (must exist in {@link DataDrivenItemRegistry})
|
||||
* @return a new ItemStack with the {@code tiedup_item_id} NBT tag set,
|
||||
* or {@link ItemStack#EMPTY} if the item is not registered in Forge
|
||||
*/
|
||||
public static ItemStack createStack(ResourceLocation itemId) {
|
||||
if (V2BondageItems.DATA_DRIVEN_ITEM == null) return ItemStack.EMPTY;
|
||||
ItemStack stack = new ItemStack(V2BondageItems.DATA_DRIVEN_ITEM.get());
|
||||
stack.getOrCreateTag().putString(DataDrivenItemRegistry.NBT_ITEM_ID, itemId.toString());
|
||||
return stack;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.tiedup.remake.v2.bondage.datadriven;
|
||||
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
/**
|
||||
* Immutable definition for a data-driven bondage item.
|
||||
*
|
||||
* <p>Loaded from JSON files in {@code assets/<namespace>/tiedup_items/}.
|
||||
* Each definition describes the properties of a bondage item variant
|
||||
* that can be instantiated as an ItemStack with the {@code tiedup_item_id} NBT tag.</p>
|
||||
*
|
||||
* <p>All rendering and gameplay properties are read from this record at runtime
|
||||
* via {@link DataDrivenBondageItem}'s stack-aware method overrides.</p>
|
||||
*/
|
||||
public record DataDrivenItemDefinition(
|
||||
/** Unique identifier for this item definition (e.g., "tiedup:leather_armbinder"). */
|
||||
ResourceLocation id,
|
||||
|
||||
/** Human-readable display name (fallback if no translation key). */
|
||||
String displayName,
|
||||
|
||||
/** Optional translation key for localized display name. */
|
||||
@Nullable String translationKey,
|
||||
|
||||
/** Resource location of the GLB model file. */
|
||||
ResourceLocation modelLocation,
|
||||
|
||||
/** Optional slim (Alex-style) model variant. */
|
||||
@Nullable ResourceLocation slimModelLocation,
|
||||
|
||||
/** Optional base texture path for color variant resolution. */
|
||||
@Nullable ResourceLocation texturePath,
|
||||
|
||||
/** Optional separate GLB for animations (shared template). */
|
||||
@Nullable ResourceLocation animationSource,
|
||||
|
||||
/** Body regions this item occupies. Never empty. */
|
||||
Set<BodyRegionV2> occupiedRegions,
|
||||
|
||||
/** Body regions this item blocks. Defaults to occupiedRegions if not specified. */
|
||||
Set<BodyRegionV2> blockedRegions,
|
||||
|
||||
/** Pose priority for conflict resolution. Higher wins. */
|
||||
int posePriority,
|
||||
|
||||
/** Escape difficulty for the struggle minigame. */
|
||||
int escapeDifficulty,
|
||||
|
||||
/** Whether this item can be locked with a padlock. */
|
||||
boolean lockable,
|
||||
|
||||
/** Whether this item supports color variants. */
|
||||
boolean supportsColor,
|
||||
|
||||
/** Default tint colors per channel (e.g. "tintable_0" -> 0x8B4513). Empty map if none. */
|
||||
Map<String, Integer> tintChannels,
|
||||
|
||||
/**
|
||||
* Optional inventory icon model location (e.g., "tiedup:item/armbinder").
|
||||
*
|
||||
* <p>Points to a standard {@code item/generated} model JSON that will be used
|
||||
* as the inventory sprite for this data-driven item variant. When null, the
|
||||
* default {@code tiedup:item/data_driven_item} model is used.</p>
|
||||
*/
|
||||
@Nullable ResourceLocation icon,
|
||||
|
||||
/**
|
||||
* Optional movement style that changes how a bound player physically moves.
|
||||
* Determines server-side speed reduction, jump suppression, and client animation.
|
||||
*/
|
||||
@Nullable com.tiedup.remake.v2.bondage.movement.MovementStyle movementStyle,
|
||||
|
||||
/**
|
||||
* Optional per-item overrides for the movement style's default values.
|
||||
* Requires {@code movementStyle} to be non-null (ignored otherwise).
|
||||
*/
|
||||
@Nullable com.tiedup.remake.v2.bondage.movement.MovementModifier movementModifier,
|
||||
|
||||
/**
|
||||
* Per-animation bone whitelist. Maps animation name (e.g. "idle", "struggle")
|
||||
* to the set of PlayerAnimator bone names this item is allowed to animate.
|
||||
*
|
||||
* <p>Valid bone names: head, body, rightArm, leftArm, rightLeg, leftLeg.</p>
|
||||
*
|
||||
* <p>At animation time, the effective parts for a given clip are computed as
|
||||
* {@code intersection(animationBones[clipName], ownedParts)}. If the clip name
|
||||
* is not present in this map (or null), the item falls back to its full
|
||||
* {@code ownedParts}.</p>
|
||||
*
|
||||
* <p>This field is required in the JSON definition. Never null, never empty.</p>
|
||||
*/
|
||||
Map<String, Set<String>> animationBones
|
||||
) {}
|
||||
@@ -0,0 +1,422 @@
|
||||
package com.tiedup.remake.v2.bondage.datadriven;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import com.tiedup.remake.v2.bondage.movement.MovementModifier;
|
||||
import com.tiedup.remake.v2.bondage.movement.MovementStyle;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collections;
|
||||
import java.util.EnumSet;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
/**
|
||||
* Parses JSON files into {@link DataDrivenItemDefinition} instances.
|
||||
*
|
||||
* <p>Uses manual field extraction (not Gson deserialization) for validation control.
|
||||
* Invalid fields are logged as warnings; critical errors (missing type, empty regions,
|
||||
* missing model) cause the entire definition to be skipped.</p>
|
||||
*
|
||||
* <p>Expected JSON format:
|
||||
* <pre>{@code
|
||||
* {
|
||||
* "type": "tiedup:bondage_item",
|
||||
* "display_name": "Leather Armbinder",
|
||||
* "translation_key": "item.tiedup.leather_armbinder",
|
||||
* "model": "tiedup:models/gltf/v2/armbinder/armbinder.glb",
|
||||
* "slim_model": "tiedup:models/gltf/v2/armbinder/armbinder_slim.glb",
|
||||
* "texture": "tiedup:textures/item/armbinder",
|
||||
* "animation_source": "tiedup:models/gltf/v2/armbinder/armbinder_anim.glb",
|
||||
* "regions": ["ARMS", "HANDS", "TORSO"],
|
||||
* "blocked_regions": ["ARMS", "HANDS", "TORSO", "FINGERS"],
|
||||
* "pose_type": "STANDARD",
|
||||
* "pose_priority": 50,
|
||||
* "escape_difficulty": 150,
|
||||
* "resistance_id": "armbinder",
|
||||
* "lockable": true,
|
||||
* "supports_color": false,
|
||||
* "color_variants": []
|
||||
* }
|
||||
* }</pre>
|
||||
*/
|
||||
public final class DataDrivenItemParser {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger("DataDrivenItems");
|
||||
|
||||
private DataDrivenItemParser() {}
|
||||
|
||||
/**
|
||||
* Parse a JSON input stream into a DataDrivenItemDefinition.
|
||||
*
|
||||
* @param input the JSON input stream
|
||||
* @param fileId the resource location of the file (for error messages)
|
||||
* @return the parsed definition, or null if the file is invalid
|
||||
*/
|
||||
@Nullable
|
||||
public static DataDrivenItemDefinition parse(InputStream input, ResourceLocation fileId) {
|
||||
try {
|
||||
JsonObject root = JsonParser.parseReader(
|
||||
new InputStreamReader(input, StandardCharsets.UTF_8)
|
||||
).getAsJsonObject();
|
||||
|
||||
return parseObject(root, fileId);
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("[DataDrivenItems] Failed to parse JSON {}: {}", fileId, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a JsonObject into a DataDrivenItemDefinition.
|
||||
*
|
||||
* @param root the parsed JSON object
|
||||
* @param fileId the resource location of the file (for error messages)
|
||||
* @return the parsed definition, or null if validation fails
|
||||
*/
|
||||
@Nullable
|
||||
public static DataDrivenItemDefinition parseObject(JsonObject root, ResourceLocation fileId) {
|
||||
// Validate type field
|
||||
String type = getStringOrNull(root, "type");
|
||||
if (!"tiedup:bondage_item".equals(type)) {
|
||||
LOGGER.error("[DataDrivenItems] Skipping {}: invalid or missing type '{}' (expected 'tiedup:bondage_item')",
|
||||
fileId, type);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Required: display_name
|
||||
String displayName = getStringOrNull(root, "display_name");
|
||||
if (displayName == null || displayName.isEmpty()) {
|
||||
LOGGER.error("[DataDrivenItems] Skipping {}: missing 'display_name'", fileId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Optional: translation_key
|
||||
String translationKey = getStringOrNull(root, "translation_key");
|
||||
|
||||
// Required: model
|
||||
String modelStr = getStringOrNull(root, "model");
|
||||
if (modelStr == null || modelStr.isEmpty()) {
|
||||
LOGGER.error("[DataDrivenItems] Skipping {}: missing 'model'", fileId);
|
||||
return null;
|
||||
}
|
||||
ResourceLocation modelLocation = ResourceLocation.tryParse(modelStr);
|
||||
if (modelLocation == null) {
|
||||
LOGGER.error("[DataDrivenItems] Skipping {}: invalid model ResourceLocation '{}'", fileId, modelStr);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Optional: slim_model
|
||||
ResourceLocation slimModelLocation = parseOptionalResourceLocation(root, "slim_model", fileId);
|
||||
|
||||
// Optional: texture
|
||||
ResourceLocation texturePath = parseOptionalResourceLocation(root, "texture", fileId);
|
||||
|
||||
// Optional: animation_source
|
||||
ResourceLocation animationSource = parseOptionalResourceLocation(root, "animation_source", fileId);
|
||||
|
||||
// Required: regions (non-empty)
|
||||
Set<BodyRegionV2> occupiedRegions = parseRegions(root, "regions", fileId);
|
||||
if (occupiedRegions == null || occupiedRegions.isEmpty()) {
|
||||
LOGGER.error("[DataDrivenItems] Skipping {}: missing or empty 'regions'", fileId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Optional: blocked_regions (defaults to regions)
|
||||
Set<BodyRegionV2> blockedRegions = parseRegions(root, "blocked_regions", fileId);
|
||||
if (blockedRegions == null || blockedRegions.isEmpty()) {
|
||||
blockedRegions = occupiedRegions;
|
||||
}
|
||||
|
||||
// Optional: pose_priority (default 0)
|
||||
int posePriority = getIntOrDefault(root, "pose_priority", 0);
|
||||
|
||||
// Optional: escape_difficulty (default 0)
|
||||
int escapeDifficulty = getIntOrDefault(root, "escape_difficulty", 0);
|
||||
|
||||
// Optional: lockable (default true)
|
||||
boolean lockable = getBooleanOrDefault(root, "lockable", true);
|
||||
|
||||
// Optional: supports_color (default false)
|
||||
boolean supportsColor = getBooleanOrDefault(root, "supports_color", false);
|
||||
|
||||
// Optional: tint_channels (default empty)
|
||||
Map<String, Integer> tintChannels = parseTintChannels(root, "tint_channels", fileId);
|
||||
|
||||
// Optional: icon (item model ResourceLocation for inventory sprite)
|
||||
ResourceLocation icon = parseOptionalResourceLocation(root, "icon", fileId);
|
||||
|
||||
// Optional: movement_style (requires valid MovementStyle name)
|
||||
MovementStyle movementStyle = null;
|
||||
String movementStyleStr = getStringOrNull(root, "movement_style");
|
||||
if (movementStyleStr != null && !movementStyleStr.isEmpty()) {
|
||||
movementStyle = MovementStyle.fromName(movementStyleStr);
|
||||
if (movementStyle == null) {
|
||||
LOGGER.warn("[DataDrivenItems] In {}: unknown movement_style '{}', ignoring",
|
||||
fileId, movementStyleStr);
|
||||
}
|
||||
}
|
||||
|
||||
// Optional: movement_modifier (requires movement_style to be set)
|
||||
MovementModifier movementModifier = null;
|
||||
if (movementStyle != null && root.has("movement_modifier") && root.get("movement_modifier").isJsonObject()) {
|
||||
JsonObject modObj = root.getAsJsonObject("movement_modifier");
|
||||
Float speedMul = getFloatOrNull(modObj, "speed_multiplier");
|
||||
Boolean jumpDis = getBooleanOrNull(modObj, "jump_disabled");
|
||||
if (speedMul != null || jumpDis != null) {
|
||||
movementModifier = new MovementModifier(speedMul, jumpDis);
|
||||
}
|
||||
} else if (movementStyle == null && root.has("movement_modifier")) {
|
||||
LOGGER.warn("[DataDrivenItems] In {}: movement_modifier ignored because movement_style is absent",
|
||||
fileId);
|
||||
}
|
||||
|
||||
// Required: animation_bones (per-animation bone whitelist)
|
||||
Map<String, Set<String>> animationBones = parseAnimationBones(root, fileId);
|
||||
if (animationBones == null) {
|
||||
LOGGER.error("[DataDrivenItems] Skipping {}: missing or invalid 'animation_bones'", fileId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build the item ID from the file path
|
||||
// fileId is like "tiedup:tiedup_items/leather_armbinder.json"
|
||||
// We want "tiedup:leather_armbinder"
|
||||
String idPath = fileId.getPath();
|
||||
// Strip "tiedup_items/" prefix
|
||||
if (idPath.startsWith("tiedup_items/")) {
|
||||
idPath = idPath.substring("tiedup_items/".length());
|
||||
}
|
||||
// Strip ".json" suffix
|
||||
if (idPath.endsWith(".json")) {
|
||||
idPath = idPath.substring(0, idPath.length() - 5);
|
||||
}
|
||||
ResourceLocation id = new ResourceLocation(fileId.getNamespace(), idPath);
|
||||
|
||||
return new DataDrivenItemDefinition(
|
||||
id, displayName, translationKey, modelLocation, slimModelLocation,
|
||||
texturePath, animationSource, occupiedRegions, blockedRegions,
|
||||
posePriority, escapeDifficulty,
|
||||
lockable, supportsColor, tintChannels, icon,
|
||||
movementStyle, movementModifier, animationBones
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Helper Methods =====
|
||||
|
||||
@Nullable
|
||||
private static String getStringOrNull(JsonObject obj, String key) {
|
||||
if (!obj.has(key) || obj.get(key).isJsonNull()) return null;
|
||||
try {
|
||||
return obj.get(key).getAsString();
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static int getIntOrDefault(JsonObject obj, String key, int defaultValue) {
|
||||
if (!obj.has(key) || obj.get(key).isJsonNull()) return defaultValue;
|
||||
try {
|
||||
return obj.get(key).getAsInt();
|
||||
} catch (Exception e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean getBooleanOrDefault(JsonObject obj, String key, boolean defaultValue) {
|
||||
if (!obj.has(key) || obj.get(key).isJsonNull()) return defaultValue;
|
||||
try {
|
||||
return obj.get(key).getAsBoolean();
|
||||
} catch (Exception e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static Float getFloatOrNull(JsonObject obj, String key) {
|
||||
if (!obj.has(key) || obj.get(key).isJsonNull()) return null;
|
||||
try {
|
||||
return obj.get(key).getAsFloat();
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static Boolean getBooleanOrNull(JsonObject obj, String key) {
|
||||
if (!obj.has(key) || obj.get(key).isJsonNull()) return null;
|
||||
try {
|
||||
return obj.get(key).getAsBoolean();
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static ResourceLocation parseOptionalResourceLocation(
|
||||
JsonObject obj, String key, ResourceLocation fileId
|
||||
) {
|
||||
String value = getStringOrNull(obj, key);
|
||||
if (value == null || value.isEmpty()) return null;
|
||||
ResourceLocation loc = ResourceLocation.tryParse(value);
|
||||
if (loc == null) {
|
||||
LOGGER.warn("[DataDrivenItems] In {}: invalid ResourceLocation for '{}': '{}'", fileId, key, value);
|
||||
}
|
||||
return loc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a JSON string array into an EnumSet of BodyRegionV2.
|
||||
* Unknown region names are logged as warnings and skipped.
|
||||
*/
|
||||
@Nullable
|
||||
private static Set<BodyRegionV2> parseRegions(JsonObject obj, String key, ResourceLocation fileId) {
|
||||
if (!obj.has(key) || !obj.get(key).isJsonArray()) return null;
|
||||
|
||||
JsonArray arr = obj.getAsJsonArray(key);
|
||||
if (arr.isEmpty()) return null;
|
||||
|
||||
EnumSet<BodyRegionV2> regions = EnumSet.noneOf(BodyRegionV2.class);
|
||||
for (JsonElement elem : arr) {
|
||||
try {
|
||||
String name = elem.getAsString().toUpperCase();
|
||||
BodyRegionV2 region = BodyRegionV2.fromName(name);
|
||||
if (region != null) {
|
||||
regions.add(region);
|
||||
} else {
|
||||
LOGGER.warn("[DataDrivenItems] In {}: unknown region '{}' in '{}', skipping",
|
||||
fileId, name, key);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("[DataDrivenItems] In {}: invalid element in '{}': {}",
|
||||
fileId, key, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return regions.isEmpty() ? null : Collections.unmodifiableSet(regions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a tint_channels JSON object mapping channel names to hex color strings.
|
||||
*
|
||||
* <p>Example JSON:
|
||||
* <pre>{@code
|
||||
* "tint_channels": {
|
||||
* "tintable_0": "#8B4513",
|
||||
* "tintable_1": "#FF0000"
|
||||
* }
|
||||
* }</pre>
|
||||
*
|
||||
* @param obj the parent JSON object
|
||||
* @param key the field name to parse
|
||||
* @param fileId the source file for error messages
|
||||
* @return an unmodifiable map of channel names to RGB ints, or empty map if absent
|
||||
*/
|
||||
private static Map<String, Integer> parseTintChannels(JsonObject obj, String key, ResourceLocation fileId) {
|
||||
if (!obj.has(key) || !obj.get(key).isJsonObject()) return Map.of();
|
||||
JsonObject channels = obj.getAsJsonObject(key);
|
||||
Map<String, Integer> result = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, JsonElement> entry : channels.entrySet()) {
|
||||
try {
|
||||
String hex = entry.getValue().getAsString();
|
||||
int color = Integer.parseInt(hex.startsWith("#") ? hex.substring(1) : hex, 16);
|
||||
result.put(entry.getKey(), color);
|
||||
} catch (NumberFormatException e) {
|
||||
LOGGER.warn("[DataDrivenItems] In {}: invalid hex color '{}' for tint channel '{}'",
|
||||
fileId, entry.getValue(), entry.getKey());
|
||||
}
|
||||
}
|
||||
return Collections.unmodifiableMap(result);
|
||||
}
|
||||
|
||||
/** Valid PlayerAnimator bone names for animation_bones validation. */
|
||||
private static final Set<String> VALID_BONE_NAMES = Set.of(
|
||||
"head", "body", "rightArm", "leftArm", "rightLeg", "leftLeg"
|
||||
);
|
||||
|
||||
/**
|
||||
* Parse the {@code animation_bones} JSON object.
|
||||
*
|
||||
* <p>Format:
|
||||
* <pre>{@code
|
||||
* "animation_bones": {
|
||||
* "idle": ["rightArm", "leftArm"],
|
||||
* "struggle": ["rightArm", "leftArm", "body"]
|
||||
* }
|
||||
* }</pre>
|
||||
*
|
||||
* <p>Each key is an animation name, each value is a JSON array of bone name strings.
|
||||
* Bone names are validated against the 6 PlayerAnimator parts. Invalid bone names
|
||||
* are logged as warnings and skipped. Empty arrays or unknown-only arrays cause the
|
||||
* entire animation entry to be skipped.</p>
|
||||
*
|
||||
* @param obj the parent JSON object
|
||||
* @param fileId the source file for error messages
|
||||
* @return unmodifiable map of animation name to bone set, or null if absent/invalid
|
||||
*/
|
||||
@Nullable
|
||||
private static Map<String, Set<String>> parseAnimationBones(JsonObject obj, ResourceLocation fileId) {
|
||||
if (!obj.has("animation_bones") || !obj.get("animation_bones").isJsonObject()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
JsonObject bonesObj = obj.getAsJsonObject("animation_bones");
|
||||
if (bonesObj.size() == 0) {
|
||||
LOGGER.error("[DataDrivenItems] In {}: 'animation_bones' is empty", fileId);
|
||||
return null;
|
||||
}
|
||||
|
||||
Map<String, Set<String>> result = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, JsonElement> entry : bonesObj.entrySet()) {
|
||||
String animName = entry.getKey();
|
||||
JsonElement value = entry.getValue();
|
||||
|
||||
if (!value.isJsonArray()) {
|
||||
LOGGER.warn("[DataDrivenItems] In {}: animation_bones['{}'] is not an array, skipping",
|
||||
fileId, animName);
|
||||
continue;
|
||||
}
|
||||
|
||||
JsonArray boneArray = value.getAsJsonArray();
|
||||
Set<String> bones = new HashSet<>();
|
||||
for (JsonElement boneElem : boneArray) {
|
||||
try {
|
||||
String boneName = boneElem.getAsString();
|
||||
if (VALID_BONE_NAMES.contains(boneName)) {
|
||||
bones.add(boneName);
|
||||
} else {
|
||||
LOGGER.warn("[DataDrivenItems] In {}: animation_bones['{}'] contains unknown bone '{}', skipping",
|
||||
fileId, animName, boneName);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("[DataDrivenItems] In {}: invalid element in animation_bones['{}']",
|
||||
fileId, animName);
|
||||
}
|
||||
}
|
||||
|
||||
if (!bones.isEmpty()) {
|
||||
result.put(animName, Collections.unmodifiableSet(bones));
|
||||
} else {
|
||||
LOGGER.warn("[DataDrivenItems] In {}: animation_bones['{}'] resolved to empty set, skipping",
|
||||
fileId, animName);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.isEmpty()) {
|
||||
LOGGER.error("[DataDrivenItems] In {}: 'animation_bones' has no valid entries", fileId);
|
||||
return null;
|
||||
}
|
||||
|
||||
return Collections.unmodifiableMap(result);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.tiedup.remake.v2.bondage.datadriven;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
/**
|
||||
* Thread-safe registry for data-driven bondage item definitions.
|
||||
*
|
||||
* <p>Populated by the reload listener that scans {@code tiedup_items/} JSON files.
|
||||
* Uses volatile atomic swap (same pattern as {@link
|
||||
* com.tiedup.remake.client.animation.context.ContextGlbRegistry}) to ensure
|
||||
* the render thread always sees a consistent snapshot.</p>
|
||||
*
|
||||
* <p>Lookup methods accept either a {@link ResourceLocation} ID directly
|
||||
* or an {@link ItemStack} (reads the {@code tiedup_item_id} NBT tag).</p>
|
||||
*/
|
||||
public final class DataDrivenItemRegistry {
|
||||
|
||||
/** NBT key storing the data-driven item ID on ItemStacks. */
|
||||
public static final String NBT_ITEM_ID = "tiedup_item_id";
|
||||
|
||||
/**
|
||||
* Volatile reference to an unmodifiable map. Reload builds a new map
|
||||
* and swaps atomically; consumer threads always see a consistent snapshot.
|
||||
*/
|
||||
private static volatile Map<ResourceLocation, DataDrivenItemDefinition> DEFINITIONS = Map.of();
|
||||
|
||||
private DataDrivenItemRegistry() {}
|
||||
|
||||
/**
|
||||
* Atomically replace all definitions with a new set.
|
||||
* Called by the reload listener after parsing all JSON files.
|
||||
*
|
||||
* @param newDefs the new definitions map (will be defensively copied)
|
||||
*/
|
||||
public static void reload(Map<ResourceLocation, DataDrivenItemDefinition> newDefs) {
|
||||
DEFINITIONS = Collections.unmodifiableMap(new HashMap<>(newDefs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically merge new definitions into the existing registry.
|
||||
*
|
||||
* <p>On an integrated server, both the client (assets/) and server (data/) reload
|
||||
* listeners populate this registry. Using {@link #reload} would cause the second
|
||||
* listener to overwrite the first's definitions. This method builds a new map
|
||||
* from the existing snapshot + the new entries, then swaps atomically.</p>
|
||||
*
|
||||
* @param newDefs the definitions to merge (will overwrite existing entries with same key)
|
||||
*/
|
||||
public static void mergeAll(Map<ResourceLocation, DataDrivenItemDefinition> newDefs) {
|
||||
Map<ResourceLocation, DataDrivenItemDefinition> merged = new HashMap<>(DEFINITIONS);
|
||||
merged.putAll(newDefs);
|
||||
DEFINITIONS = Collections.unmodifiableMap(merged);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a definition by its unique ID.
|
||||
*
|
||||
* @param id the definition ID (e.g., "tiedup:leather_armbinder")
|
||||
* @return the definition, or null if not found
|
||||
*/
|
||||
@Nullable
|
||||
public static DataDrivenItemDefinition get(ResourceLocation id) {
|
||||
return DEFINITIONS.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a definition from an ItemStack by reading the {@code tiedup_item_id} NBT tag.
|
||||
*
|
||||
* @param stack the ItemStack to inspect
|
||||
* @return the definition, or null if the stack is empty, has no tag, or the ID is unknown
|
||||
*/
|
||||
@Nullable
|
||||
public static DataDrivenItemDefinition get(ItemStack stack) {
|
||||
if (stack.isEmpty()) return null;
|
||||
CompoundTag tag = stack.getTag();
|
||||
if (tag == null || !tag.contains(NBT_ITEM_ID)) return null;
|
||||
ResourceLocation id = ResourceLocation.tryParse(tag.getString(NBT_ITEM_ID));
|
||||
if (id == null) return null;
|
||||
return DEFINITIONS.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered definitions.
|
||||
*
|
||||
* @return unmodifiable collection of all definitions
|
||||
*/
|
||||
public static Collection<DataDrivenItemDefinition> getAll() {
|
||||
return DEFINITIONS.values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all definitions. Called on world unload or for testing.
|
||||
*/
|
||||
public static void clear() {
|
||||
DEFINITIONS = Map.of();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.tiedup.remake.v2.bondage.datadriven;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.server.packs.resources.Resource;
|
||||
import net.minecraft.server.packs.resources.ResourceManager;
|
||||
import net.minecraft.server.packs.resources.SimplePreparableReloadListener;
|
||||
import net.minecraft.util.profiling.ProfilerFiller;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
/**
|
||||
* Resource reload listener that scans {@code assets/<namespace>/tiedup_items/}
|
||||
* for JSON files and populates the {@link DataDrivenItemRegistry}.
|
||||
*
|
||||
* <p>Registered via {@link net.minecraftforge.client.event.RegisterClientReloadListenersEvent}
|
||||
* in {@link com.tiedup.remake.client.gltf.GltfClientSetup}.</p>
|
||||
*
|
||||
* <p>Follows the same pattern as {@link com.tiedup.remake.client.animation.context.ContextGlbRegistry}:
|
||||
* prepare phase is a no-op, apply phase scans + parses + atomic-swaps the registry.</p>
|
||||
*/
|
||||
public class DataDrivenItemReloadListener extends SimplePreparableReloadListener<Void> {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger("DataDrivenItems");
|
||||
|
||||
/** Resource directory containing item definition JSON files. */
|
||||
private static final String DIRECTORY = "tiedup_items";
|
||||
|
||||
@Override
|
||||
protected Void prepare(ResourceManager resourceManager, ProfilerFiller profiler) {
|
||||
// No preparation needed — parsing happens in apply phase
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void apply(Void nothing, ResourceManager resourceManager, ProfilerFiller profiler) {
|
||||
Map<ResourceLocation, DataDrivenItemDefinition> newDefs = new HashMap<>();
|
||||
|
||||
Map<ResourceLocation, Resource> resources = resourceManager.listResources(
|
||||
DIRECTORY, loc -> loc.getPath().endsWith(".json")
|
||||
);
|
||||
|
||||
int skipped = 0;
|
||||
|
||||
for (Map.Entry<ResourceLocation, Resource> entry : resources.entrySet()) {
|
||||
ResourceLocation fileId = entry.getKey();
|
||||
Resource resource = entry.getValue();
|
||||
|
||||
try (InputStream input = resource.open()) {
|
||||
DataDrivenItemDefinition def = DataDrivenItemParser.parse(input, fileId);
|
||||
|
||||
if (def != null) {
|
||||
// Check for duplicate IDs
|
||||
if (newDefs.containsKey(def.id())) {
|
||||
LOGGER.warn("[DataDrivenItems] Duplicate item ID '{}' from file '{}' — overwriting previous definition",
|
||||
def.id(), fileId);
|
||||
}
|
||||
|
||||
newDefs.put(def.id(), def);
|
||||
LOGGER.debug("[DataDrivenItems] Loaded: {} -> '{}'", def.id(), def.displayName());
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("[DataDrivenItems] Failed to read resource {}: {}", fileId, e.getMessage());
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
// Merge into the registry (not replace) so the server listener doesn't
|
||||
// overwrite client-only definitions on integrated server
|
||||
DataDrivenItemRegistry.mergeAll(newDefs);
|
||||
|
||||
LOGGER.info("[DataDrivenItems] Loaded {} item definitions ({} skipped) from {} JSON files",
|
||||
newDefs.size(), skipped, resources.size());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.tiedup.remake.v2.bondage.datadriven;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.server.packs.resources.Resource;
|
||||
import net.minecraft.server.packs.resources.ResourceManager;
|
||||
import net.minecraft.server.packs.resources.SimplePreparableReloadListener;
|
||||
import net.minecraft.util.profiling.ProfilerFiller;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
/**
|
||||
* Server-side resource reload listener that scans {@code data/<namespace>/tiedup_items/}
|
||||
* for JSON files and populates the {@link DataDrivenItemRegistry}.
|
||||
*
|
||||
* <p>This is the server counterpart to {@link DataDrivenItemReloadListener} (client-side,
|
||||
* which scans {@code assets/}). On a dedicated server, only this listener runs.
|
||||
* On an integrated server (singleplayer), both listeners run -- the last one to apply
|
||||
* wins the atomic swap, but they parse identical JSON content so the result is the same.</p>
|
||||
*
|
||||
* <p>Registered via {@link net.minecraftforge.event.AddReloadListenerEvent} in
|
||||
* {@link com.tiedup.remake.core.TiedUpMod.ForgeEvents}.</p>
|
||||
*/
|
||||
public class DataDrivenItemServerReloadListener extends SimplePreparableReloadListener<Void> {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger("DataDrivenItems");
|
||||
|
||||
/** Resource directory containing item definition JSON files (under data/). */
|
||||
private static final String DIRECTORY = "tiedup_items";
|
||||
|
||||
@Override
|
||||
protected Void prepare(ResourceManager resourceManager, ProfilerFiller profiler) {
|
||||
// No preparation needed -- parsing happens in apply phase
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void apply(Void nothing, ResourceManager resourceManager, ProfilerFiller profiler) {
|
||||
Map<ResourceLocation, DataDrivenItemDefinition> newDefs = new HashMap<>();
|
||||
|
||||
Map<ResourceLocation, Resource> resources = resourceManager.listResources(
|
||||
DIRECTORY, loc -> loc.getPath().endsWith(".json")
|
||||
);
|
||||
|
||||
int skipped = 0;
|
||||
|
||||
for (Map.Entry<ResourceLocation, Resource> entry : resources.entrySet()) {
|
||||
ResourceLocation fileId = entry.getKey();
|
||||
Resource resource = entry.getValue();
|
||||
|
||||
try (InputStream input = resource.open()) {
|
||||
DataDrivenItemDefinition def = DataDrivenItemParser.parse(input, fileId);
|
||||
|
||||
if (def != null) {
|
||||
// Check for duplicate IDs
|
||||
if (newDefs.containsKey(def.id())) {
|
||||
LOGGER.warn("[DataDrivenItems] Server: Duplicate item ID '{}' from file '{}' -- overwriting previous definition",
|
||||
def.id(), fileId);
|
||||
}
|
||||
|
||||
newDefs.put(def.id(), def);
|
||||
LOGGER.debug("[DataDrivenItems] Server loaded: {} -> '{}'", def.id(), def.displayName());
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("[DataDrivenItems] Server: Failed to read resource {}: {}", fileId, e.getMessage());
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
// Merge into the registry (not replace) so the client listener's
|
||||
// definitions aren't overwritten on integrated server
|
||||
DataDrivenItemRegistry.mergeAll(newDefs);
|
||||
|
||||
LOGGER.info("[DataDrivenItems] Server loaded {} item definitions ({} skipped) from {} JSON files",
|
||||
newDefs.size(), skipped, resources.size());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package com.tiedup.remake.v2.bondage.items;
|
||||
|
||||
import com.tiedup.remake.items.base.IHasResistance;
|
||||
import com.tiedup.remake.items.base.ILockable;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import com.tiedup.remake.v2.bondage.IV2BondageItem;
|
||||
import com.tiedup.remake.v2.bondage.V2EquipResult;
|
||||
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
|
||||
import java.util.List;
|
||||
import net.minecraft.ChatFormatting;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.world.InteractionHand;
|
||||
import net.minecraft.world.InteractionResult;
|
||||
import net.minecraft.world.InteractionResultHolder;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.Item;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.TooltipFlag;
|
||||
import net.minecraft.world.level.Level;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
/**
|
||||
* Base class for V2 bondage items.
|
||||
*
|
||||
* Provides:
|
||||
* - Self-equip via right-click in air (use())
|
||||
* - Equip on target via right-click on entity (interactLivingEntity())
|
||||
* - Lock-aware canUnequip() bridging IV2BondageItem and ILockable
|
||||
* - Lock/resistance tooltips
|
||||
*
|
||||
* Subclasses implement: getOccupiedRegions(), getModelLocation(), getPosePriority(),
|
||||
* getResistanceId(), notifyStruggle().
|
||||
*/
|
||||
public abstract class AbstractV2BondageItem extends Item
|
||||
implements IV2BondageItem, ILockable, IHasResistance {
|
||||
|
||||
protected AbstractV2BondageItem(Properties properties) {
|
||||
super(properties);
|
||||
}
|
||||
|
||||
// ===== EQUIP: SELF (left-click hold with tying duration) =====
|
||||
// Self-equip is handled by SelfBondageInputHandler (left-click hold) which sends
|
||||
// PacketSelfBondage, routed to handleV2SelfBondage() with tying progress bar.
|
||||
// Right-click in air does nothing for self-equip — consistent with V1 behavior.
|
||||
|
||||
@Override
|
||||
public InteractionResultHolder<ItemStack> use(Level level, Player player, InteractionHand hand) {
|
||||
return InteractionResultHolder.pass(player.getItemInHand(hand));
|
||||
}
|
||||
|
||||
// ===== EQUIP: ON TARGET (right-click on entity) =====
|
||||
|
||||
@Override
|
||||
public InteractionResult interactLivingEntity(
|
||||
ItemStack stack, Player player, LivingEntity target, InteractionHand hand
|
||||
) {
|
||||
// Client returns SUCCESS for arm swing animation. Server may reject —
|
||||
// minor visual desync is accepted Forge pattern (same as vanilla food/bow).
|
||||
if (player.level().isClientSide) {
|
||||
return InteractionResult.SUCCESS;
|
||||
}
|
||||
|
||||
// Cannot equip if player's arms are restrained
|
||||
if (V2EquipmentHelper.isRegionOccupied(player, BodyRegionV2.ARMS)) {
|
||||
return InteractionResult.PASS;
|
||||
}
|
||||
|
||||
// Distance + line-of-sight validation
|
||||
if (player.distanceTo(target) > 4.0 || !player.hasLineOfSight(target)) {
|
||||
return InteractionResult.PASS;
|
||||
}
|
||||
|
||||
V2EquipResult result = V2EquipmentHelper.equipItem(target, stack);
|
||||
if (result.isSuccess()) {
|
||||
// Drop displaced items at target's feet
|
||||
for (ItemStack displaced : result.displaced()) {
|
||||
target.spawnAtLocation(displaced);
|
||||
}
|
||||
stack.shrink(1);
|
||||
return InteractionResult.SUCCESS;
|
||||
}
|
||||
return InteractionResult.PASS;
|
||||
}
|
||||
|
||||
// ===== LOCK-AWARE CANUNEQUIP =====
|
||||
|
||||
@Override
|
||||
public boolean canUnequip(ItemStack stack, LivingEntity entity) {
|
||||
return !isLocked(stack);
|
||||
}
|
||||
|
||||
// ===== TOOLTIPS =====
|
||||
|
||||
@Override
|
||||
public void appendHoverText(
|
||||
ItemStack stack, @Nullable Level level, List<Component> tooltip, TooltipFlag flag
|
||||
) {
|
||||
super.appendHoverText(stack, level, tooltip, flag);
|
||||
// Lock status from ILockable
|
||||
appendLockTooltip(stack, tooltip);
|
||||
// Escape difficulty
|
||||
int difficulty = getEscapeDifficulty(stack);
|
||||
if (difficulty > 0) {
|
||||
tooltip.add(Component.translatable("item.tiedup.tooltip.escape_difficulty", difficulty)
|
||||
.withStyle(ChatFormatting.GRAY));
|
||||
}
|
||||
}
|
||||
|
||||
// ===== IV2BondageItem DEFAULTS =====
|
||||
|
||||
@Override
|
||||
public int getEscapeDifficulty() { return 0; }
|
||||
|
||||
@Override
|
||||
public boolean supportsColor() { return false; }
|
||||
|
||||
@Override
|
||||
public boolean supportsSlimModel() { return false; }
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.tiedup.remake.v2.bondage.items;
|
||||
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import java.util.Collections;
|
||||
import java.util.EnumSet;
|
||||
import java.util.Set;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
|
||||
/**
|
||||
* V2 Handcuffs — first V2 bondage item.
|
||||
*
|
||||
* Occupies ARMS only. Mittens (HANDS) can coexist on top.
|
||||
* Uses existing cuffs_prototype.glb for 3D rendering.
|
||||
*/
|
||||
public class V2Handcuffs extends AbstractV2BondageItem {
|
||||
|
||||
private static final Set<BodyRegionV2> REGIONS =
|
||||
Collections.unmodifiableSet(EnumSet.of(BodyRegionV2.ARMS));
|
||||
|
||||
private static final ResourceLocation MODEL = new ResourceLocation(
|
||||
TiedUpMod.MOD_ID, "models/gltf/v2/handcuffs/cuffs_prototype.glb"
|
||||
);
|
||||
|
||||
public V2Handcuffs() {
|
||||
super(new Properties().stacksTo(1));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<BodyRegionV2> getOccupiedRegions() { return REGIONS; }
|
||||
|
||||
@Override
|
||||
public ResourceLocation getModelLocation() { return MODEL; }
|
||||
|
||||
@Override
|
||||
public int getPosePriority() { return 30; }
|
||||
|
||||
@Override
|
||||
public int getEscapeDifficulty() { return 100; }
|
||||
|
||||
@Override
|
||||
public String getResistanceId() { return "handcuffs"; }
|
||||
|
||||
@Override
|
||||
public void notifyStruggle(LivingEntity entity) {
|
||||
entity.level().playSound(
|
||||
null, entity.getX(), entity.getY(), entity.getZ(),
|
||||
net.minecraft.sounds.SoundEvents.CHAIN_STEP,
|
||||
net.minecraft.sounds.SoundSource.PLAYERS,
|
||||
0.4f, 1.0f
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.tiedup.remake.v2.bondage.movement;
|
||||
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
/**
|
||||
* Optional per-item overrides for movement style defaults.
|
||||
* Parsed from the {@code "movement_modifier"} JSON object.
|
||||
*
|
||||
* <p>Null fields fall back to the style's defaults. Only the winning item's
|
||||
* modifier is used (lower-severity items' modifiers are ignored).</p>
|
||||
*
|
||||
* <p>Requires a {@code movement_style} to be set on the same item definition.
|
||||
* The parser ignores {@code movement_modifier} if {@code movement_style} is absent.</p>
|
||||
*/
|
||||
public record MovementModifier(
|
||||
/** Override speed multiplier, or null to use style default. */
|
||||
@Nullable Float speedMultiplier,
|
||||
|
||||
/** Override jump disabled flag, or null to use style default. */
|
||||
@Nullable Boolean jumpDisabled
|
||||
) {}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.tiedup.remake.v2.bondage.movement;
|
||||
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
|
||||
/**
|
||||
* Movement styles that change how a bound player physically moves.
|
||||
* Each style has a severity (higher = more constraining), default speed multiplier,
|
||||
* and default jump-disabled flag.
|
||||
*
|
||||
* <p>When multiple styled items are worn, the style with the highest severity wins.
|
||||
* If two items share the same severity, the item on the region with the lowest
|
||||
* {@link com.tiedup.remake.v2.BodyRegionV2#ordinal()} wins.</p>
|
||||
*
|
||||
* <p>This enum is shared (server + client). It does NOT contain handler references
|
||||
* to avoid pulling server-only classes into client code.</p>
|
||||
*/
|
||||
public enum MovementStyle {
|
||||
|
||||
/** Swaying side-to-side gait, visual zigzag via animation. Jump allowed. */
|
||||
WADDLE(1, 0.6f, false),
|
||||
|
||||
/** Tiny dragging steps, heavy speed reduction. Jump disabled. */
|
||||
SHUFFLE(2, 0.4f, true),
|
||||
|
||||
/** Automatic small hops when moving forward. Jump disabled (auto-hop replaces it). */
|
||||
HOP(3, 0.35f, true),
|
||||
|
||||
/** On all fours, swim-like hitbox (0.6 high). Jump disabled. */
|
||||
CRAWL(4, 0.2f, true);
|
||||
|
||||
private final int severity;
|
||||
private final float defaultSpeedMultiplier;
|
||||
private final boolean defaultJumpDisabled;
|
||||
|
||||
MovementStyle(int severity, float defaultSpeedMultiplier, boolean defaultJumpDisabled) {
|
||||
this.severity = severity;
|
||||
this.defaultSpeedMultiplier = defaultSpeedMultiplier;
|
||||
this.defaultJumpDisabled = defaultJumpDisabled;
|
||||
}
|
||||
|
||||
/** Higher severity = more constraining. Used for resolution tiebreaking. */
|
||||
public int getSeverity() {
|
||||
return severity;
|
||||
}
|
||||
|
||||
/** Default speed multiplier (0.0-1.0) applied via MULTIPLY_BASE AttributeModifier. */
|
||||
public float getDefaultSpeedMultiplier() {
|
||||
return defaultSpeedMultiplier;
|
||||
}
|
||||
|
||||
/** Whether jumping is disabled by default for this style. */
|
||||
public boolean isDefaultJumpDisabled() {
|
||||
return defaultJumpDisabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe valueOf that returns null instead of throwing on unknown names.
|
||||
*
|
||||
* @param name the style name (case-insensitive)
|
||||
* @return the style, or null if not recognized
|
||||
*/
|
||||
@Nullable
|
||||
public static MovementStyle fromName(String name) {
|
||||
if (name == null) return null;
|
||||
try {
|
||||
return valueOf(name.toUpperCase());
|
||||
} catch (IllegalArgumentException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
package com.tiedup.remake.v2.bondage.movement;
|
||||
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
import com.tiedup.remake.network.ModNetwork;
|
||||
import com.tiedup.remake.network.sync.PacketSyncMovementStyle;
|
||||
import com.tiedup.remake.state.PlayerBindState;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import com.tiedup.remake.v2.bondage.IV2BondageEquipment;
|
||||
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import net.minecraft.network.protocol.game.ClientboundSetEntityMotionPacket;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.effect.MobEffects;
|
||||
import net.minecraft.world.entity.EntityDimensions;
|
||||
import net.minecraft.world.entity.Pose;
|
||||
import net.minecraft.world.entity.ai.attributes.AttributeInstance;
|
||||
import net.minecraft.world.entity.ai.attributes.AttributeModifier;
|
||||
import net.minecraft.world.entity.ai.attributes.Attributes;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
import net.minecraftforge.event.TickEvent;
|
||||
import net.minecraftforge.event.entity.living.LivingEvent;
|
||||
import net.minecraftforge.eventbus.api.EventPriority;
|
||||
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||
import net.minecraftforge.fml.common.Mod;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
/**
|
||||
* Server-side manager for movement style mechanics.
|
||||
*
|
||||
* <p>Hooks into two events:
|
||||
* <ul>
|
||||
* <li>{@code PlayerTickEvent(Phase.END)} at HIGH priority -- resolves style,
|
||||
* manages lifecycle transitions, dispatches per-style tick logic. Runs after
|
||||
* vanilla {@code travel()} so velocity modifications apply correctly.</li>
|
||||
* <li>{@code LivingJumpEvent} -- suppresses jump for styles with jump disabled.
|
||||
* {@code LivingJumpEvent} is NOT cancelable; jump is neutralized by subtracting
|
||||
* the jump impulse from Y velocity.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Per-player state lives on {@link PlayerBindState} to piggyback on existing
|
||||
* lifecycle cleanup hooks (death, logout, dimension change).</p>
|
||||
*
|
||||
* @see MovementStyleResolver for resolution logic
|
||||
* @see MovementStyle for style definitions
|
||||
*/
|
||||
@Mod.EventBusSubscriber(
|
||||
modid = TiedUpMod.MOD_ID,
|
||||
bus = Mod.EventBusSubscriber.Bus.FORGE
|
||||
)
|
||||
public class MovementStyleManager {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger("MovementStyles");
|
||||
|
||||
// --- V1 legacy modifier UUID (H6 cleanup) ---
|
||||
// Source of truth: RestraintEffectUtils.BIND_SPEED_MODIFIER_UUID (same value).
|
||||
// RestraintEffectUtils used this UUID with ADDITION operation and addPermanentModifier().
|
||||
// Players upgrading from V1 may still have this modifier saved in their NBT.
|
||||
// Removed on tick to prevent double stacking with V2 MULTIPLY_BASE modifiers.
|
||||
private static final UUID V1_BIND_SPEED_MODIFIER_UUID =
|
||||
UUID.fromString("7f3c7c8e-9d4e-4c7a-8e5f-1a2b3c4d5e6f");
|
||||
|
||||
// --- Unique UUIDs for AttributeModifiers (one per style to allow clean removal) ---
|
||||
private static final UUID WADDLE_SPEED_UUID =
|
||||
UUID.fromString("d7a1c001-0000-0000-0000-000000000001");
|
||||
private static final UUID SHUFFLE_SPEED_UUID =
|
||||
UUID.fromString("d7a1c001-0000-0000-0000-000000000002");
|
||||
private static final UUID HOP_SPEED_UUID =
|
||||
UUID.fromString("d7a1c001-0000-0000-0000-000000000003");
|
||||
private static final UUID CRAWL_SPEED_UUID =
|
||||
UUID.fromString("d7a1c001-0000-0000-0000-000000000004");
|
||||
|
||||
// --- Hop tuning constants ---
|
||||
private static final double HOP_Y_IMPULSE = 0.28;
|
||||
private static final double HOP_FORWARD_IMPULSE = 0.18;
|
||||
private static final int HOP_COOLDOWN_TICKS = 10;
|
||||
private static final int HOP_STARTUP_DELAY_TICKS = 4;
|
||||
|
||||
// --- Movement detection threshold (squared distance) ---
|
||||
private static final double MOVEMENT_THRESHOLD_SQ = 0.001;
|
||||
|
||||
// --- Number of consecutive non-moving ticks before hop startup resets ---
|
||||
private static final int HOP_STARTUP_RESET_TICKS = 2;
|
||||
|
||||
// ==================== Tick Event ====================
|
||||
|
||||
/**
|
||||
* Per-tick movement style processing. Runs at HIGH priority at Phase.END
|
||||
* so it executes before {@code BondageItemRestrictionHandler} (default priority).
|
||||
*
|
||||
* <p>Tick flow:
|
||||
* <ol>
|
||||
* <li>Skip conditions: passenger, dead, struggling</li>
|
||||
* <li>Pending pose restore (crawl deactivated but can't stand yet)</li>
|
||||
* <li>Resolve current style from equipped items</li>
|
||||
* <li>Compare with active style, handle transitions</li>
|
||||
* <li>Dispatch to style-specific tick (unless on ladder)</li>
|
||||
* <li>Update last position for next tick's movement detection</li>
|
||||
* </ol>
|
||||
*/
|
||||
@SubscribeEvent(priority = EventPriority.HIGH)
|
||||
public static void onPlayerTick(TickEvent.PlayerTickEvent event) {
|
||||
if (event.side.isClient() || event.phase != TickEvent.Phase.END) {
|
||||
return;
|
||||
}
|
||||
if (!(event.player instanceof ServerPlayer player)) {
|
||||
return;
|
||||
}
|
||||
|
||||
PlayerBindState state = PlayerBindState.getInstance(player);
|
||||
if (state == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Skip conditions ---
|
||||
// Update last position even when suspended to prevent false movement
|
||||
// detection on resume (e.g., teleport while riding)
|
||||
if (player.isPassenger() || player.isDeadOrDying() || state.isStruggling()) {
|
||||
state.lastX = player.getX();
|
||||
state.lastY = player.getY();
|
||||
state.lastZ = player.getZ();
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Pending pose restore (crawl deactivated but can't stand) ---
|
||||
if (state.pendingPoseRestore) {
|
||||
tryRestoreStandingPose(player, state);
|
||||
}
|
||||
|
||||
// --- H6: Remove stale V1 permanent modifier if present ---
|
||||
// Players upgrading from V1 may have a permanent ADDITION modifier saved in NBT.
|
||||
// This one-time cleanup prevents double stacking with the V2 MULTIPLY_BASE modifier.
|
||||
cleanupV1Modifier(player);
|
||||
|
||||
// --- Resolve current style from equipped items ---
|
||||
IV2BondageEquipment equipment = V2EquipmentHelper.getEquipment(player);
|
||||
Map<BodyRegionV2, ItemStack> equipped = equipment != null
|
||||
? equipment.getAllEquipped() : Map.of();
|
||||
ResolvedMovement resolved = MovementStyleResolver.resolve(equipped);
|
||||
|
||||
// --- Compare with current active style ---
|
||||
MovementStyle newStyle = resolved.style();
|
||||
MovementStyle oldStyle = state.getActiveMovementStyle();
|
||||
|
||||
if (newStyle != oldStyle) {
|
||||
// Style changed: deactivate old, activate new
|
||||
if (oldStyle != null) {
|
||||
onDeactivate(player, state, oldStyle);
|
||||
}
|
||||
if (newStyle != null) {
|
||||
state.setResolvedMovementSpeed(resolved.speedMultiplier());
|
||||
state.setResolvedJumpDisabled(resolved.jumpDisabled());
|
||||
onActivate(player, state, newStyle);
|
||||
} else {
|
||||
state.setResolvedMovementSpeed(1.0f);
|
||||
state.setResolvedJumpDisabled(false);
|
||||
}
|
||||
state.setActiveMovementStyle(newStyle);
|
||||
|
||||
// Sync to all tracking clients (animation + crawl pose)
|
||||
ModNetwork.sendToAllTrackingAndSelf(
|
||||
new PacketSyncMovementStyle(player.getUUID(), newStyle), player);
|
||||
}
|
||||
|
||||
// --- Per-style tick ---
|
||||
if (state.getActiveMovementStyle() != null) {
|
||||
// Ladder suspension: skip style tick when on ladder
|
||||
// (ladder movement is controlled by BondageItemRestrictionHandler)
|
||||
if (player.onClimbable()) {
|
||||
state.lastX = player.getX();
|
||||
state.lastY = player.getY();
|
||||
state.lastZ = player.getZ();
|
||||
return;
|
||||
}
|
||||
|
||||
tickStyle(player, state);
|
||||
}
|
||||
|
||||
// Update last position for next tick's movement detection
|
||||
state.lastX = player.getX();
|
||||
state.lastY = player.getY();
|
||||
state.lastZ = player.getZ();
|
||||
}
|
||||
|
||||
// ==================== Jump Suppression ====================
|
||||
|
||||
/**
|
||||
* Suppress jumps for styles with jump disabled.
|
||||
*
|
||||
* <p>{@code LivingJumpEvent} is NOT cancelable. Standard approach: subtract
|
||||
* the known jump impulse from Y velocity, preserving knockback and other
|
||||
* sources of Y motion.</p>
|
||||
*
|
||||
* <p>A {@link ClientboundSetEntityMotionPacket} is sent to minimize the
|
||||
* client-side 1-frame bounce artifact.</p>
|
||||
*/
|
||||
@SubscribeEvent
|
||||
public static void onLivingJump(LivingEvent.LivingJumpEvent event) {
|
||||
if (!(event.getEntity() instanceof ServerPlayer player)) {
|
||||
return;
|
||||
}
|
||||
|
||||
PlayerBindState state = PlayerBindState.getInstance(player);
|
||||
if (state == null || !state.isResolvedJumpDisabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Subtract vanilla jump impulse, preserving other Y velocity (knockback, etc.)
|
||||
// Vanilla: jumpPower = 0.42 + (amplifier + 1) * 0.1 = 0.42 * factor
|
||||
double jumpVelocity = 0.42 * getJumpBoostFactor(player);
|
||||
Vec3 motion = player.getDeltaMovement();
|
||||
player.setDeltaMovement(motion.x, motion.y - jumpVelocity, motion.z);
|
||||
|
||||
// Sync to client to minimize visual bounce artifact
|
||||
player.connection.send(new ClientboundSetEntityMotionPacket(player));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the Jump Boost potion factor.
|
||||
* Vanilla adds {@code (amplifier + 1) * 0.1} to the base 0.42 jump height.
|
||||
* We express this as a multiplicative factor on 0.42 for clean subtraction.
|
||||
*
|
||||
* @return 1.0 with no Jump Boost, higher with Jump Boost active
|
||||
*/
|
||||
private static double getJumpBoostFactor(Player player) {
|
||||
var jumpBoost = player.getEffect(MobEffects.JUMP);
|
||||
if (jumpBoost != null) {
|
||||
return 1.0 + (jumpBoost.getAmplifier() + 1) * 0.1 / 0.42;
|
||||
}
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
// ==================== Lifecycle ====================
|
||||
|
||||
private static void onActivate(ServerPlayer player, PlayerBindState state,
|
||||
MovementStyle style) {
|
||||
switch (style) {
|
||||
case WADDLE -> activateWaddle(player, state);
|
||||
case SHUFFLE -> activateShuffle(player, state);
|
||||
case HOP -> activateHop(player, state);
|
||||
case CRAWL -> activateCrawl(player, state);
|
||||
}
|
||||
}
|
||||
|
||||
private static void onDeactivate(ServerPlayer player, PlayerBindState state,
|
||||
MovementStyle style) {
|
||||
switch (style) {
|
||||
case WADDLE -> deactivateWaddle(player, state);
|
||||
case SHUFFLE -> deactivateShuffle(player, state);
|
||||
case HOP -> deactivateHop(player, state);
|
||||
case CRAWL -> deactivateCrawl(player, state);
|
||||
}
|
||||
}
|
||||
|
||||
private static void tickStyle(ServerPlayer player, PlayerBindState state) {
|
||||
switch (state.getActiveMovementStyle()) {
|
||||
case WADDLE -> tickWaddle(player, state);
|
||||
case SHUFFLE -> tickShuffle(player, state);
|
||||
case HOP -> tickHop(player, state);
|
||||
case CRAWL -> tickCrawl(player, state);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Waddle ====================
|
||||
|
||||
private static void activateWaddle(ServerPlayer player, PlayerBindState state) {
|
||||
applySpeedModifier(player, WADDLE_SPEED_UUID, "tiedup.waddle_speed",
|
||||
state.getResolvedMovementSpeed());
|
||||
}
|
||||
|
||||
private static void deactivateWaddle(ServerPlayer player, PlayerBindState state) {
|
||||
removeSpeedModifier(player, WADDLE_SPEED_UUID);
|
||||
}
|
||||
|
||||
private static void tickWaddle(ServerPlayer player, PlayerBindState state) {
|
||||
// Waddle is animation-only on the server. No velocity manipulation.
|
||||
// The visual zigzag is handled by the context animation on the client.
|
||||
}
|
||||
|
||||
// ==================== Shuffle ====================
|
||||
|
||||
private static void activateShuffle(ServerPlayer player, PlayerBindState state) {
|
||||
applySpeedModifier(player, SHUFFLE_SPEED_UUID, "tiedup.shuffle_speed",
|
||||
state.getResolvedMovementSpeed());
|
||||
}
|
||||
|
||||
private static void deactivateShuffle(ServerPlayer player, PlayerBindState state) {
|
||||
removeSpeedModifier(player, SHUFFLE_SPEED_UUID);
|
||||
}
|
||||
|
||||
private static void tickShuffle(ServerPlayer player, PlayerBindState state) {
|
||||
// Shuffle: speed reduction via attribute is sufficient. No per-tick work.
|
||||
}
|
||||
|
||||
// ==================== Hop ====================
|
||||
|
||||
private static void activateHop(ServerPlayer player, PlayerBindState state) {
|
||||
// Apply base speed reduction (~15% base speed between hops)
|
||||
applySpeedModifier(player, HOP_SPEED_UUID, "tiedup.hop_speed",
|
||||
state.getResolvedMovementSpeed());
|
||||
state.hopCooldown = 0;
|
||||
state.hopStartupPending = true;
|
||||
state.hopStartupTicks = HOP_STARTUP_DELAY_TICKS;
|
||||
}
|
||||
|
||||
private static void deactivateHop(ServerPlayer player, PlayerBindState state) {
|
||||
removeSpeedModifier(player, HOP_SPEED_UUID);
|
||||
state.hopCooldown = 0;
|
||||
state.hopStartupPending = false;
|
||||
state.hopStartupTicks = 0;
|
||||
state.hopNotMovingTicks = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hop tick logic:
|
||||
* <ul>
|
||||
* <li>Detect movement via position delta (not player.zza/xxa)</li>
|
||||
* <li>If moving + on ground + cooldown expired: execute hop (with startup delay on first hop)</li>
|
||||
* <li>If not moving for >= 2 ticks: reset startup pending</li>
|
||||
* <li>Decrement cooldown each tick</li>
|
||||
* </ul>
|
||||
*/
|
||||
private static void tickHop(ServerPlayer player, PlayerBindState state) {
|
||||
boolean isMoving = player.distanceToSqr(state.lastX, state.lastY, state.lastZ)
|
||||
> MOVEMENT_THRESHOLD_SQ;
|
||||
|
||||
// Decrement cooldown
|
||||
if (state.hopCooldown > 0) {
|
||||
state.hopCooldown--;
|
||||
}
|
||||
|
||||
if (isMoving && player.onGround() && state.hopCooldown <= 0) {
|
||||
if (state.hopStartupPending) {
|
||||
// Startup delay: decrement and wait (latched: completes even if
|
||||
// player briefly releases input during these 4 ticks)
|
||||
state.hopStartupTicks--;
|
||||
if (state.hopStartupTicks <= 0) {
|
||||
// Startup complete: execute first hop
|
||||
state.hopStartupPending = false;
|
||||
executeHop(player, state);
|
||||
}
|
||||
} else {
|
||||
// Normal hop
|
||||
executeHop(player, state);
|
||||
}
|
||||
state.hopNotMovingTicks = 0;
|
||||
} else if (!isMoving) {
|
||||
state.hopNotMovingTicks++;
|
||||
// Reset startup if not moving for >= 2 consecutive ticks
|
||||
if (state.hopNotMovingTicks >= HOP_STARTUP_RESET_TICKS
|
||||
&& !state.hopStartupPending) {
|
||||
state.hopStartupPending = true;
|
||||
state.hopStartupTicks = HOP_STARTUP_DELAY_TICKS;
|
||||
}
|
||||
} else {
|
||||
// Moving but not on ground or cooldown active — reset not-moving counter
|
||||
state.hopNotMovingTicks = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single hop: apply Y impulse + forward impulse along look direction.
|
||||
* Sends {@link ClientboundSetEntityMotionPacket} to sync velocity to client.
|
||||
*/
|
||||
private static void executeHop(ServerPlayer player, PlayerBindState state) {
|
||||
Vec3 look = player.getLookAngle();
|
||||
// Project look onto horizontal plane and normalize (safe: zero vec normalizes to zero)
|
||||
Vec3 forward = new Vec3(look.x, 0, look.z).normalize();
|
||||
Vec3 currentMotion = player.getDeltaMovement();
|
||||
|
||||
player.setDeltaMovement(
|
||||
currentMotion.x + forward.x * HOP_FORWARD_IMPULSE,
|
||||
HOP_Y_IMPULSE,
|
||||
currentMotion.z + forward.z * HOP_FORWARD_IMPULSE
|
||||
);
|
||||
|
||||
state.hopCooldown = HOP_COOLDOWN_TICKS;
|
||||
|
||||
// Sync velocity to client to prevent rubber-banding
|
||||
player.connection.send(new ClientboundSetEntityMotionPacket(player));
|
||||
}
|
||||
|
||||
// ==================== Crawl ====================
|
||||
|
||||
private static void activateCrawl(ServerPlayer player, PlayerBindState state) {
|
||||
applySpeedModifier(player, CRAWL_SPEED_UUID, "tiedup.crawl_speed",
|
||||
state.getResolvedMovementSpeed());
|
||||
player.setForcedPose(Pose.SWIMMING);
|
||||
player.refreshDimensions();
|
||||
state.pendingPoseRestore = false;
|
||||
}
|
||||
|
||||
private static void deactivateCrawl(ServerPlayer player, PlayerBindState state) {
|
||||
removeSpeedModifier(player, CRAWL_SPEED_UUID);
|
||||
|
||||
// Space check: can the player stand up?
|
||||
EntityDimensions standDims = player.getDimensions(Pose.STANDING);
|
||||
AABB standBox = standDims.makeBoundingBox(player.position());
|
||||
boolean canStand = player.level().noCollision(player, standBox);
|
||||
|
||||
if (canStand) {
|
||||
player.setForcedPose(null);
|
||||
player.refreshDimensions();
|
||||
} else {
|
||||
// Can't stand yet -- flag for periodic retry in tick flow (step 2)
|
||||
state.pendingPoseRestore = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static void tickCrawl(ServerPlayer player, PlayerBindState state) {
|
||||
// Guard re-assertion: only re-apply if something cleared the forced pose
|
||||
// (avoids unnecessary per-tick SynchedEntityData dirty-marking)
|
||||
if (player.getForcedPose() != Pose.SWIMMING) {
|
||||
player.setForcedPose(Pose.SWIMMING);
|
||||
player.refreshDimensions();
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Pending Pose Restore ====================
|
||||
|
||||
/**
|
||||
* Try to restore standing pose after crawl deactivation.
|
||||
* Called every tick regardless of active style (step 2 in tick flow).
|
||||
* Retries until space is available for the player to stand.
|
||||
*/
|
||||
private static void tryRestoreStandingPose(ServerPlayer player,
|
||||
PlayerBindState state) {
|
||||
EntityDimensions standDims = player.getDimensions(Pose.STANDING);
|
||||
AABB standBox = standDims.makeBoundingBox(player.position());
|
||||
boolean canStand = player.level().noCollision(player, standBox);
|
||||
|
||||
if (canStand) {
|
||||
player.setForcedPose(null);
|
||||
player.refreshDimensions();
|
||||
state.pendingPoseRestore = false;
|
||||
LOGGER.debug("Restored standing pose for {} (pending pose restore cleared)",
|
||||
player.getName().getString());
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== V1 Legacy Cleanup (H6) ====================
|
||||
|
||||
/**
|
||||
* Remove the legacy V1 {@code RestraintEffectUtils} speed modifier if present.
|
||||
*
|
||||
* <p>V1 used {@code addPermanentModifier()} with UUID {@code 7f3c7c8e-...} and
|
||||
* {@link AttributeModifier.Operation#ADDITION}. Because permanent modifiers are
|
||||
* serialized to player NBT, players upgrading mid-session or loading old saves
|
||||
* may still carry this modifier. Removing it here ensures only the V2
|
||||
* {@code MULTIPLY_BASE} modifier is active.</p>
|
||||
*
|
||||
* <p>This is a no-op if the modifier is not present (cheap UUID lookup).</p>
|
||||
*/
|
||||
private static void cleanupV1Modifier(ServerPlayer player) {
|
||||
AttributeInstance attr = player.getAttribute(Attributes.MOVEMENT_SPEED);
|
||||
if (attr != null && attr.getModifier(V1_BIND_SPEED_MODIFIER_UUID) != null) {
|
||||
attr.removeModifier(V1_BIND_SPEED_MODIFIER_UUID);
|
||||
LOGGER.info("Removed stale V1 speed modifier from player {}",
|
||||
player.getName().getString());
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Attribute Modifier Helpers ====================
|
||||
|
||||
/**
|
||||
* Apply a transient {@code MULTIPLY_BASE} speed modifier.
|
||||
* Always removes any existing modifier with the same UUID first, because
|
||||
* {@code addTransientModifier} throws {@link IllegalArgumentException}
|
||||
* if a modifier with the same UUID already exists.
|
||||
*
|
||||
* <p>{@code MULTIPLY_BASE} means the modifier value is added to 1.0 and
|
||||
* multiplied with the base value. A multiplier of 0.4 requires a modifier
|
||||
* value of -0.6: {@code base * (1 + (-0.6)) = base * 0.4}.</p>
|
||||
*
|
||||
* @param player the target player
|
||||
* @param uuid unique modifier UUID per style
|
||||
* @param name modifier name (for debugging in F3 screen)
|
||||
* @param multiplier the desired speed fraction (0.0-1.0)
|
||||
*/
|
||||
private static void applySpeedModifier(ServerPlayer player, UUID uuid, String name,
|
||||
float multiplier) {
|
||||
AttributeInstance attr = player.getAttribute(Attributes.MOVEMENT_SPEED);
|
||||
if (attr == null) return;
|
||||
|
||||
// Remove existing modifier first (no-op if not present)
|
||||
attr.removeModifier(uuid);
|
||||
|
||||
// MULTIPLY_BASE: value of -(1 - multiplier) reduces base speed to multiplier fraction
|
||||
double value = -(1.0 - multiplier);
|
||||
attr.addTransientModifier(new AttributeModifier(uuid, name,
|
||||
value, AttributeModifier.Operation.MULTIPLY_BASE));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a speed modifier by UUID. Safe to call even if no modifier
|
||||
* with this UUID is present.
|
||||
*/
|
||||
private static void removeSpeedModifier(ServerPlayer player, UUID uuid) {
|
||||
AttributeInstance attr = player.getAttribute(Attributes.MOVEMENT_SPEED);
|
||||
if (attr == null) return;
|
||||
attr.removeModifier(uuid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package com.tiedup.remake.v2.bondage.movement;
|
||||
|
||||
import com.tiedup.remake.items.base.ItemBind;
|
||||
import com.tiedup.remake.items.base.PoseType;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemDefinition;
|
||||
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry;
|
||||
import java.util.Map;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
/**
|
||||
* Resolves the winning movement style from a player's equipped bondage items.
|
||||
*
|
||||
* <p>Shared class (client + server). Deterministic: same items produce the same result.
|
||||
* The highest-severity style wins. Tiebreaker: lowest {@link BodyRegionV2#ordinal()}.</p>
|
||||
*
|
||||
* <p>The winning item's {@link MovementModifier} (if present) overrides the style's
|
||||
* default speed/jump values. Modifiers from lower-severity items are ignored.</p>
|
||||
*
|
||||
* <h3>V1 Compatibility (H6 fix)</h3>
|
||||
* <p>V1 items ({@link ItemBind}) stored in V2 capability
|
||||
* do not have data-driven definitions. This resolver provides a fallback that
|
||||
* maps V1 bind mode + pose type to a {@link MovementStyle} with speed values matching
|
||||
* the original V1 behavior, preventing double stacking between the legacy
|
||||
* {@code RestraintEffectUtils} attribute modifier and the V2 modifier.</p>
|
||||
*/
|
||||
public final class MovementStyleResolver {
|
||||
|
||||
private MovementStyleResolver() {}
|
||||
|
||||
// --- V1 fallback speed values ---
|
||||
// V1 used ADDITION(-0.09) on base 0.10 = 0.01 effective = 10% speed
|
||||
// Expressed as MULTIPLY_BASE fraction: 0.10
|
||||
private static final float V1_STANDARD_SPEED = 0.10f;
|
||||
|
||||
// V1 used ADDITION(-0.10) on base 0.10 = 0.00 effective = 0% speed
|
||||
// Expressed as MULTIPLY_BASE fraction: 0.0 (fully immobile)
|
||||
private static final float V1_IMMOBILIZED_SPEED = 0.0f;
|
||||
|
||||
/**
|
||||
* Resolve the winning movement style from all equipped items.
|
||||
*
|
||||
* <p>Checks V2 data-driven definitions first, then falls back to V1 {@link ItemBind}
|
||||
* introspection for items without data-driven definitions.</p>
|
||||
*
|
||||
* @param equipped map of region to ItemStack (from {@code IV2BondageEquipment.getAllEquipped()})
|
||||
* @return the resolved movement, or {@link ResolvedMovement#NONE} if no styled items
|
||||
*/
|
||||
public static ResolvedMovement resolve(Map<BodyRegionV2, ItemStack> equipped) {
|
||||
if (equipped == null || equipped.isEmpty()) {
|
||||
return ResolvedMovement.NONE;
|
||||
}
|
||||
|
||||
MovementStyle bestStyle = null;
|
||||
float bestSpeed = 1.0f;
|
||||
boolean bestJumpDisabled = false;
|
||||
int bestSeverity = -1;
|
||||
int bestRegionOrdinal = Integer.MAX_VALUE;
|
||||
|
||||
for (Map.Entry<BodyRegionV2, ItemStack> entry : equipped.entrySet()) {
|
||||
BodyRegionV2 region = entry.getKey();
|
||||
ItemStack stack = entry.getValue();
|
||||
if (stack.isEmpty()) continue;
|
||||
|
||||
// --- Try V2 data-driven definition first ---
|
||||
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
|
||||
if (def != null && def.movementStyle() != null) {
|
||||
MovementStyle style = def.movementStyle();
|
||||
int severity = style.getSeverity();
|
||||
int regionOrdinal = region.ordinal();
|
||||
|
||||
if (severity > bestSeverity
|
||||
|| (severity == bestSeverity && regionOrdinal < bestRegionOrdinal)) {
|
||||
bestStyle = style;
|
||||
MovementModifier mod = def.movementModifier();
|
||||
bestSpeed = (mod != null && mod.speedMultiplier() != null)
|
||||
? mod.speedMultiplier()
|
||||
: style.getDefaultSpeedMultiplier();
|
||||
bestJumpDisabled = (mod != null && mod.jumpDisabled() != null)
|
||||
? mod.jumpDisabled()
|
||||
: style.isDefaultJumpDisabled();
|
||||
bestSeverity = severity;
|
||||
bestRegionOrdinal = regionOrdinal;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- V1 fallback: ItemBind without data-driven definition ---
|
||||
V1Fallback fallback = resolveV1Fallback(stack);
|
||||
if (fallback != null) {
|
||||
int severity = fallback.style.getSeverity();
|
||||
int regionOrdinal = region.ordinal();
|
||||
|
||||
if (severity > bestSeverity
|
||||
|| (severity == bestSeverity && regionOrdinal < bestRegionOrdinal)) {
|
||||
bestStyle = fallback.style;
|
||||
bestSpeed = fallback.speed;
|
||||
bestJumpDisabled = fallback.jumpDisabled;
|
||||
bestSeverity = severity;
|
||||
bestRegionOrdinal = regionOrdinal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestStyle == null) {
|
||||
return ResolvedMovement.NONE;
|
||||
}
|
||||
|
||||
return new ResolvedMovement(bestStyle, bestSpeed, bestJumpDisabled);
|
||||
}
|
||||
|
||||
// ==================== V1 Fallback ====================
|
||||
|
||||
/**
|
||||
* Attempt to derive a movement style from a V1 {@link ItemBind} item.
|
||||
*
|
||||
* <p>Only items with legs bound produce a movement style. The mapping preserves
|
||||
* the original V1 speed values:</p>
|
||||
* <ul>
|
||||
* <li>WRAP / LATEX_SACK: SHUFFLE at 0% speed (full immobilization), jump disabled</li>
|
||||
* <li>DOG / HUMAN_CHAIR: CRAWL at V1 standard speed (10%), jump disabled</li>
|
||||
* <li>STANDARD / STRAITJACKET: SHUFFLE at 10% speed, jump disabled</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param stack the ItemStack to inspect
|
||||
* @return fallback resolution, or null if the item is not a V1 bind or legs are not bound
|
||||
*/
|
||||
@Nullable
|
||||
private static V1Fallback resolveV1Fallback(ItemStack stack) {
|
||||
if (!(stack.getItem() instanceof ItemBind bindItem)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!ItemBind.hasLegsBound(stack)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
PoseType poseType = bindItem.getPoseType();
|
||||
|
||||
return switch (poseType) {
|
||||
case WRAP, LATEX_SACK ->
|
||||
new V1Fallback(MovementStyle.SHUFFLE, V1_IMMOBILIZED_SPEED, true);
|
||||
case DOG, HUMAN_CHAIR ->
|
||||
new V1Fallback(MovementStyle.CRAWL, V1_STANDARD_SPEED, true);
|
||||
default ->
|
||||
// STANDARD, STRAITJACKET: shuffle at V1 standard speed
|
||||
new V1Fallback(MovementStyle.SHUFFLE, V1_STANDARD_SPEED, true);
|
||||
};
|
||||
}
|
||||
|
||||
/** Internal holder for V1 fallback resolution result. */
|
||||
private record V1Fallback(MovementStyle style, float speed, boolean jumpDisabled) {}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.tiedup.remake.v2.bondage.movement;
|
||||
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
/**
|
||||
* Result of resolving the winning movement style from all equipped items.
|
||||
* Contains the final computed values (style defaults merged with item overrides).
|
||||
*
|
||||
* <p>A null instance or null style means no movement restriction applies.</p>
|
||||
*/
|
||||
public record ResolvedMovement(
|
||||
/** The winning movement style, or null if no styled items are equipped. */
|
||||
@Nullable MovementStyle style,
|
||||
|
||||
/** Final speed multiplier (style default or item override). */
|
||||
float speedMultiplier,
|
||||
|
||||
/** Final jump-disabled flag (style default or item override). */
|
||||
boolean jumpDisabled
|
||||
) {
|
||||
|
||||
/** Sentinel for "no movement style active". */
|
||||
public static final ResolvedMovement NONE = new ResolvedMovement(null, 1.0f, false);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.tiedup.remake.v2.bondage.network;
|
||||
|
||||
import com.tiedup.remake.v2.bondage.IV2EquipmentHolder;
|
||||
import com.tiedup.remake.v2.bondage.capability.V2BondageEquipmentProvider;
|
||||
import java.util.function.Supplier;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.network.FriendlyByteBuf;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import net.minecraftforge.fml.loading.FMLEnvironment;
|
||||
import net.minecraftforge.network.NetworkEvent;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
/**
|
||||
* Server-to-client packet that syncs V2 bondage equipment state.
|
||||
*
|
||||
* Sent when equipment changes (equip/unequip/clear) and on player login
|
||||
* or start-tracking. The client deserializes into its local capability
|
||||
* so the render layer has data to display.
|
||||
*/
|
||||
public class PacketSyncV2Equipment {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger("PacketSyncV2Equipment");
|
||||
|
||||
private final int entityId;
|
||||
private final CompoundTag data;
|
||||
|
||||
public PacketSyncV2Equipment(int entityId, CompoundTag data) {
|
||||
this.entityId = entityId;
|
||||
this.data = data != null ? data : new CompoundTag();
|
||||
}
|
||||
|
||||
// ==================== Codec ====================
|
||||
|
||||
public static void encode(PacketSyncV2Equipment msg, FriendlyByteBuf buf) {
|
||||
buf.writeInt(msg.entityId);
|
||||
buf.writeNbt(msg.data);
|
||||
}
|
||||
|
||||
public static PacketSyncV2Equipment decode(FriendlyByteBuf buf) {
|
||||
int entityId = buf.readInt();
|
||||
CompoundTag data = buf.readNbt();
|
||||
return new PacketSyncV2Equipment(entityId, data != null ? data : new CompoundTag());
|
||||
}
|
||||
|
||||
// ==================== Handler ====================
|
||||
|
||||
public static void handle(PacketSyncV2Equipment msg, Supplier<NetworkEvent.Context> ctxSupplier) {
|
||||
NetworkEvent.Context ctx = ctxSupplier.get();
|
||||
ctx.enqueueWork(() -> {
|
||||
if (FMLEnvironment.dist == Dist.CLIENT) {
|
||||
handleOnClient(msg);
|
||||
}
|
||||
});
|
||||
ctx.setPacketHandled(true);
|
||||
}
|
||||
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
private static void handleOnClient(PacketSyncV2Equipment msg) {
|
||||
Level level = Minecraft.getInstance().level;
|
||||
if (level == null) return;
|
||||
|
||||
Entity entity = level.getEntity(msg.entityId);
|
||||
if (entity instanceof LivingEntity living) {
|
||||
// IV2EquipmentHolder entities (e.g., Damsels) sync via SynchedEntityData,
|
||||
// not via this packet. If we receive one for such an entity, deserialize
|
||||
// directly into their internal equipment storage.
|
||||
if (living instanceof IV2EquipmentHolder holder) {
|
||||
holder.getV2Equipment().deserializeNBT(msg.data);
|
||||
return;
|
||||
}
|
||||
living.getCapability(V2BondageEquipmentProvider.V2_BONDAGE_EQUIPMENT)
|
||||
.ifPresent(equip -> equip.deserializeNBT(msg.data));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package com.tiedup.remake.v2.bondage.network;
|
||||
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
import com.tiedup.remake.items.ItemKey;
|
||||
import com.tiedup.remake.items.ItemMasterKey;
|
||||
import com.tiedup.remake.items.base.ILockable;
|
||||
import com.tiedup.remake.network.PacketRateLimiter;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Supplier;
|
||||
import net.minecraft.network.FriendlyByteBuf;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.InteractionHand;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraftforge.network.NetworkEvent;
|
||||
|
||||
/**
|
||||
* C2S packet: player locks or unlocks a V2 bondage item on a target entity.
|
||||
*
|
||||
* <p>The server checks the player's hands for a key -- no key data is sent by
|
||||
* the client. This prevents spoofed key UUIDs.
|
||||
*
|
||||
* <p>Security model:
|
||||
* <ul>
|
||||
* <li>Distance check: sender must be within 4 blocks</li>
|
||||
* <li>Line-of-sight check: sender must see the target</li>
|
||||
* <li>Key authority: server reads key from sender's hands, never from packet</li>
|
||||
* <li>Master key can only UNLOCK (not lock)</li>
|
||||
* <li>Regular key must match the lock UUID to unlock</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Rate limited under the "action" bucket (10 tokens, 2/sec refill).
|
||||
*/
|
||||
public class PacketV2LockToggle {
|
||||
|
||||
public enum Action { LOCK, UNLOCK }
|
||||
|
||||
private final int targetEntityId;
|
||||
private final BodyRegionV2 region;
|
||||
private final Action action;
|
||||
|
||||
public PacketV2LockToggle(int targetEntityId, BodyRegionV2 region, Action action) {
|
||||
this.targetEntityId = targetEntityId;
|
||||
this.region = region;
|
||||
this.action = action;
|
||||
}
|
||||
|
||||
public static void encode(PacketV2LockToggle msg, FriendlyByteBuf buf) {
|
||||
buf.writeInt(msg.targetEntityId);
|
||||
buf.writeEnum(msg.region);
|
||||
buf.writeEnum(msg.action);
|
||||
}
|
||||
|
||||
public static PacketV2LockToggle decode(FriendlyByteBuf buf) {
|
||||
return new PacketV2LockToggle(
|
||||
buf.readInt(),
|
||||
buf.readEnum(BodyRegionV2.class),
|
||||
buf.readEnum(Action.class)
|
||||
);
|
||||
}
|
||||
|
||||
public static void handle(PacketV2LockToggle msg, Supplier<NetworkEvent.Context> ctxSupplier) {
|
||||
NetworkEvent.Context ctx = ctxSupplier.get();
|
||||
ctx.enqueueWork(() -> {
|
||||
ServerPlayer sender = ctx.getSender();
|
||||
if (sender == null) return;
|
||||
if (!PacketRateLimiter.allowPacket(sender, "action")) return;
|
||||
|
||||
handleServer(sender, msg.targetEntityId, msg.region, msg.action);
|
||||
});
|
||||
ctx.setPacketHandled(true);
|
||||
}
|
||||
|
||||
private static void handleServer(
|
||||
ServerPlayer sender, int targetEntityId, BodyRegionV2 region, Action action
|
||||
) {
|
||||
Entity rawTarget = sender.level().getEntity(targetEntityId);
|
||||
if (!(rawTarget instanceof LivingEntity target)) return;
|
||||
|
||||
// Distance + line-of-sight validation
|
||||
if (sender.distanceTo(target) > 4.0 || !sender.hasLineOfSight(target)) return;
|
||||
|
||||
ItemStack stack = V2EquipmentHelper.getInRegion(target, region);
|
||||
if (stack.isEmpty()) return;
|
||||
if (!(stack.getItem() instanceof ILockable lockable)) return;
|
||||
|
||||
// Find key in sender's hands -- server-authoritative, never from packet
|
||||
ItemStack mainHand = sender.getItemInHand(InteractionHand.MAIN_HAND);
|
||||
ItemStack offHand = sender.getItemInHand(InteractionHand.OFF_HAND);
|
||||
|
||||
boolean hasMasterKey = mainHand.getItem() instanceof ItemMasterKey
|
||||
|| offHand.getItem() instanceof ItemMasterKey;
|
||||
|
||||
ItemKey heldKey = null;
|
||||
ItemStack heldKeyStack = ItemStack.EMPTY;
|
||||
if (mainHand.getItem() instanceof ItemKey key) {
|
||||
heldKey = key;
|
||||
heldKeyStack = mainHand;
|
||||
} else if (offHand.getItem() instanceof ItemKey key) {
|
||||
heldKey = key;
|
||||
heldKeyStack = offHand;
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case LOCK -> {
|
||||
if (lockable.isLocked(stack)) return;
|
||||
if (!lockable.isLockable(stack)) return;
|
||||
if (hasMasterKey) return; // master key cannot lock
|
||||
if (heldKey == null) return;
|
||||
|
||||
UUID keyUUID = heldKey.getKeyUUID(heldKeyStack);
|
||||
lockable.setLockedByKeyUUID(stack, keyUUID);
|
||||
lockable.initializeLockResistance(stack);
|
||||
|
||||
TiedUpMod.LOGGER.debug("[V2LockToggle] Locked region {} on entity {}",
|
||||
region.name(), target.getName().getString());
|
||||
}
|
||||
case UNLOCK -> {
|
||||
if (!lockable.isLocked(stack)) return;
|
||||
|
||||
if (hasMasterKey) {
|
||||
lockable.setLockedByKeyUUID(stack, null);
|
||||
lockable.clearLockResistance(stack);
|
||||
} else if (heldKey != null) {
|
||||
UUID keyUUID = heldKey.getKeyUUID(heldKeyStack);
|
||||
if (!lockable.matchesKey(stack, keyUUID)) return;
|
||||
lockable.setLockedByKeyUUID(stack, null);
|
||||
lockable.clearLockResistance(stack);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
TiedUpMod.LOGGER.debug("[V2LockToggle] Unlocked region {} on entity {}",
|
||||
region.name(), target.getName().getString());
|
||||
}
|
||||
}
|
||||
|
||||
V2EquipmentHelper.sync(target);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.tiedup.remake.v2.bondage.network;
|
||||
|
||||
import com.tiedup.remake.network.PacketRateLimiter;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import com.tiedup.remake.v2.bondage.IV2BondageItem;
|
||||
import com.tiedup.remake.v2.bondage.V2EquipResult;
|
||||
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
|
||||
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem;
|
||||
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry;
|
||||
import java.util.function.Supplier;
|
||||
import net.minecraft.network.FriendlyByteBuf;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraftforge.network.NetworkEvent;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
/**
|
||||
* Client→Server: Player equips a bondage item from their own inventory onto a body region.
|
||||
*/
|
||||
public class PacketV2SelfEquip {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger("PacketV2SelfEquip");
|
||||
|
||||
private final BodyRegionV2 region;
|
||||
private final int inventorySlot;
|
||||
|
||||
public PacketV2SelfEquip(BodyRegionV2 region, int inventorySlot) {
|
||||
this.region = region;
|
||||
this.inventorySlot = inventorySlot;
|
||||
}
|
||||
|
||||
public static void encode(PacketV2SelfEquip msg, FriendlyByteBuf buf) {
|
||||
buf.writeEnum(msg.region);
|
||||
buf.writeVarInt(msg.inventorySlot);
|
||||
}
|
||||
|
||||
public static PacketV2SelfEquip decode(FriendlyByteBuf buf) {
|
||||
return new PacketV2SelfEquip(buf.readEnum(BodyRegionV2.class), buf.readVarInt());
|
||||
}
|
||||
|
||||
public static void handle(PacketV2SelfEquip msg, Supplier<NetworkEvent.Context> ctxSupplier) {
|
||||
NetworkEvent.Context ctx = ctxSupplier.get();
|
||||
ctx.enqueueWork(() -> {
|
||||
ServerPlayer player = ctx.getSender();
|
||||
if (player == null) return;
|
||||
if (!PacketRateLimiter.allowPacket(player, "action")) return;
|
||||
|
||||
// Validate slot index
|
||||
if (msg.inventorySlot < 0 || msg.inventorySlot >= player.getInventory().getContainerSize()) return;
|
||||
|
||||
ItemStack stack = player.getInventory().getItem(msg.inventorySlot);
|
||||
if (stack.isEmpty()) return;
|
||||
if (!(stack.getItem() instanceof IV2BondageItem bondageItem)) return;
|
||||
|
||||
// Warn if data-driven item has no definition (missing JSON or reload issue)
|
||||
if (bondageItem instanceof DataDrivenBondageItem && DataDrivenItemRegistry.get(stack) == null) {
|
||||
LOGGER.warn("[V2SelfEquip] Data-driven item in slot {} has no definition — equip blocked. Stack NBT: {}",
|
||||
msg.inventorySlot, stack.getTag());
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate item targets this region
|
||||
if (!bondageItem.getOccupiedRegions(stack).contains(msg.region)) return;
|
||||
|
||||
// Furniture seat blocks this region
|
||||
if (player.isPassenger() && player.getVehicle() instanceof com.tiedup.remake.v2.furniture.ISeatProvider provider) {
|
||||
com.tiedup.remake.v2.furniture.SeatDefinition seat = provider.getSeatForPassenger(player);
|
||||
if (seat != null && seat.blockedRegions().contains(msg.region)) {
|
||||
return; // Region blocked by furniture
|
||||
}
|
||||
}
|
||||
|
||||
// Try equip (handles conflict resolution)
|
||||
V2EquipResult result = V2EquipmentHelper.equipItem(player, stack);
|
||||
if (result.isSuccess()) {
|
||||
// Remove from inventory (or reduce count)
|
||||
player.getInventory().removeItem(msg.inventorySlot, 1);
|
||||
// Return any displaced items to inventory
|
||||
if (result.displaced() != null) {
|
||||
for (ItemStack displaced : result.displaced()) {
|
||||
if (!displaced.isEmpty()) {
|
||||
player.getInventory().placeItemBackInInventory(displaced);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
ctx.setPacketHandled(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.tiedup.remake.v2.bondage.network;
|
||||
|
||||
import com.tiedup.remake.items.ItemKey;
|
||||
import com.tiedup.remake.items.base.ILockable;
|
||||
import com.tiedup.remake.network.PacketRateLimiter;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Supplier;
|
||||
import net.minecraft.network.FriendlyByteBuf;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraftforge.network.NetworkEvent;
|
||||
|
||||
/**
|
||||
* Client→Server: Player locks their own equipped item using a key from inventory.
|
||||
*/
|
||||
public class PacketV2SelfLock {
|
||||
|
||||
private final BodyRegionV2 region;
|
||||
|
||||
public PacketV2SelfLock(BodyRegionV2 region) {
|
||||
this.region = region;
|
||||
}
|
||||
|
||||
public static void encode(PacketV2SelfLock msg, FriendlyByteBuf buf) {
|
||||
buf.writeEnum(msg.region);
|
||||
}
|
||||
|
||||
public static PacketV2SelfLock decode(FriendlyByteBuf buf) {
|
||||
return new PacketV2SelfLock(buf.readEnum(BodyRegionV2.class));
|
||||
}
|
||||
|
||||
public static void handle(PacketV2SelfLock msg, Supplier<NetworkEvent.Context> ctxSupplier) {
|
||||
NetworkEvent.Context ctx = ctxSupplier.get();
|
||||
ctx.enqueueWork(() -> {
|
||||
ServerPlayer player = ctx.getSender();
|
||||
if (player == null) return;
|
||||
if (!PacketRateLimiter.allowPacket(player, "action")) return;
|
||||
|
||||
// Arms must be free to self-lock
|
||||
if (V2EquipmentHelper.isRegionOccupied(player, BodyRegionV2.ARMS)) return;
|
||||
|
||||
ItemStack equipped = V2EquipmentHelper.getInRegion(player, msg.region);
|
||||
if (equipped.isEmpty()) return;
|
||||
if (!(equipped.getItem() instanceof ILockable lockable)) return;
|
||||
if (!lockable.isLockable(equipped) || lockable.isLocked(equipped)) return;
|
||||
|
||||
// Furniture seat blocks this region
|
||||
if (player.isPassenger() && player.getVehicle() instanceof com.tiedup.remake.v2.furniture.ISeatProvider provider) {
|
||||
com.tiedup.remake.v2.furniture.SeatDefinition seat = provider.getSeatForPassenger(player);
|
||||
if (seat != null && seat.blockedRegions().contains(msg.region)) {
|
||||
return; // Region blocked by furniture
|
||||
}
|
||||
}
|
||||
|
||||
// Find a key in inventory
|
||||
ItemStack keyStack = findKeyInInventory(player);
|
||||
if (keyStack.isEmpty()) return;
|
||||
if (!(keyStack.getItem() instanceof ItemKey key)) return;
|
||||
|
||||
UUID keyUUID = key.getKeyUUID(keyStack);
|
||||
lockable.setLockedByKeyUUID(equipped, keyUUID);
|
||||
V2EquipmentHelper.sync(player);
|
||||
});
|
||||
ctx.setPacketHandled(true);
|
||||
}
|
||||
|
||||
/** Find the first ItemKey in the player's inventory. Returns ItemStack.EMPTY if none. */
|
||||
private static ItemStack findKeyInInventory(ServerPlayer player) {
|
||||
for (int i = 0; i < player.getInventory().getContainerSize(); i++) {
|
||||
ItemStack s = player.getInventory().getItem(i);
|
||||
if (!s.isEmpty() && s.getItem() instanceof ItemKey) {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
return ItemStack.EMPTY;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.tiedup.remake.v2.bondage.network;
|
||||
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
import com.tiedup.remake.network.PacketRateLimiter;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import com.tiedup.remake.v2.bondage.IV2BondageItem;
|
||||
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
|
||||
import java.util.function.Supplier;
|
||||
import net.minecraft.network.FriendlyByteBuf;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraftforge.network.NetworkEvent;
|
||||
|
||||
/**
|
||||
* C2S packet: player requests removal of a V2 bondage item from themselves.
|
||||
*
|
||||
* Validates:
|
||||
* - Region is occupied
|
||||
* - Item canUnequip (not locked)
|
||||
* - ARMS not occupied (can't manipulate buckles with bound arms)
|
||||
* - Item does not occupy ARMS (arm restraints require struggle, not manual removal)
|
||||
*/
|
||||
public class PacketV2SelfRemove {
|
||||
|
||||
private final BodyRegionV2 region;
|
||||
|
||||
public PacketV2SelfRemove(BodyRegionV2 region) {
|
||||
this.region = region;
|
||||
}
|
||||
|
||||
public static void encode(PacketV2SelfRemove msg, FriendlyByteBuf buf) {
|
||||
buf.writeEnum(msg.region);
|
||||
}
|
||||
|
||||
public static PacketV2SelfRemove decode(FriendlyByteBuf buf) {
|
||||
return new PacketV2SelfRemove(buf.readEnum(BodyRegionV2.class));
|
||||
}
|
||||
|
||||
public static void handle(PacketV2SelfRemove msg, Supplier<NetworkEvent.Context> ctxSupplier) {
|
||||
NetworkEvent.Context ctx = ctxSupplier.get();
|
||||
ctx.enqueueWork(() -> {
|
||||
ServerPlayer player = ctx.getSender();
|
||||
if (player == null) return;
|
||||
if (!PacketRateLimiter.allowPacket(player, "action")) return;
|
||||
|
||||
handleServer(player, msg.region);
|
||||
});
|
||||
ctx.setPacketHandled(true);
|
||||
}
|
||||
|
||||
private static void handleServer(ServerPlayer player, BodyRegionV2 region) {
|
||||
ItemStack stack = V2EquipmentHelper.getInRegion(player, region);
|
||||
if (stack.isEmpty()) return;
|
||||
|
||||
if (!(stack.getItem() instanceof IV2BondageItem item)) return;
|
||||
|
||||
// Arm restraints cannot be self-removed — must use struggle
|
||||
if (item.getOccupiedRegions(stack).contains(BodyRegionV2.ARMS)) {
|
||||
TiedUpMod.LOGGER.debug("[V2SelfRemove] Blocked: item occupies ARMS, must struggle");
|
||||
return;
|
||||
}
|
||||
|
||||
// Cannot manipulate buckles/clasps with bound arms
|
||||
if (V2EquipmentHelper.isRegionOccupied(player, BodyRegionV2.ARMS)) {
|
||||
TiedUpMod.LOGGER.debug("[V2SelfRemove] Blocked: player's ARMS are occupied");
|
||||
return;
|
||||
}
|
||||
|
||||
// Cannot manipulate buckles/clasps with covered hands
|
||||
if (V2EquipmentHelper.isRegionOccupied(player, BodyRegionV2.HANDS)) {
|
||||
TiedUpMod.LOGGER.debug("[V2SelfRemove] Blocked: player's HANDS are occupied");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check item allows unequip (not locked)
|
||||
if (!item.canUnequip(stack, player)) {
|
||||
TiedUpMod.LOGGER.debug("[V2SelfRemove] Blocked: item canUnequip=false (locked?)");
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove and give to inventory
|
||||
ItemStack removed = V2EquipmentHelper.unequipFromRegion(player, region);
|
||||
if (!removed.isEmpty()) {
|
||||
if (!player.getInventory().add(removed)) {
|
||||
player.drop(removed, false);
|
||||
}
|
||||
}
|
||||
// sync() is called inside unequipFromRegion
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.tiedup.remake.v2.bondage.network;
|
||||
|
||||
import com.tiedup.remake.items.ItemKey;
|
||||
import com.tiedup.remake.items.ModItems;
|
||||
import com.tiedup.remake.items.base.ILockable;
|
||||
import com.tiedup.remake.network.PacketRateLimiter;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Supplier;
|
||||
import net.minecraft.network.FriendlyByteBuf;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraftforge.network.NetworkEvent;
|
||||
|
||||
/**
|
||||
* Client→Server: Player unlocks their own equipped item using the matching key.
|
||||
*/
|
||||
public class PacketV2SelfUnlock {
|
||||
|
||||
private final BodyRegionV2 region;
|
||||
|
||||
public PacketV2SelfUnlock(BodyRegionV2 region) {
|
||||
this.region = region;
|
||||
}
|
||||
|
||||
public static void encode(PacketV2SelfUnlock msg, FriendlyByteBuf buf) {
|
||||
buf.writeEnum(msg.region);
|
||||
}
|
||||
|
||||
public static PacketV2SelfUnlock decode(FriendlyByteBuf buf) {
|
||||
return new PacketV2SelfUnlock(buf.readEnum(BodyRegionV2.class));
|
||||
}
|
||||
|
||||
public static void handle(PacketV2SelfUnlock msg, Supplier<NetworkEvent.Context> ctxSupplier) {
|
||||
NetworkEvent.Context ctx = ctxSupplier.get();
|
||||
ctx.enqueueWork(() -> {
|
||||
ServerPlayer player = ctx.getSender();
|
||||
if (player == null) return;
|
||||
if (!PacketRateLimiter.allowPacket(player, "action")) return;
|
||||
|
||||
// Arms must be free to self-unlock
|
||||
if (V2EquipmentHelper.isRegionOccupied(player, BodyRegionV2.ARMS)) return;
|
||||
|
||||
ItemStack equipped = V2EquipmentHelper.getInRegion(player, msg.region);
|
||||
if (equipped.isEmpty()) return;
|
||||
if (!(equipped.getItem() instanceof ILockable lockable)) return;
|
||||
if (!lockable.isLocked(equipped)) return;
|
||||
|
||||
// Furniture seat blocks this region
|
||||
if (player.isPassenger() && player.getVehicle() instanceof com.tiedup.remake.v2.furniture.ISeatProvider provider) {
|
||||
com.tiedup.remake.v2.furniture.SeatDefinition seat = provider.getSeatForPassenger(player);
|
||||
if (seat != null && seat.blockedRegions().contains(msg.region)) {
|
||||
return; // Region blocked by furniture
|
||||
}
|
||||
}
|
||||
|
||||
// Find matching key in inventory
|
||||
UUID lockedByUUID = lockable.getLockedByKeyUUID(equipped);
|
||||
ItemStack keyStack = findMatchingKeyInInventory(player, lockedByUUID);
|
||||
if (keyStack.isEmpty()) return;
|
||||
|
||||
lockable.setLockedByKeyUUID(equipped, null);
|
||||
V2EquipmentHelper.sync(player);
|
||||
});
|
||||
ctx.setPacketHandled(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a key in the player's inventory that matches the lock UUID,
|
||||
* or a master key that unlocks anything.
|
||||
*/
|
||||
private static ItemStack findMatchingKeyInInventory(ServerPlayer player, UUID lockedByUUID) {
|
||||
for (int i = 0; i < player.getInventory().getContainerSize(); i++) {
|
||||
ItemStack s = player.getInventory().getItem(i);
|
||||
if (s.isEmpty()) continue;
|
||||
// Master key unlocks everything
|
||||
if (s.is(ModItems.MASTER_KEY.get())) return s;
|
||||
// Regular key: must match the lock UUID
|
||||
if (s.getItem() instanceof ItemKey key) {
|
||||
if (lockedByUUID != null && lockedByUUID.equals(key.getKeyUUID(s))) {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
}
|
||||
return ItemStack.EMPTY;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.tiedup.remake.v2.bondage.network;
|
||||
|
||||
import com.tiedup.remake.items.base.IHasResistance;
|
||||
import com.tiedup.remake.items.base.ILockable;
|
||||
import com.tiedup.remake.minigame.ContinuousStruggleMiniGameState;
|
||||
import com.tiedup.remake.minigame.StruggleSessionManager;
|
||||
import com.tiedup.remake.network.ModNetwork;
|
||||
import com.tiedup.remake.network.PacketRateLimiter;
|
||||
import com.tiedup.remake.network.minigame.PacketContinuousStruggleState;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
|
||||
import java.util.function.Supplier;
|
||||
import net.minecraft.network.FriendlyByteBuf;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraftforge.network.NetworkEvent;
|
||||
|
||||
/**
|
||||
* C2S packet: player starts a struggle minigame against a V2 item in a region.
|
||||
*
|
||||
* <p>Flow:
|
||||
* <ol>
|
||||
* <li>Client sends this packet with the target {@link BodyRegionV2}</li>
|
||||
* <li>Server validates: region occupied, item has resistance</li>
|
||||
* <li>Server creates a {@link ContinuousStruggleMiniGameState} via
|
||||
* {@link MiniGameSessionManager#startV2StruggleSession}</li>
|
||||
* <li>Server sends {@link PacketContinuousStruggleState}(START) back to open the minigame GUI</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>Rate limited under the "ui" bucket (3 tokens, 0.5/sec refill) since this is
|
||||
* a screen-opening action, not a per-tick input.
|
||||
*/
|
||||
public class PacketV2StruggleStart {
|
||||
|
||||
private final BodyRegionV2 region;
|
||||
|
||||
public PacketV2StruggleStart(BodyRegionV2 region) {
|
||||
this.region = region;
|
||||
}
|
||||
|
||||
public BodyRegionV2 getRegion() {
|
||||
return region;
|
||||
}
|
||||
|
||||
public static void encode(PacketV2StruggleStart msg, FriendlyByteBuf buf) {
|
||||
buf.writeEnum(msg.region);
|
||||
}
|
||||
|
||||
public static PacketV2StruggleStart decode(FriendlyByteBuf buf) {
|
||||
return new PacketV2StruggleStart(buf.readEnum(BodyRegionV2.class));
|
||||
}
|
||||
|
||||
public static void handle(PacketV2StruggleStart msg, Supplier<NetworkEvent.Context> ctxSupplier) {
|
||||
NetworkEvent.Context ctx = ctxSupplier.get();
|
||||
ctx.enqueueWork(() -> {
|
||||
ServerPlayer player = ctx.getSender();
|
||||
if (player == null) return;
|
||||
if (!PacketRateLimiter.allowPacket(player, "ui")) return;
|
||||
|
||||
handleServer(player, msg.region);
|
||||
});
|
||||
ctx.setPacketHandled(true);
|
||||
}
|
||||
|
||||
private static void handleServer(ServerPlayer player, BodyRegionV2 region) {
|
||||
ItemStack stack = V2EquipmentHelper.getInRegion(player, region);
|
||||
if (stack.isEmpty()) return;
|
||||
|
||||
if (!(stack.getItem() instanceof IHasResistance resistanceItem)) return;
|
||||
|
||||
// BUG-002 fix: respect canBeStruggledOut flag
|
||||
if (!resistanceItem.canBeStruggledOut(stack)) return;
|
||||
|
||||
// RISK-002 fix: respect server config
|
||||
if (!com.tiedup.remake.core.ModConfig.SERVER.struggleMiniGameEnabled.get()) return;
|
||||
|
||||
int resistance = resistanceItem.getCurrentResistance(stack, player);
|
||||
boolean isLocked = false;
|
||||
if (stack.getItem() instanceof ILockable lockable) {
|
||||
isLocked = lockable.isLocked(stack);
|
||||
if (isLocked) {
|
||||
resistance += lockable.getCurrentLockResistance(stack);
|
||||
}
|
||||
}
|
||||
|
||||
// RISK-003 fix: no point starting a session with 0 resistance
|
||||
if (resistance <= 0) return;
|
||||
|
||||
StruggleSessionManager manager = StruggleSessionManager.getInstance();
|
||||
ContinuousStruggleMiniGameState session = manager.startV2StruggleSession(
|
||||
player, region, resistance, isLocked
|
||||
);
|
||||
|
||||
if (session != null) {
|
||||
ModNetwork.sendToPlayer(
|
||||
new PacketContinuousStruggleState(
|
||||
session.getSessionId(),
|
||||
ContinuousStruggleMiniGameState.UpdateType.START,
|
||||
session.getCurrentDirection().getIndex(),
|
||||
session.getCurrentResistance(),
|
||||
session.getMaxResistance(),
|
||||
isLocked
|
||||
),
|
||||
player
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user