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:
NotEvil
2026-04-12 00:51:22 +02:00
parent 2e7a1d403b
commit f6466360b6
1947 changed files with 238025 additions and 1 deletions

View File

@@ -0,0 +1,60 @@
package com.tiedup.remake.v2;
import org.jetbrains.annotations.Nullable;
/**
* V2 body region system with 14 regions and global/sub hierarchy.
*
* Global regions (HEAD, ARMS, LEGS) have sub-regions for organizational purposes.
* Blocking is NOT automatic — each item declares which regions it blocks
* via {@link IV2BondageItem#getBlockedRegions()}.
*/
public enum BodyRegionV2 {
// Head regions
HEAD(true),
EYES(false),
EARS(false),
MOUTH(false),
// Upper body
NECK(false),
TORSO(false),
ARMS(true),
HANDS(false),
FINGERS(false),
// Lower body
WAIST(false),
LEGS(true),
FEET(false),
// Special
TAIL(false),
WINGS(false);
private final boolean global;
BodyRegionV2(boolean global) {
this.global = global;
}
/**
* Whether this region is a global region that blocks sub-regions.
*/
public boolean isGlobal() {
return global;
}
/**
* Safe valueOf that returns null instead of throwing on unknown names.
*/
@Nullable
public static BodyRegionV2 fromName(String name) {
if (name == null) return null;
try {
return valueOf(name);
} catch (IllegalArgumentException e) {
return null;
}
}
}

View File

@@ -0,0 +1,64 @@
package com.tiedup.remake.v2;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.v2.blocks.PetBedBlockEntity;
import com.tiedup.remake.v2.blocks.PetBowlBlockEntity;
import com.tiedup.remake.v2.blocks.PetCageBlockEntity;
import net.minecraft.world.level.block.entity.BlockEntityType;
import net.minecraftforge.registries.DeferredRegister;
import net.minecraftforge.registries.ForgeRegistries;
import net.minecraftforge.registries.RegistryObject;
/**
* V2 Block Entity Registration.
*/
public class V2BlockEntities {
public static final DeferredRegister<BlockEntityType<?>> BLOCK_ENTITIES =
DeferredRegister.create(
ForgeRegistries.BLOCK_ENTITY_TYPES,
TiedUpMod.MOD_ID
);
public static final RegistryObject<
BlockEntityType<PetBowlBlockEntity>
> PET_BOWL = BLOCK_ENTITIES.register("pet_bowl", () ->
BlockEntityType.Builder.of(
(pos, state) ->
new PetBowlBlockEntity(
V2BlockEntities.PET_BOWL.get(),
pos,
state
),
V2Blocks.PET_BOWL.get()
).build(null)
);
public static final RegistryObject<
BlockEntityType<PetBedBlockEntity>
> PET_BED = BLOCK_ENTITIES.register("pet_bed", () ->
BlockEntityType.Builder.of(
(pos, state) ->
new PetBedBlockEntity(
V2BlockEntities.PET_BED.get(),
pos,
state
),
V2Blocks.PET_BED.get()
).build(null)
);
public static final RegistryObject<
BlockEntityType<PetCageBlockEntity>
> PET_CAGE = BLOCK_ENTITIES.register("pet_cage", () ->
BlockEntityType.Builder.of(
(pos, state) ->
new PetCageBlockEntity(
V2BlockEntities.PET_CAGE.get(),
pos,
state
),
V2Blocks.PET_CAGE.get()
).build(null)
);
}

View File

@@ -0,0 +1,77 @@
package com.tiedup.remake.v2;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.v2.blocks.PetBedBlock;
import com.tiedup.remake.v2.blocks.PetBowlBlock;
import com.tiedup.remake.v2.blocks.PetCageBlock;
import com.tiedup.remake.v2.blocks.PetCagePartBlock;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.SoundType;
import net.minecraft.world.level.block.state.BlockBehaviour;
import net.minecraft.world.level.material.MapColor;
import net.minecraftforge.registries.DeferredRegister;
import net.minecraftforge.registries.ForgeRegistries;
import net.minecraftforge.registries.RegistryObject;
/**
* V2 Block Registration.
* Registers OBJ-rendered blocks like pet bowls and beds.
*/
public class V2Blocks {
public static final DeferredRegister<Block> BLOCKS =
DeferredRegister.create(ForgeRegistries.BLOCKS, TiedUpMod.MOD_ID);
// ========================================
// PET FURNITURE
// ========================================
public static final RegistryObject<Block> PET_BOWL = BLOCKS.register(
"pet_bowl",
() ->
new PetBowlBlock(
BlockBehaviour.Properties.of()
.mapColor(MapColor.METAL)
.strength(1.0f)
.sound(SoundType.METAL)
.noOcclusion()
)
);
public static final RegistryObject<Block> PET_BED = BLOCKS.register(
"pet_bed",
() ->
new PetBedBlock(
BlockBehaviour.Properties.of()
.mapColor(MapColor.WOOL)
.strength(0.5f)
.sound(SoundType.WOOL)
.noOcclusion()
)
);
public static final RegistryObject<Block> PET_CAGE = BLOCKS.register(
"pet_cage",
() ->
new PetCageBlock(
BlockBehaviour.Properties.of()
.mapColor(MapColor.METAL)
.strength(2.0f)
.sound(SoundType.METAL)
.noOcclusion()
)
);
public static final RegistryObject<Block> PET_CAGE_PART = BLOCKS.register(
"pet_cage_part",
() ->
new PetCagePartBlock(
BlockBehaviour.Properties.of()
.mapColor(MapColor.METAL)
.strength(2.0f)
.sound(SoundType.METAL)
.noOcclusion()
.noLootTable() // Only master drops the cage item
)
);
}

View File

@@ -0,0 +1,39 @@
package com.tiedup.remake.v2;
import com.tiedup.remake.core.TiedUpMod;
import net.minecraft.world.item.BlockItem;
import net.minecraft.world.item.Item;
import net.minecraftforge.registries.DeferredRegister;
import net.minecraftforge.registries.ForgeRegistries;
import net.minecraftforge.registries.RegistryObject;
/**
* V2 Item Registration.
* Block items for V2 blocks.
*/
public class V2Items {
public static final DeferredRegister<Item> ITEMS = DeferredRegister.create(
ForgeRegistries.ITEMS,
TiedUpMod.MOD_ID
);
// ========================================
// BLOCK ITEMS
// ========================================
public static final RegistryObject<Item> PET_BOWL = ITEMS.register(
"pet_bowl",
() -> new BlockItem(V2Blocks.PET_BOWL.get(), new Item.Properties())
);
public static final RegistryObject<Item> PET_BED = ITEMS.register(
"pet_bed",
() -> new BlockItem(V2Blocks.PET_BED.get(), new Item.Properties())
);
public static final RegistryObject<Item> PET_CAGE = ITEMS.register(
"pet_cage",
() -> new BlockItem(V2Blocks.PET_CAGE.get(), new Item.Properties())
);
}

View File

@@ -0,0 +1,66 @@
package com.tiedup.remake.v2.blocks;
import javax.annotation.Nullable;
import net.minecraft.core.BlockPos;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.entity.BlockEntityType;
import net.minecraft.world.level.block.state.BlockState;
/**
* Base block entity for blocks that render with OBJ models.
* Subclasses define the model and texture locations.
*/
public abstract class ObjBlockEntity extends BlockEntity {
public ObjBlockEntity(
BlockEntityType<?> type,
BlockPos pos,
BlockState state
) {
super(type, pos, state);
}
/**
* Get the OBJ model resource location.
* Example: "tiedup:models/obj/blocks/bowl/model.obj"
*/
@Nullable
public abstract ResourceLocation getModelLocation();
/**
* Get the texture resource location.
* Example: "tiedup:textures/block/bowl.png"
*/
@Nullable
public abstract ResourceLocation getTextureLocation();
/**
* Get the model scale (default 1.0).
* Override to scale the model up or down.
*/
public float getModelScale() {
return 1.0f;
}
/**
* Get the model position offset (x, y, z).
* Override to adjust model position within the block.
*/
public float[] getModelOffset() {
return new float[] { 0.0f, 0.0f, 0.0f };
}
@Override
protected void saveAdditional(CompoundTag tag) {
super.saveAdditional(tag);
// Subclasses can override to save additional data
}
@Override
public void load(CompoundTag tag) {
super.load(tag);
// Subclasses can override to load additional data
}
}

View File

@@ -0,0 +1,181 @@
package com.tiedup.remake.v2.blocks;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.v2.V2BlockEntities;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.context.BlockPlaceContext;
import net.minecraft.world.level.BlockGetter;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.EntityBlock;
import net.minecraft.world.level.block.HorizontalDirectionalBlock;
import net.minecraft.world.level.block.RenderShape;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.StateDefinition;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
import net.minecraft.world.level.block.state.properties.BooleanProperty;
import net.minecraft.world.level.block.state.properties.DirectionProperty;
import net.minecraft.world.phys.BlockHitResult;
import net.minecraft.world.phys.shapes.CollisionContext;
import net.minecraft.world.phys.shapes.VoxelShape;
import org.jetbrains.annotations.Nullable;
/**
* Pet Bed Block - Used by Master to make pets sit and sleep.
* Features:
* - Right-click cycle: Stand -> SIT (wariza) -> SLEEP (curled) -> Stand
* - SIT: immobilized with animation, no night skip
* - SLEEP: vanilla sleeping + animation, night skips
* - Custom OBJ model rendering
*/
public class PetBedBlock extends Block implements EntityBlock {
public static final DirectionProperty FACING =
HorizontalDirectionalBlock.FACING;
public static final BooleanProperty OCCUPIED =
BlockStateProperties.OCCUPIED;
// Collision shape (rough approximation of pet bed)
private static final VoxelShape SHAPE = Block.box(
1.0,
0.0,
1.0,
15.0,
6.0,
15.0
);
public PetBedBlock(Properties properties) {
super(properties);
this.registerDefaultState(
this.stateDefinition.any()
.setValue(FACING, Direction.NORTH)
.setValue(OCCUPIED, false)
);
}
@Override
protected void createBlockStateDefinition(
StateDefinition.Builder<Block, BlockState> builder
) {
builder.add(FACING, OCCUPIED);
}
@Override
public BlockState getStateForPlacement(BlockPlaceContext context) {
return this.defaultBlockState().setValue(
FACING,
context.getHorizontalDirection().getOpposite()
);
}
@Override
public VoxelShape getShape(
BlockState state,
BlockGetter level,
BlockPos pos,
CollisionContext context
) {
return SHAPE;
}
@Override
public RenderShape getRenderShape(BlockState state) {
return RenderShape.ENTITYBLOCK_ANIMATED; // Use block entity renderer
}
@Nullable
@Override
public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return new PetBedBlockEntity(V2BlockEntities.PET_BED.get(), pos, state);
}
@Override
public InteractionResult use(
BlockState state,
Level level,
BlockPos pos,
Player player,
InteractionHand hand,
BlockHitResult hit
) {
if (level.isClientSide) {
return InteractionResult.CONSUME;
}
if (!(player instanceof ServerPlayer sp)) {
return InteractionResult.PASS;
}
// SLEEP -> Stand: player is sleeping, wake them up
if (sp.isSleeping()) {
sp.stopSleeping();
PetBedManager.clearPlayer(sp);
TiedUpMod.LOGGER.debug(
"[PetBedBlock] {} woke up from bed at {}",
player.getName().getString(),
pos
);
return InteractionResult.SUCCESS;
}
// SIT -> SLEEP: player is already sitting on this bed
if (PetBedManager.isOnBed(sp, pos)) {
sp.startSleeping(pos);
// FIX: notify server to count this player for night skip
if (level instanceof ServerLevel sl) {
sl.updateSleepingPlayerList();
}
PetBedManager.setMode(sp, pos, PetBedManager.PetBedMode.SLEEP);
TiedUpMod.LOGGER.debug(
"[PetBedBlock] {} started sleeping in bed at {}",
player.getName().getString(),
pos
);
return InteractionResult.SUCCESS;
}
// Check if bed is already occupied by someone else
BlockEntity be = level.getBlockEntity(pos);
if (be instanceof PetBedBlockEntity petBed && petBed.isOccupied()) {
return InteractionResult.FAIL;
}
// Stand -> SIT
PetBedManager.setMode(sp, pos, PetBedManager.PetBedMode.SIT);
TiedUpMod.LOGGER.debug(
"[PetBedBlock] {} sat down on bed at {}",
player.getName().getString(),
pos
);
return InteractionResult.SUCCESS;
}
@Override
public void setBedOccupied(
BlockState state,
Level level,
BlockPos pos,
net.minecraft.world.entity.LivingEntity sleeper,
boolean occupied
) {
level.setBlock(pos, state.setValue(OCCUPIED, occupied), 3);
}
@Override
public boolean isBed(
BlockState state,
BlockGetter level,
BlockPos pos,
@Nullable net.minecraft.world.entity.Entity player
) {
return true;
}
}

View File

@@ -0,0 +1,148 @@
package com.tiedup.remake.v2.blocks;
import com.tiedup.remake.core.TiedUpMod;
import java.util.UUID;
import javax.annotation.Nullable;
import net.minecraft.core.BlockPos;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.level.block.entity.BlockEntityType;
import net.minecraft.world.level.block.state.BlockState;
/**
* Block entity for Pet Bed.
* Used by Master to make pets sleep. Players must crouch to use it.
*/
public class PetBedBlockEntity extends ObjBlockEntity {
public static final ResourceLocation MODEL_LOCATION =
ResourceLocation.fromNamespaceAndPath(
TiedUpMod.MOD_ID,
"models/obj/blocks/bed/model.obj"
);
public static final ResourceLocation TEXTURE_LOCATION =
ResourceLocation.fromNamespaceAndPath(
TiedUpMod.MOD_ID,
"textures/block/pet_bed.png"
);
/** UUID of the pet currently using this bed (null if empty) */
@Nullable
private UUID occupantUUID = null;
/** UUID of the Master who owns this bed (null = public) */
@Nullable
private UUID ownerUUID = null;
public PetBedBlockEntity(
BlockEntityType<?> type,
BlockPos pos,
BlockState state
) {
super(type, pos, state);
}
@Override
public ResourceLocation getModelLocation() {
return MODEL_LOCATION;
}
@Override
public ResourceLocation getTextureLocation() {
return TEXTURE_LOCATION;
}
@Override
public float getModelScale() {
return 0.8f; // Scale down to fit in block
}
@Override
public float[] getModelOffset() {
return new float[] { 0.0f, 0.0f, 0.0f };
}
// ========================================
// OCCUPANCY
// ========================================
public boolean isOccupied() {
return occupantUUID != null;
}
@Nullable
public UUID getOccupantUUID() {
return occupantUUID;
}
/**
* Have a pet occupy this bed.
*
* @param petUUID The pet's UUID
* @return true if successful, false if already occupied
*/
public boolean occupy(UUID petUUID) {
if (isOccupied()) return false;
this.occupantUUID = petUUID;
setChanged();
return true;
}
/**
* Release the current occupant.
*/
public void release() {
this.occupantUUID = null;
setChanged();
}
// ========================================
// OWNERSHIP
// ========================================
@Nullable
public UUID getOwnerUUID() {
return ownerUUID;
}
public void setOwner(@Nullable UUID masterUUID) {
this.ownerUUID = masterUUID;
setChanged();
}
public boolean isOwnedBy(UUID uuid) {
return ownerUUID != null && ownerUUID.equals(uuid);
}
public boolean isPublic() {
return ownerUUID == null;
}
// ========================================
// NBT
// ========================================
@Override
protected void saveAdditional(CompoundTag tag) {
super.saveAdditional(tag);
if (occupantUUID != null) {
tag.putUUID("occupant", occupantUUID);
}
if (ownerUUID != null) {
tag.putUUID("owner", ownerUUID);
}
}
@Override
public void load(CompoundTag tag) {
super.load(tag);
if (tag.hasUUID("occupant")) {
this.occupantUUID = tag.getUUID("occupant");
}
if (tag.hasUUID("owner")) {
this.ownerUUID = tag.getUUID("owner");
}
}
}

View File

@@ -0,0 +1,278 @@
package com.tiedup.remake.v2.blocks;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.network.ModNetwork;
import com.tiedup.remake.network.sync.PacketSyncPetBedState;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import org.jetbrains.annotations.Nullable;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
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.level.Level;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
/**
* Server-side tracker for pet bed sit/sleep state per player.
* Manages immobilization, block entity occupancy, and client sync.
*/
public class PetBedManager {
public enum PetBedMode {
SIT,
SLEEP,
}
private static final UUID PET_BED_SPEED_MODIFIER_UUID = UUID.fromString(
"a1b2c3d4-e5f6-4789-abcd-ef0123456789"
);
private static final String PET_BED_SPEED_MODIFIER_NAME =
"tiedup.pet_bed_immobilize";
private static final double FULL_IMMOBILIZATION = -0.10;
/** Active pet bed entries keyed by player UUID */
private static final Map<UUID, PetBedEntry> entries =
new ConcurrentHashMap<>();
private static class PetBedEntry {
final BlockPos pos;
PetBedMode mode;
PetBedEntry(BlockPos pos, PetBedMode mode) {
this.pos = pos;
this.mode = mode;
}
}
/**
* Set a player's pet bed mode (SIT or SLEEP).
*/
public static void setMode(
ServerPlayer player,
BlockPos pos,
PetBedMode mode
) {
UUID uuid = player.getUUID();
PetBedEntry existing = entries.get(uuid);
if (existing != null && mode == PetBedMode.SLEEP) {
// Transitioning from SIT to SLEEP — update mode
existing.mode = mode;
} else {
// New entry (Stand → SIT)
entries.put(uuid, new PetBedEntry(pos.immutable(), mode));
}
// Occupy the block entity
BlockEntity be = player.level().getBlockEntity(pos);
if (be instanceof PetBedBlockEntity petBed) {
petBed.occupy(uuid);
}
// Teleport player to bed center and set rotation to bed facing
double cx = pos.getX() + 0.5;
double cy = pos.getY();
double cz = pos.getZ() + 0.5;
// Nudge player slightly backward on the bed
float bedYRot = getBedFacingAngle(player.level(), pos);
double rad = Math.toRadians(bedYRot);
double backOffset = 0.15;
cx += Math.sin(rad) * backOffset;
cz -= Math.cos(rad) * backOffset;
player.teleportTo(cx, cy, cz);
player.setYRot(bedYRot);
player.setYBodyRot(bedYRot);
player.setYHeadRot(bedYRot);
// Immobilize the player (both SIT and SLEEP)
applySpeedModifier(player);
// Sync to all clients
PacketSyncPetBedState packet = new PacketSyncPetBedState(
uuid,
mode == PetBedMode.SIT ? (byte) 1 : (byte) 2,
pos
);
ModNetwork.sendToAllTrackingAndSelf(packet, player);
TiedUpMod.LOGGER.debug(
"[PetBedManager] {} -> {} at {}",
player.getName().getString(),
mode,
pos
);
}
/**
* Clear a player from the pet bed system.
*/
public static void clearPlayer(ServerPlayer player) {
UUID uuid = player.getUUID();
PetBedEntry entry = entries.remove(uuid);
if (entry == null) return;
// Release block entity
BlockEntity be = player.level().getBlockEntity(entry.pos);
if (be instanceof PetBedBlockEntity petBed) {
petBed.release();
}
// Restore speed
removeSpeedModifier(player);
// Sync clear to clients
PacketSyncPetBedState packet = new PacketSyncPetBedState(
uuid,
(byte) 0,
entry.pos
);
ModNetwork.sendToAllTrackingAndSelf(packet, player);
TiedUpMod.LOGGER.debug(
"[PetBedManager] {} cleared from pet bed at {}",
player.getName().getString(),
entry.pos
);
}
/**
* Check if a player is on a pet bed at the given position.
*/
public static boolean isOnBed(ServerPlayer player, BlockPos pos) {
PetBedEntry entry = entries.get(player.getUUID());
return entry != null && entry.pos.equals(pos);
}
/**
* Check if a player is on any pet bed.
*/
public static boolean isOnAnyBed(ServerPlayer player) {
return entries.containsKey(player.getUUID());
}
/**
* Get the current mode for a player, or null if not on a bed.
*/
@Nullable
public static PetBedMode getMode(ServerPlayer player) {
PetBedEntry entry = entries.get(player.getUUID());
return entry != null ? entry.mode : null;
}
/**
* Tick a player to check if pet bed state should be cancelled.
* Called from server-side tick handler.
*/
public static void tickPlayer(ServerPlayer player) {
PetBedEntry entry = entries.get(player.getUUID());
if (entry == null) return;
// SLEEP: detect vanilla wakeup (night skip, etc.)
if (entry.mode == PetBedMode.SLEEP && !player.isSleeping()) {
clearPlayer(player);
return;
}
// Both modes: lock body rotation to bed facing (prevents camera from rotating model)
float bedYRot = getBedFacingAngle(player.level(), entry.pos);
player.setYBodyRot(bedYRot);
// SLEEP: enforce correct Y position (vanilla startSleeping sets Y+0.2)
if (entry.mode == PetBedMode.SLEEP) {
double correctY = entry.pos.getY();
if (Math.abs(player.getY() - correctY) > 0.01) {
player.teleportTo(player.getX(), correctY, player.getZ());
}
return;
}
// SIT: cancel on sneak (like dismounting a vehicle)
if (entry.mode != PetBedMode.SIT) return;
if (player.isShiftKeyDown()) {
clearPlayer(player);
return;
}
// Check if player moved too far from bed
double distSq = player.blockPosition().distSqr(entry.pos);
if (distSq > 2.25) {
// > 1.5 blocks
clearPlayer(player);
return;
}
// Check if the block is still a pet bed
if (
!(player.level().getBlockState(entry.pos).getBlock() instanceof
PetBedBlock)
) {
clearPlayer(player);
}
}
private static float getBedFacingAngle(Level level, BlockPos pos) {
BlockState state = level.getBlockState(pos);
if (state.hasProperty(PetBedBlock.FACING)) {
return state.getValue(PetBedBlock.FACING).toYRot();
}
return 0f;
}
/**
* Clean up when a player disconnects.
*/
public static void onPlayerDisconnect(UUID uuid) {
entries.remove(uuid);
}
/**
* Remove any leftover pet bed speed modifier on login.
* The modifier persists on the entity through save/load, but the entries map doesn't,
* so we clean it up here to prevent the player from being stuck.
*/
public static void onPlayerLogin(ServerPlayer player) {
removeSpeedModifier(player);
}
private static void applySpeedModifier(ServerPlayer player) {
AttributeInstance movementSpeed = player.getAttribute(
Attributes.MOVEMENT_SPEED
);
if (movementSpeed == null) return;
// Remove existing to avoid duplicates
if (movementSpeed.getModifier(PET_BED_SPEED_MODIFIER_UUID) != null) {
movementSpeed.removeModifier(PET_BED_SPEED_MODIFIER_UUID);
}
AttributeModifier modifier = new AttributeModifier(
PET_BED_SPEED_MODIFIER_UUID,
PET_BED_SPEED_MODIFIER_NAME,
FULL_IMMOBILIZATION,
AttributeModifier.Operation.ADDITION
);
movementSpeed.addPermanentModifier(modifier);
}
private static void removeSpeedModifier(ServerPlayer player) {
AttributeInstance movementSpeed = player.getAttribute(
Attributes.MOVEMENT_SPEED
);
if (
movementSpeed != null &&
movementSpeed.getModifier(PET_BED_SPEED_MODIFIER_UUID) != null
) {
movementSpeed.removeModifier(PET_BED_SPEED_MODIFIER_UUID);
}
}
}

View File

@@ -0,0 +1,174 @@
package com.tiedup.remake.v2.blocks;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.v2.V2BlockEntities;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.item.context.BlockPlaceContext;
import net.minecraft.world.level.BlockGetter;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.EntityBlock;
import net.minecraft.world.level.block.HorizontalDirectionalBlock;
import net.minecraft.world.level.block.RenderShape;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.StateDefinition;
import net.minecraft.world.level.block.state.properties.DirectionProperty;
import net.minecraft.world.phys.BlockHitResult;
import net.minecraft.world.phys.shapes.CollisionContext;
import net.minecraft.world.phys.shapes.VoxelShape;
import org.jetbrains.annotations.Nullable;
/**
* Pet Bowl Block - Used by Master to feed pets.
* Features:
* - Right-click with food to fill
* - Crouch + right-click to eat (only for pets)
* - Custom OBJ model rendering
*/
public class PetBowlBlock extends Block implements EntityBlock {
public static final DirectionProperty FACING =
HorizontalDirectionalBlock.FACING;
// Collision shape (rough approximation of bowl)
private static final VoxelShape SHAPE = Block.box(
3.0,
0.0,
3.0,
13.0,
4.0,
13.0
);
public PetBowlBlock(Properties properties) {
super(properties);
this.registerDefaultState(
this.stateDefinition.any().setValue(FACING, Direction.NORTH)
);
}
@Override
protected void createBlockStateDefinition(
StateDefinition.Builder<Block, BlockState> builder
) {
builder.add(FACING);
}
@Override
public BlockState getStateForPlacement(BlockPlaceContext context) {
return this.defaultBlockState().setValue(
FACING,
context.getHorizontalDirection().getOpposite()
);
}
@Override
public VoxelShape getShape(
BlockState state,
BlockGetter level,
BlockPos pos,
CollisionContext context
) {
return SHAPE;
}
@Override
public RenderShape getRenderShape(BlockState state) {
return RenderShape.ENTITYBLOCK_ANIMATED; // Use block entity renderer
}
@Nullable
@Override
public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return new PetBowlBlockEntity(
V2BlockEntities.PET_BOWL.get(),
pos,
state
);
}
@Override
public InteractionResult use(
BlockState state,
Level level,
BlockPos pos,
Player player,
InteractionHand hand,
BlockHitResult hit
) {
if (level.isClientSide) {
return InteractionResult.SUCCESS;
}
BlockEntity be = level.getBlockEntity(pos);
if (!(be instanceof PetBowlBlockEntity bowl)) {
return InteractionResult.PASS;
}
ItemStack heldItem = player.getItemInHand(hand);
// Fill bowl with food items
if (isFood(heldItem)) {
int foodValue = getFoodValue(heldItem);
bowl.fillBowl(foodValue);
if (!player.getAbilities().instabuild) {
heldItem.shrink(1);
}
TiedUpMod.LOGGER.debug(
"[PetBowlBlock] {} filled bowl at {} with {}",
player.getName().getString(),
pos,
heldItem.getItem()
);
return InteractionResult.CONSUME;
}
// Eat from bowl (empty hand)
if (heldItem.isEmpty() && bowl.hasFood()) {
// TODO: Check if player is a pet and allowed to eat
int consumed = bowl.eatFromBowl();
if (consumed > 0) {
player.getFoodData().eat(consumed, 0.6f);
TiedUpMod.LOGGER.debug(
"[PetBowlBlock] {} ate {} from bowl at {}",
player.getName().getString(),
consumed,
pos
);
}
return InteractionResult.CONSUME;
}
return InteractionResult.PASS;
}
private boolean isFood(ItemStack stack) {
if (stack.isEmpty()) return false;
return (
stack.getItem().isEdible() ||
stack.is(Items.WHEAT) ||
stack.is(Items.CARROT) ||
stack.is(Items.APPLE)
);
}
private int getFoodValue(ItemStack stack) {
if (stack.getItem().isEdible()) {
var food = stack.getItem().getFoodProperties();
return food != null ? food.getNutrition() : 2;
}
return 2; // Default for non-standard foods
}
}

View File

@@ -0,0 +1,122 @@
package com.tiedup.remake.v2.blocks;
import com.tiedup.remake.core.TiedUpMod;
import net.minecraft.core.BlockPos;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.level.block.entity.BlockEntityType;
import net.minecraft.world.level.block.state.BlockState;
/**
* Block entity for Pet Bowl.
* Used by Master to feed pets. Players must crouch to eat from it.
*/
public class PetBowlBlockEntity extends ObjBlockEntity {
public static final ResourceLocation MODEL_LOCATION =
ResourceLocation.fromNamespaceAndPath(
TiedUpMod.MOD_ID,
"models/obj/blocks/bowl/model.obj"
);
public static final ResourceLocation TEXTURE_LOCATION =
ResourceLocation.fromNamespaceAndPath(
TiedUpMod.MOD_ID,
"textures/block/pet_bowl.png"
);
/** Whether the bowl currently has food */
private boolean hasFood = false;
/** Food saturation level (0-20) */
private int foodLevel = 0;
public PetBowlBlockEntity(
BlockEntityType<?> type,
BlockPos pos,
BlockState state
) {
super(type, pos, state);
}
@Override
public ResourceLocation getModelLocation() {
return MODEL_LOCATION;
}
@Override
public ResourceLocation getTextureLocation() {
return TEXTURE_LOCATION;
}
@Override
public float getModelScale() {
return 1.0f; // Adjust based on model size
}
// ========================================
// FOOD MANAGEMENT
// ========================================
public boolean hasFood() {
return hasFood && foodLevel > 0;
}
public int getFoodLevel() {
return foodLevel;
}
/**
* Fill the bowl with food.
*
* @param amount Amount of food to add (saturation points)
*/
public void fillBowl(int amount) {
this.foodLevel = Math.min(20, this.foodLevel + amount);
this.hasFood = this.foodLevel > 0;
setChanged();
}
/**
* Pet eats from the bowl.
*
* @return Amount of food consumed (0 if empty)
*/
public int eatFromBowl() {
if (!hasFood()) return 0;
int consumed = Math.min(4, foodLevel); // Eat up to 4 at a time
foodLevel -= consumed;
hasFood = foodLevel > 0;
setChanged();
return consumed;
}
/**
* Empty the bowl completely.
*/
public void emptyBowl() {
this.foodLevel = 0;
this.hasFood = false;
setChanged();
}
// ========================================
// NBT
// ========================================
@Override
protected void saveAdditional(CompoundTag tag) {
super.saveAdditional(tag);
tag.putBoolean("hasFood", hasFood);
tag.putInt("foodLevel", foodLevel);
}
@Override
public void load(CompoundTag tag) {
super.load(tag);
this.hasFood = tag.getBoolean("hasFood");
this.foodLevel = tag.getInt("foodLevel");
}
}

View File

@@ -0,0 +1,538 @@
package com.tiedup.remake.v2.blocks;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.state.IBondageState;
import com.tiedup.remake.util.KidnappedHelper;
import com.tiedup.remake.v2.V2BlockEntities;
import com.tiedup.remake.v2.V2Blocks;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.context.BlockPlaceContext;
import net.minecraft.world.level.BlockGetter;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.EntityBlock;
import net.minecraft.world.level.block.HorizontalDirectionalBlock;
import net.minecraft.world.level.block.RenderShape;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.StateDefinition;
import net.minecraft.world.level.block.state.properties.DirectionProperty;
import net.minecraft.world.phys.BlockHitResult;
import net.minecraft.world.phys.shapes.CollisionContext;
import net.minecraft.world.phys.shapes.Shapes;
import net.minecraft.world.phys.shapes.VoxelShape;
import org.jetbrains.annotations.Nullable;
/**
* Pet Cage Block (Master) - Multi-block cage (3x3x2).
*
* <p>Collision is computed from the actual OBJ model dimensions. Each block in
* the 3x3x2 grid gets collision shapes for any cage walls that pass through it,
* clipped to the block boundaries. This gives pixel-accurate cage collision
* that matches the visual model.
*/
public class PetCageBlock extends Block implements EntityBlock {
public static final DirectionProperty FACING =
HorizontalDirectionalBlock.FACING;
// ========================================
// OBJ MODEL DIMENSIONS (in model space, before rotation/translation)
// ========================================
private static final double MODEL_MIN_X = -1.137;
private static final double MODEL_MAX_X = 1.0;
private static final double MODEL_MIN_Z = -1.122;
private static final double MODEL_MAX_Z = 1.0;
private static final double MODEL_MAX_Y = 2.218;
/** Wall thickness in blocks. */
private static final double WALL_T = 0.125; // 2 pixels
private static final VoxelShape OUTLINE_SHAPE = Block.box(
0,
0,
0,
16,
16,
16
);
public PetCageBlock(Properties properties) {
super(properties);
this.registerDefaultState(
this.stateDefinition.any().setValue(FACING, Direction.NORTH)
);
}
@Override
protected void createBlockStateDefinition(
StateDefinition.Builder<Block, BlockState> builder
) {
builder.add(FACING);
}
@Override
public BlockState getStateForPlacement(BlockPlaceContext context) {
Direction facing = context.getHorizontalDirection().getOpposite();
BlockPos pos = context.getClickedPos();
for (BlockPos partPos : getPartPositions(pos, facing)) {
if (
!context
.getLevel()
.getBlockState(partPos)
.canBeReplaced(context)
) {
return null;
}
}
return this.defaultBlockState().setValue(FACING, facing);
}
@Override
public void onPlace(
BlockState state,
Level level,
BlockPos pos,
BlockState oldState,
boolean isMoving
) {
super.onPlace(state, level, pos, oldState, isMoving);
if (level.isClientSide || oldState.is(this)) return;
Direction facing = state.getValue(FACING);
BlockState partState = V2Blocks.PET_CAGE_PART.get()
.defaultBlockState()
.setValue(PetCagePartBlock.FACING, facing);
for (BlockPos partPos : getPartPositions(pos, facing)) {
level.setBlock(partPos, partState, 3);
}
}
@Override
public VoxelShape getShape(
BlockState state,
BlockGetter level,
BlockPos pos,
CollisionContext context
) {
return OUTLINE_SHAPE;
}
@Override
public VoxelShape getCollisionShape(
BlockState state,
BlockGetter level,
BlockPos pos,
CollisionContext context
) {
Direction facing = state.getValue(FACING);
return computeCageCollision(pos, facing, pos);
}
@Override
public RenderShape getRenderShape(BlockState state) {
return RenderShape.ENTITYBLOCK_ANIMATED;
}
@Nullable
@Override
public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return new PetCageBlockEntity(
V2BlockEntities.PET_CAGE.get(),
pos,
state
);
}
@Override
public InteractionResult use(
BlockState state,
Level level,
BlockPos pos,
Player player,
InteractionHand hand,
BlockHitResult hit
) {
if (level.isClientSide) {
return InteractionResult.CONSUME;
}
if (!(player instanceof ServerPlayer sp)) {
return InteractionResult.PASS;
}
BlockEntity be = level.getBlockEntity(pos);
if (!(be instanceof PetCageBlockEntity cageBE)) {
return InteractionResult.PASS;
}
if (cageBE.hasOccupant()) {
ServerPlayer occupant = cageBE.getOccupant(level.getServer());
if (occupant != null) {
PetCageManager.releasePlayer(occupant);
TiedUpMod.LOGGER.debug(
"[PetCageBlock] {} released {} from cage at {}",
player.getName().getString(),
occupant.getName().getString(),
pos
);
} else {
cageBE.clearOccupant();
}
return InteractionResult.SUCCESS;
}
double cx = pos.getX() + 0.5;
double cy = pos.getY();
double cz = pos.getZ() + 0.5;
for (ServerPlayer nearby : level
.getServer()
.getPlayerList()
.getPlayers()) {
if (nearby == player) continue;
if (nearby.distanceToSqr(cx, cy, cz) > 9.0) continue;
IBondageState kidState = KidnappedHelper.getKidnappedState(nearby);
if (kidState != null && kidState.isTiedUp()) {
PetCageManager.cagePlayer(nearby, pos);
cageBE.setOccupant(nearby.getUUID());
TiedUpMod.LOGGER.debug(
"[PetCageBlock] {} caged {} at {}",
player.getName().getString(),
nearby.getName().getString(),
pos
);
return InteractionResult.SUCCESS;
}
}
return InteractionResult.PASS;
}
@Override
public void onRemove(
BlockState state,
Level level,
BlockPos pos,
BlockState newState,
boolean isMoving
) {
if (!state.is(newState.getBlock())) {
BlockEntity be = level.getBlockEntity(pos);
if (
be instanceof PetCageBlockEntity cageBE &&
cageBE.hasOccupant() &&
level.getServer() != null
) {
ServerPlayer occupant = cageBE.getOccupant(level.getServer());
if (occupant != null) {
PetCageManager.releasePlayer(occupant);
}
}
Direction facing = state.getValue(FACING);
for (BlockPos partPos : getPartPositions(pos, facing)) {
BlockState partState = level.getBlockState(partPos);
if (partState.getBlock() instanceof PetCagePartBlock) {
level.setBlock(
partPos,
Blocks.AIR.defaultBlockState(),
3 | 64
);
}
}
}
super.onRemove(state, level, pos, newState, isMoving);
}
// ========================================
// CAGE COLLISION (model-accurate)
// ========================================
/**
* Compute the cage AABB in world coordinates for a given master position and facing.
* The renderer does translate(0.5, 0, 0.5) then rotate, so vertices are
* first rotated then translated.
*
* @return {minX, minZ, maxX, maxZ, maxY}
*/
public static double[] getCageWorldBounds(
BlockPos masterPos,
Direction facing
) {
double cx = masterPos.getX() + 0.5;
double cz = masterPos.getZ() + 0.5;
double wMinX, wMaxX, wMinZ, wMaxZ;
switch (facing) {
case SOUTH -> {
// 180°: (x,z) → (-x,-z)
wMinX = cx - MODEL_MAX_X;
wMaxX = cx - MODEL_MIN_X;
wMinZ = cz - MODEL_MAX_Z;
wMaxZ = cz - MODEL_MIN_Z;
}
case WEST -> {
// 90°: (x,z) → (z,-x)
wMinX = cx + MODEL_MIN_Z;
wMaxX = cx + MODEL_MAX_Z;
wMinZ = cz - MODEL_MAX_X;
wMaxZ = cz - MODEL_MIN_X;
}
case EAST -> {
// -90°: (x,z) → (-z,x)
wMinX = cx - MODEL_MAX_Z;
wMaxX = cx - MODEL_MIN_Z;
wMinZ = cz + MODEL_MIN_X;
wMaxZ = cz + MODEL_MAX_X;
}
default -> {
// NORTH, 0°: (x,z) → (x,z)
wMinX = cx + MODEL_MIN_X;
wMaxX = cx + MODEL_MAX_X;
wMinZ = cz + MODEL_MIN_Z;
wMaxZ = cz + MODEL_MAX_Z;
}
}
return new double[] {
wMinX,
wMinZ,
wMaxX,
wMaxZ,
masterPos.getY() + MODEL_MAX_Y,
};
}
/**
* Compute the cage collision VoxelShape for any block position within the cage grid.
* Clips the 4 cage walls + floor to the block's boundaries.
*/
public static VoxelShape computeCageCollision(
BlockPos masterPos,
Direction facing,
BlockPos blockPos
) {
double[] bounds = getCageWorldBounds(masterPos, facing);
double cMinX = bounds[0],
cMinZ = bounds[1];
double cMaxX = bounds[2],
cMaxZ = bounds[3];
double cMaxY = bounds[4];
double cMinY = masterPos.getY(); // Cage base = master Y
double bx = blockPos.getX();
double by = blockPos.getY();
double bz = blockPos.getZ();
VoxelShape shape = Shapes.empty();
// Left wall (at cMinX, perpendicular to X) — full height from base to ceiling
shape = addClippedWall(
shape,
cMinX - WALL_T / 2,
cMinY,
cMinZ,
cMinX + WALL_T / 2,
cMaxY,
cMaxZ,
bx,
by,
bz
);
// Right wall (at cMaxX)
shape = addClippedWall(
shape,
cMaxX - WALL_T / 2,
cMinY,
cMinZ,
cMaxX + WALL_T / 2,
cMaxY,
cMaxZ,
bx,
by,
bz
);
// Front wall (at cMinZ, perpendicular to Z)
shape = addClippedWall(
shape,
cMinX,
cMinY,
cMinZ - WALL_T / 2,
cMaxX,
cMaxY,
cMinZ + WALL_T / 2,
bx,
by,
bz
);
// Back wall (at cMaxZ)
shape = addClippedWall(
shape,
cMinX,
cMinY,
cMaxZ - WALL_T / 2,
cMaxX,
cMaxY,
cMaxZ + WALL_T / 2,
bx,
by,
bz
);
// Floor (at cage base only)
shape = addClippedWall(
shape,
cMinX,
cMinY,
cMinZ,
cMaxX,
cMinY + WALL_T,
cMaxZ,
bx,
by,
bz
);
// Ceiling: 1px thin at [1.9375, 2.0] — player head at 1.925 < 1.9375, works from both sides
double ceilY = cMinY + 2.0;
double ceilT = 0.0625; // 1 pixel thin so player (1.8 tall) fits under it
shape = addClippedWall(
shape,
cMinX,
ceilY - ceilT,
cMinZ,
cMaxX,
ceilY,
cMaxZ,
bx,
by,
bz
);
return shape;
}
/**
* Clip a world-space AABB to a block's local bounds and add it as a VoxelShape.
* X and Z are clipped to [0,1] (entities in other blocks won't query this one).
* Y max is allowed to extend above 1.0 (like fences) since entities below
* will still query this block and collide with the extended shape.
*/
private static VoxelShape addClippedWall(
VoxelShape existing,
double wMinX,
double wMinY,
double wMinZ,
double wMaxX,
double wMaxY,
double wMaxZ,
double blockX,
double blockY,
double blockZ
) {
double lMinX = Math.max(0, wMinX - blockX);
double lMinY = Math.max(0, wMinY - blockY);
double lMinZ = Math.max(0, wMinZ - blockZ);
double lMaxX = Math.min(1, wMaxX - blockX);
double lMaxY = Math.min(1.5, wMaxY - blockY); // Allow extending up to 0.5 above block (like fences)
double lMaxZ = Math.min(1, wMaxZ - blockZ);
if (lMinX >= lMaxX || lMinY >= lMaxY || lMinZ >= lMaxZ || lMaxY <= 0) {
return existing;
}
return Shapes.or(
existing,
Shapes.box(lMinX, lMinY, lMinZ, lMaxX, lMaxY, lMaxZ)
);
}
// ========================================
// MULTI-BLOCK LAYOUT (3x3x2)
// ========================================
private static final int[][] GRID_OFFSETS;
static {
java.util.List<int[]> offsets = new java.util.ArrayList<>();
for (int dy = 0; dy <= 1; dy++) {
for (int dl = -1; dl <= 1; dl++) {
for (int df = -1; df <= 1; df++) {
if (dl == 0 && dy == 0 && df == 0) continue;
offsets.add(new int[] { dl, dy, df });
}
}
}
GRID_OFFSETS = offsets.toArray(new int[0][]);
}
public static BlockPos[] getPartPositions(
BlockPos masterPos,
Direction facing
) {
Direction left = facing.getCounterClockWise();
int lx = left.getStepX();
int lz = left.getStepZ();
int fx = facing.getStepX();
int fz = facing.getStepZ();
BlockPos[] positions = new BlockPos[GRID_OFFSETS.length];
for (int i = 0; i < GRID_OFFSETS.length; i++) {
int dl = GRID_OFFSETS[i][0];
int dy = GRID_OFFSETS[i][1];
int df = GRID_OFFSETS[i][2];
int wx = dl * lx + df * fx;
int wz = dl * lz + df * fz;
positions[i] = masterPos.offset(wx, dy, wz);
}
return positions;
}
@Nullable
public static int[] getPartLocalOffset(
BlockPos masterPos,
Direction facing,
BlockPos partPos
) {
Direction left = facing.getCounterClockWise();
int dx = partPos.getX() - masterPos.getX();
int dy = partPos.getY() - masterPos.getY();
int dz = partPos.getZ() - masterPos.getZ();
int lx = left.getStepX(),
lz = left.getStepZ();
int fx = facing.getStepX(),
fz = facing.getStepZ();
int det = lx * fz - fx * lz;
if (det == 0) return null;
int dl = (dx * fz - dz * fx) / det;
int df = (dz * lx - dx * lz) / det;
if (
dl < -1 || dl > 1 || df < -1 || df > 1 || dy < 0 || dy > 1
) return null;
if (dl == 0 && dy == 0 && df == 0) return null;
return new int[] { dl, dy, df };
}
}

View File

@@ -0,0 +1,129 @@
package com.tiedup.remake.v2.blocks;
import com.tiedup.remake.core.TiedUpMod;
import java.util.UUID;
import net.minecraft.core.BlockPos;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.level.block.entity.BlockEntityType;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.AABB;
import org.jetbrains.annotations.Nullable;
/**
* Block entity for Pet Cage.
* Tracks the occupant UUID and provides OBJ model rendering data.
*/
public class PetCageBlockEntity extends ObjBlockEntity {
public static final ResourceLocation MODEL_LOCATION =
ResourceLocation.fromNamespaceAndPath(
TiedUpMod.MOD_ID,
"models/obj/blocks/cage/model.obj"
);
public static final ResourceLocation TEXTURE_LOCATION =
ResourceLocation.fromNamespaceAndPath(
TiedUpMod.MOD_ID,
"textures/block/pet_cage.png"
);
@Nullable
private UUID occupantUUID;
public PetCageBlockEntity(
BlockEntityType<?> type,
BlockPos pos,
BlockState state
) {
super(type, pos, state);
}
@Override
public ResourceLocation getModelLocation() {
return MODEL_LOCATION;
}
@Override
public ResourceLocation getTextureLocation() {
return TEXTURE_LOCATION;
}
@Override
public float getModelScale() {
return 1.0f;
}
/**
* Large render bounding box so the cage doesn't disappear when the camera
* is inside the model (e.g. player caged) or looking away from the master block.
*/
@Override
public AABB getRenderBoundingBox() {
BlockPos p = getBlockPos();
return new AABB(
p.getX() - 2,
p.getY(),
p.getZ() - 2,
p.getX() + 3,
p.getY() + 3,
p.getZ() + 3
);
}
// ========================================
// OCCUPANT MANAGEMENT
// ========================================
public boolean hasOccupant() {
return occupantUUID != null;
}
@Nullable
public UUID getOccupantUUID() {
return occupantUUID;
}
public void setOccupant(UUID uuid) {
this.occupantUUID = uuid;
setChanged();
}
public void clearOccupant() {
this.occupantUUID = null;
setChanged();
}
/**
* Get the occupant ServerPlayer if online.
*/
@Nullable
public ServerPlayer getOccupant(@Nullable MinecraftServer server) {
if (server == null || occupantUUID == null) return null;
return server.getPlayerList().getPlayer(occupantUUID);
}
// ========================================
// NBT
// ========================================
@Override
protected void saveAdditional(CompoundTag tag) {
super.saveAdditional(tag);
if (occupantUUID != null) {
tag.putUUID("occupantUUID", occupantUUID);
}
}
@Override
public void load(CompoundTag tag) {
super.load(tag);
if (tag.hasUUID("occupantUUID")) {
this.occupantUUID = tag.getUUID("occupantUUID");
} else {
this.occupantUUID = null;
}
}
}

View File

@@ -0,0 +1,189 @@
package com.tiedup.remake.v2.blocks;
import com.tiedup.remake.core.TiedUpMod;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import org.jetbrains.annotations.Nullable;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerPlayer;
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.level.block.entity.BlockEntity;
/**
* Server-side manager for pet cage confinement.
* Tracks which players are caged, immobilizes them, and handles cleanup.
*/
public class PetCageManager {
private static final UUID PET_CAGE_SPEED_MODIFIER_UUID = UUID.fromString(
"b2c3d4e5-f6a7-4890-bcde-f01234567890"
);
private static final String PET_CAGE_SPEED_MODIFIER_NAME =
"tiedup.pet_cage_immobilize";
private static final double FULL_IMMOBILIZATION = -0.10;
/** Active cage entries keyed by player UUID */
private static final Map<UUID, CageEntry> entries =
new ConcurrentHashMap<>();
private static class CageEntry {
final BlockPos pos;
CageEntry(BlockPos pos) {
this.pos = pos;
}
}
/**
* Cage a player at the given master block position.
* Teleports them to the center of the 3x3x3 cage and immobilizes.
*/
public static void cagePlayer(ServerPlayer player, BlockPos masterPos) {
UUID uuid = player.getUUID();
entries.put(uuid, new CageEntry(masterPos.immutable()));
// Teleport to cage center (master is at center of 3x3x3 grid)
double[] center = getCageCenter(player, masterPos);
player.teleportTo(center[0], center[1], center[2]);
// Immobilize
applySpeedModifier(player);
TiedUpMod.LOGGER.debug(
"[PetCageManager] {} caged at {}",
player.getName().getString(),
masterPos
);
}
/**
* Release a player from cage confinement.
*/
public static void releasePlayer(ServerPlayer player) {
UUID uuid = player.getUUID();
CageEntry entry = entries.remove(uuid);
if (entry == null) return;
// Release block entity
BlockEntity be = player.level().getBlockEntity(entry.pos);
if (be instanceof PetCageBlockEntity cageBE) {
cageBE.clearOccupant();
}
// Restore speed
removeSpeedModifier(player);
TiedUpMod.LOGGER.debug(
"[PetCageManager] {} released from cage at {}",
player.getName().getString(),
entry.pos
);
}
/**
* Check if a player is in a cage.
*/
public static boolean isInCage(ServerPlayer player) {
return entries.containsKey(player.getUUID());
}
/**
* Get the cage position for a player, or null if not caged.
*/
@Nullable
public static BlockPos getCagePos(ServerPlayer player) {
CageEntry entry = entries.get(player.getUUID());
return entry != null ? entry.pos : null;
}
/**
* Tick a caged player to verify cage still exists.
* Called from server-side tick handler.
*/
public static void tickPlayer(ServerPlayer player) {
CageEntry entry = entries.get(player.getUUID());
if (entry == null) return;
// Check if the block is still a cage
if (
!(player.level().getBlockState(entry.pos).getBlock() instanceof
PetCageBlock)
) {
releasePlayer(player);
return;
}
// Keep player at cage center (prevent movement exploits)
double[] center = getCageCenter(player, entry.pos);
double distSq = player.distanceToSqr(center[0], center[1], center[2]);
if (distSq > 0.25) {
// > 0.5 blocks
player.teleportTo(center[0], center[1], center[2]);
}
}
/**
* Calculate the center of the 3x3x3 cage from the master block position.
* The master is at the center of the grid, so the cage center is the master block center.
*/
private static double[] getCageCenter(
ServerPlayer player,
BlockPos masterPos
) {
return new double[] {
masterPos.getX() + 0.5,
masterPos.getY(),
masterPos.getZ() + 0.5,
};
}
/**
* Clean up when a player disconnects.
*/
public static void onPlayerDisconnect(UUID uuid) {
entries.remove(uuid);
}
/**
* Remove any leftover cage speed modifier on login.
*/
public static void onPlayerLogin(ServerPlayer player) {
removeSpeedModifier(player);
}
private static void applySpeedModifier(ServerPlayer player) {
AttributeInstance movementSpeed = player.getAttribute(
Attributes.MOVEMENT_SPEED
);
if (movementSpeed == null) return;
// Remove existing to avoid duplicates
if (movementSpeed.getModifier(PET_CAGE_SPEED_MODIFIER_UUID) != null) {
movementSpeed.removeModifier(PET_CAGE_SPEED_MODIFIER_UUID);
}
AttributeModifier modifier = new AttributeModifier(
PET_CAGE_SPEED_MODIFIER_UUID,
PET_CAGE_SPEED_MODIFIER_NAME,
FULL_IMMOBILIZATION,
AttributeModifier.Operation.ADDITION
);
movementSpeed.addPermanentModifier(modifier);
}
private static void removeSpeedModifier(ServerPlayer player) {
AttributeInstance movementSpeed = player.getAttribute(
Attributes.MOVEMENT_SPEED
);
if (
movementSpeed != null &&
movementSpeed.getModifier(PET_CAGE_SPEED_MODIFIER_UUID) != null
) {
movementSpeed.removeModifier(PET_CAGE_SPEED_MODIFIER_UUID);
}
}
}

View File

@@ -0,0 +1,169 @@
package com.tiedup.remake.v2.blocks;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.level.BlockGetter;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.HorizontalDirectionalBlock;
import net.minecraft.world.level.block.RenderShape;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.StateDefinition;
import net.minecraft.world.level.block.state.properties.DirectionProperty;
import net.minecraft.world.phys.BlockHitResult;
import net.minecraft.world.phys.shapes.CollisionContext;
import net.minecraft.world.phys.shapes.Shapes;
import net.minecraft.world.phys.shapes.VoxelShape;
import org.jetbrains.annotations.Nullable;
/**
* Pet Cage Part Block (Slave) - Invisible structural part of a 3x3x2 multi-block cage.
*
* <p>No visible model, no selection outline. Provides collision shapes computed
* from the actual OBJ model dimensions (delegated to {@link PetCageBlock#computeCageCollision}).
* Interactions and breaking are delegated to the master.
*/
public class PetCagePartBlock extends Block {
public static final DirectionProperty FACING =
HorizontalDirectionalBlock.FACING;
public PetCagePartBlock(Properties properties) {
super(properties);
this.registerDefaultState(
this.stateDefinition.any().setValue(FACING, Direction.NORTH)
);
}
@Override
protected void createBlockStateDefinition(
StateDefinition.Builder<Block, BlockState> builder
) {
builder.add(FACING);
}
@Override
public VoxelShape getShape(
BlockState state,
BlockGetter level,
BlockPos pos,
CollisionContext context
) {
// Invisible: no outline, no selection box
return Shapes.empty();
}
@Override
public VoxelShape getCollisionShape(
BlockState state,
BlockGetter level,
BlockPos pos,
CollisionContext context
) {
Direction facing = state.getValue(FACING);
BlockPos masterPos = findMasterPos(state, level, pos);
if (masterPos == null) return Shapes.empty();
return PetCageBlock.computeCageCollision(masterPos, facing, pos);
}
@Override
public RenderShape getRenderShape(BlockState state) {
return RenderShape.INVISIBLE;
}
@Override
public InteractionResult use(
BlockState state,
Level level,
BlockPos pos,
Player player,
InteractionHand hand,
BlockHitResult hit
) {
BlockPos masterPos = findMasterPos(state, level, pos);
if (masterPos != null) {
BlockState masterState = level.getBlockState(masterPos);
if (masterState.getBlock() instanceof PetCageBlock cage) {
return cage.use(
masterState,
level,
masterPos,
player,
hand,
hit
);
}
}
return InteractionResult.PASS;
}
@Override
public void onRemove(
BlockState state,
Level level,
BlockPos pos,
BlockState newState,
boolean isMoving
) {
if (!state.is(newState.getBlock())) {
BlockPos masterPos = findMasterPos(state, level, pos);
if (masterPos != null) {
BlockState masterState = level.getBlockState(masterPos);
if (masterState.getBlock() instanceof PetCageBlock) {
level.destroyBlock(masterPos, true);
}
}
}
super.onRemove(state, level, pos, newState, isMoving);
}
@Override
public void playerWillDestroy(
Level level,
BlockPos pos,
BlockState state,
Player player
) {
// Don't call super to prevent double drops
}
@Nullable
private BlockPos findMasterPos(
BlockState partState,
BlockGetter level,
BlockPos partPos
) {
Direction facing = partState.getValue(FACING);
Direction left = facing.getCounterClockWise();
int lx = left.getStepX(),
lz = left.getStepZ();
int fx = facing.getStepX(),
fz = facing.getStepZ();
for (int dy = 0; dy <= 1; dy++) {
for (int dl = -1; dl <= 1; dl++) {
for (int df = -1; df <= 1; df++) {
if (dl == 0 && dy == 0 && df == 0) continue;
int wx = -(dl * lx + df * fx);
int wz = -(dl * lz + df * fz);
BlockPos candidate = partPos.offset(wx, -dy, wz);
BlockState candidateState = level.getBlockState(candidate);
if (
candidateState.getBlock() instanceof PetCageBlock &&
candidateState.getValue(PetCageBlock.FACING) == facing
) {
return candidate;
}
}
}
}
return null;
}
}

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,95 @@
package com.tiedup.remake.v2.client;
import java.util.List;
import net.minecraft.client.renderer.block.model.BakedQuad;
import net.minecraft.client.renderer.block.model.ItemOverrides;
import net.minecraft.client.renderer.block.model.ItemTransforms;
import net.minecraft.client.renderer.texture.TextureAtlasSprite;
import net.minecraft.client.resources.model.BakedModel;
import net.minecraft.core.Direction;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import org.jetbrains.annotations.Nullable;
/**
* Wrapper around a standard {@link BakedModel} that replaces {@link #getOverrides()}
* with a {@link DataDrivenIconOverrides} instance for NBT-based icon switching.
*
* <p>All other model properties (quads, ambient occlusion, transforms, particle icon)
* are delegated to the wrapped original model. This ensures the default appearance
* is preserved when no icon override is active.</p>
*
* <p>Used for both {@code tiedup:data_driven_item} and {@code tiedup:furniture_placer}
* item models.</p>
*/
@OnlyIn(Dist.CLIENT)
public class DataDrivenIconBakedModel implements BakedModel {
private final BakedModel original;
private final DataDrivenIconOverrides overrides;
/**
* @param original the original baked model to wrap (provides quads, transforms, etc.)
* @param overrides the custom overrides that resolve icon models from NBT
*/
public DataDrivenIconBakedModel(BakedModel original, DataDrivenIconOverrides overrides) {
this.original = original;
this.overrides = overrides;
}
/**
* Get the custom icon overrides. This is the key method that enables
* per-stack model switching.
*/
@Override
public ItemOverrides getOverrides() {
return overrides;
}
/**
* Get the custom overrides for cache management (clearing on reload).
*/
public DataDrivenIconOverrides getIconOverrides() {
return overrides;
}
// ===== Delegated methods =====
@Override
public List<BakedQuad> getQuads(@Nullable BlockState state, @Nullable Direction direction, RandomSource random) {
return original.getQuads(state, direction, random);
}
@Override
public boolean useAmbientOcclusion() {
return original.useAmbientOcclusion();
}
@Override
public boolean isGui3d() {
return original.isGui3d();
}
@Override
public boolean usesBlockLight() {
return original.usesBlockLight();
}
@Override
public boolean isCustomRenderer() {
return original.isCustomRenderer();
}
@Override
public TextureAtlasSprite getParticleIcon() {
return original.getParticleIcon();
}
@SuppressWarnings("deprecation")
@Override
public ItemTransforms getTransforms() {
return original.getTransforms();
}
}

View File

@@ -0,0 +1,217 @@
package com.tiedup.remake.v2.client;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemDefinition;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry;
import com.tiedup.remake.v2.furniture.FurnitureDefinition;
import com.tiedup.remake.v2.furniture.FurniturePlacerItem;
import com.tiedup.remake.v2.furniture.FurnitureRegistry;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.jetbrains.annotations.Nullable;
import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.client.renderer.block.model.ItemOverrides;
import net.minecraft.client.resources.model.BakedModel;
import net.minecraft.client.resources.model.ModelResourceLocation;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.entity.LivingEntity;
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;
/**
* Custom {@link ItemOverrides} that switches the rendered item model based on
* NBT-driven icon definitions.
*
* <p>Both {@link com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem} and
* {@link com.tiedup.remake.v2.furniture.FurniturePlacerItem} are singleton Items
* where each stack's behavior varies by NBT. This override reads the item/furniture
* ID from NBT, looks up the corresponding definition's {@code icon} field, and
* resolves the matching {@link BakedModel} from the model registry.</p>
*
* <p>Icon model resolution strategy (tried in order):
* <ol>
* <li>Plain {@link ResourceLocation} lookup (for models registered via
* {@link net.minecraftforge.client.event.ModelEvent.RegisterAdditional})</li>
* <li>{@link ModelResourceLocation} with "inventory" variant (for models baked
* from registered items, e.g., "tiedup:armbinder#inventory")</li>
* </ol>
* Falls back to the original model if no icon is defined or the icon model is not found.</p>
*/
@OnlyIn(Dist.CLIENT)
public class DataDrivenIconOverrides extends ItemOverrides {
private static final Logger LOGGER = LogManager.getLogger("DataDrivenIcons");
/**
* Identifies which type of NBT-driven item this override handles.
*/
public enum Mode {
/** Data-driven bondage items (reads {@code tiedup_item_id} NBT). */
BONDAGE_ITEM,
/** Furniture placer items (reads {@code tiedup_furniture_id} NBT). */
FURNITURE_PLACER
}
private final Mode mode;
/**
* Cache of resolved icon ResourceLocations to their BakedModels.
* Cleared on resource reload (when ModifyBakingResult fires again).
* Uses ConcurrentHashMap because resolve() is called from the render thread.
*
* <p>Values are never null (ConcurrentHashMap forbids nulls). Missing icons
* are tracked separately in {@link #knownMissing}.</p>
*/
private final Map<ResourceLocation, BakedModel> iconModelCache = new ConcurrentHashMap<>();
/**
* Set of icon ResourceLocations that were looked up but not found.
* Prevents repeated failed lookups from hitting the model registry every frame.
*/
private final Map<ResourceLocation, Boolean> knownMissing = new ConcurrentHashMap<>();
/**
* Set of icon ResourceLocations that we already logged a warning for (to avoid log spam).
*/
private final Map<ResourceLocation, Boolean> warnedMissing = new ConcurrentHashMap<>();
public DataDrivenIconOverrides(Mode mode) {
super();
this.mode = mode;
}
@Override
@Nullable
public BakedModel resolve(
BakedModel originalModel,
ItemStack stack,
@Nullable ClientLevel level,
@Nullable LivingEntity entity,
int seed
) {
ResourceLocation iconRL = getIconFromStack(stack);
if (iconRL == null) {
// No icon defined for this variant — use the default model
return originalModel;
}
// Check if already known to be missing
if (knownMissing.containsKey(iconRL)) {
return originalModel;
}
// Check cache for resolved model
BakedModel cached = iconModelCache.get(iconRL);
if (cached != null) {
return cached;
}
// Try to resolve the icon model
BakedModel resolved = lookupIconModel(iconRL);
if (resolved != null) {
iconModelCache.put(iconRL, resolved);
return resolved;
}
// Mark as known missing to avoid repeated lookups
knownMissing.put(iconRL, Boolean.TRUE);
return originalModel;
}
/**
* Read the icon ResourceLocation from the stack's NBT by looking up the definition.
*/
@Nullable
private ResourceLocation getIconFromStack(ItemStack stack) {
if (stack.isEmpty()) return null;
switch (mode) {
case BONDAGE_ITEM: {
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
return def != null ? def.icon() : null;
}
case FURNITURE_PLACER: {
String furnitureIdStr = FurniturePlacerItem.getFurnitureIdFromStack(stack);
if (furnitureIdStr == null) return null;
FurnitureDefinition def = FurnitureRegistry.get(furnitureIdStr);
return def != null ? def.icon() : null;
}
default:
return null;
}
}
/**
* Look up an icon model from the model registry using multiple resolution strategies.
*
* <p>Strategy:
* <ol>
* <li>Try the icon as a plain ResourceLocation (for RegisterAdditional models)</li>
* <li>If the icon path starts with "item/", strip it and try as a
* ModelResourceLocation with "inventory" variant</li>
* </ol>
*
* @param iconRL the icon model ResourceLocation
* @return the resolved BakedModel, or null if not found
*/
@Nullable
private BakedModel lookupIconModel(ResourceLocation iconRL) {
Minecraft mc = Minecraft.getInstance();
if (mc.getModelManager() == null) return null;
BakedModel missingModel = mc.getModelManager().getMissingModel();
// Strategy 1: Plain ResourceLocation lookup
BakedModel model = mc.getModelManager().getModel(iconRL);
if (model != missingModel) {
return model;
}
// Strategy 2: Derive ModelResourceLocation with "inventory" variant
// e.g., "tiedup:item/armbinder" → ModelResourceLocation("tiedup:armbinder", "inventory")
String path = iconRL.getPath();
if (path.startsWith("item/")) {
String itemPath = path.substring("item/".length());
ModelResourceLocation mrl = new ModelResourceLocation(
new ResourceLocation(iconRL.getNamespace(), itemPath),
"inventory"
);
model = mc.getModelManager().getModel(mrl);
if (model != missingModel) {
return model;
}
}
// Strategy 3: Try as-is with "inventory" variant
// e.g., "tiedup:armbinder" → ModelResourceLocation("tiedup:armbinder", "inventory")
if (!path.contains("/")) {
ModelResourceLocation mrl = new ModelResourceLocation(iconRL, "inventory");
model = mc.getModelManager().getModel(mrl);
if (model != missingModel) {
return model;
}
}
// Not found — log once
if (!warnedMissing.containsKey(iconRL)) {
warnedMissing.put(iconRL, Boolean.TRUE);
LOGGER.warn("[DataDrivenIcons] Icon model not found for '{}' (mode={}). "
+ "Ensure a model JSON exists at assets/{}/models/{}.json or the item is registered.",
iconRL, mode, iconRL.getNamespace(), iconRL.getPath());
}
return null;
}
/**
* Clear the icon model cache. Called when models are re-baked (on resource reload).
*/
public void clearCache() {
iconModelCache.clear();
knownMissing.clear();
warnedMissing.clear();
}
}

View File

@@ -0,0 +1,99 @@
package com.tiedup.remake.v2.client;
import com.mojang.blaze3d.vertex.PoseStack;
import com.tiedup.remake.client.renderer.obj.ObjModel;
import com.tiedup.remake.client.renderer.obj.ObjModelRegistry;
import com.tiedup.remake.client.renderer.obj.ObjModelRenderer;
import com.tiedup.remake.v2.blocks.ObjBlockEntity;
import net.minecraft.client.renderer.MultiBufferSource;
import net.minecraft.client.renderer.blockentity.BlockEntityRenderer;
import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider;
import net.minecraft.core.Direction;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.level.block.HorizontalDirectionalBlock;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
/**
* Block entity renderer for OBJ model blocks.
* Uses the existing OBJ rendering system from client/renderer/obj/.
*/
@OnlyIn(Dist.CLIENT)
public class ObjBlockRenderer implements BlockEntityRenderer<ObjBlockEntity> {
public ObjBlockRenderer(BlockEntityRendererProvider.Context context) {}
@Override
public void render(
ObjBlockEntity blockEntity,
float partialTick,
PoseStack poseStack,
MultiBufferSource buffer,
int packedLight,
int packedOverlay
) {
ResourceLocation modelLoc = blockEntity.getModelLocation();
if (modelLoc == null) return;
ObjModel model = ObjModelRegistry.get(modelLoc);
if (model == null) return;
poseStack.pushPose();
// Center on block
poseStack.translate(0.5, 0.0, 0.5);
// Apply block rotation based on facing direction
applyBlockRotation(poseStack, blockEntity.getBlockState());
// Apply custom scale if specified
float scale = blockEntity.getModelScale();
if (scale != 1.0f) {
poseStack.scale(scale, scale, scale);
}
// Apply custom offset if specified
float[] offset = blockEntity.getModelOffset();
if (offset[0] != 0 || offset[1] != 0 || offset[2] != 0) {
poseStack.translate(offset[0], offset[1], offset[2]);
}
// Render using the existing OBJ renderer system
ObjModelRenderer.render(
model,
poseStack,
buffer,
packedLight,
packedOverlay
);
poseStack.popPose();
}
private void applyBlockRotation(
PoseStack poseStack,
BlockState blockState
) {
if (blockState.hasProperty(HorizontalDirectionalBlock.FACING)) {
Direction facing = blockState.getValue(
HorizontalDirectionalBlock.FACING
);
float rotation = switch (facing) {
case NORTH -> 0f;
case SOUTH -> 180f;
case WEST -> 90f;
case EAST -> -90f;
default -> 0f;
};
poseStack.mulPose(
com.mojang.math.Axis.YP.rotationDegrees(rotation)
);
}
}
@Override
public boolean shouldRenderOffScreen(ObjBlockEntity blockEntity) {
return true;
}
}

View File

@@ -0,0 +1,153 @@
package com.tiedup.remake.v2.client;
import com.tiedup.remake.blocks.entity.ModBlockEntities;
import com.tiedup.remake.client.model.CellCoreBakedModel;
import com.tiedup.remake.client.renderer.CellCoreRenderer;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.v2.V2BlockEntities;
import java.util.Map;
import net.minecraft.client.resources.model.BakedModel;
import net.minecraft.client.resources.model.ModelResourceLocation;
import net.minecraft.resources.ResourceLocation;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.client.event.EntityRenderersEvent;
import net.minecraftforge.client.event.ModelEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
/**
* V2 Client-side setup.
* Registers block entity renderers, model replacements, and icon model overrides.
*
* <p>The icon override system allows data-driven bondage items and furniture placers
* to display per-variant inventory sprites. Each JSON definition can specify an
* optional {@code icon} field pointing to a model ResourceLocation. The model
* is resolved at render time from the baked model registry.</p>
*
* <p>Icon models that correspond to existing registered items (e.g., "tiedup:item/armbinder")
* are automatically available. For custom icon models that don't correspond to a registered
* item, place the model JSON under {@code assets/<namespace>/models/item/icons/} and it
* will be registered for baking via {@link ModelEvent.RegisterAdditional}.</p>
*/
@Mod.EventBusSubscriber(
modid = TiedUpMod.MOD_ID,
bus = Mod.EventBusSubscriber.Bus.MOD,
value = Dist.CLIENT
)
public class V2ClientSetup {
@SubscribeEvent
public static void registerRenderers(
EntityRenderersEvent.RegisterRenderers event
) {
// Register OBJ block renderers
event.registerBlockEntityRenderer(
V2BlockEntities.PET_BOWL.get(),
ObjBlockRenderer::new
);
event.registerBlockEntityRenderer(
V2BlockEntities.PET_BED.get(),
ObjBlockRenderer::new
);
event.registerBlockEntityRenderer(
V2BlockEntities.PET_CAGE.get(),
ObjBlockRenderer::new
);
// Register Cell Core pulsing indicator renderer
event.registerBlockEntityRenderer(
ModBlockEntities.CELL_CORE.get(),
CellCoreRenderer::new
);
TiedUpMod.LOGGER.info(
"[V2ClientSetup] Registered block entity renderers"
);
}
@SubscribeEvent
public static void onModifyBakingResult(
ModelEvent.ModifyBakingResult event
) {
// Block model path key (used in model JSON references)
ResourceLocation blockModelLoc = ResourceLocation.fromNamespaceAndPath(
TiedUpMod.MOD_ID,
"block/cell_core"
);
// Blockstate variant key (used by the block renderer to look up models)
ModelResourceLocation stateModelLoc = new ModelResourceLocation(
ResourceLocation.fromNamespaceAndPath(
TiedUpMod.MOD_ID,
"cell_core"
),
""
);
// Find the original model from either key
BakedModel original = event.getModels().get(stateModelLoc);
if (original == null) {
original = event.getModels().get(blockModelLoc);
}
if (original != null) {
CellCoreBakedModel wrapper = new CellCoreBakedModel(original);
event.getModels().put(blockModelLoc, wrapper);
event.getModels().put(stateModelLoc, wrapper);
TiedUpMod.LOGGER.info(
"[V2ClientSetup] Replaced cell_core BakedModel at both model keys"
);
}
// ===== Data-driven item icon overrides =====
wrapItemModelWithIconOverrides(
event.getModels(),
"data_driven_item",
DataDrivenIconOverrides.Mode.BONDAGE_ITEM
);
wrapItemModelWithIconOverrides(
event.getModels(),
"furniture_placer",
DataDrivenIconOverrides.Mode.FURNITURE_PLACER
);
}
/**
* Wrap an item's baked model with a {@link DataDrivenIconBakedModel} that
* switches the rendered model based on NBT icon definitions.
*
* @param models the mutable model registry from {@link ModelEvent.ModifyBakingResult}
* @param itemName the item registry name (e.g., "data_driven_item")
* @param mode the icon override mode (determines which NBT key to read)
*/
private static void wrapItemModelWithIconOverrides(
Map<ResourceLocation, BakedModel> models,
String itemName,
DataDrivenIconOverrides.Mode mode
) {
ModelResourceLocation itemModelLoc = new ModelResourceLocation(
new ResourceLocation(TiedUpMod.MOD_ID, itemName),
"inventory"
);
BakedModel originalItemModel = models.get(itemModelLoc);
if (originalItemModel == null) {
TiedUpMod.LOGGER.warn(
"[V2ClientSetup] Could not find baked model for {} — icon overrides not applied",
itemModelLoc
);
return;
}
DataDrivenIconOverrides overrides = new DataDrivenIconOverrides(mode);
DataDrivenIconBakedModel wrapped = new DataDrivenIconBakedModel(
originalItemModel, overrides
);
models.put(itemModelLoc, wrapped);
TiedUpMod.LOGGER.info(
"[V2ClientSetup] Wrapped {} model with icon overrides (mode={})",
itemName, mode
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,103 @@
package com.tiedup.remake.v2.furniture;
import java.util.List;
import java.util.Map;
import net.minecraft.resources.ResourceLocation;
import org.jetbrains.annotations.Nullable;
/**
* Immutable definition for a data-driven furniture piece.
*
* <p>Loaded from JSON files in {@code data/<namespace>/tiedup_furniture/}.
* Synced to clients via {@code PacketSyncFurnitureDefinitions}.
* Each definition describes placement rules, visual properties,
* seat layout, and interaction feedback for a furniture type.</p>
*
* <p>All rendering and gameplay properties are read from this record at runtime
* via the furniture entity and renderer.</p>
*/
public record FurnitureDefinition(
/** Unique identifier (e.g., "tiedup:wooden_stocks"). */
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,
/** Tint channel defaults: channel name to ARGB color (e.g., "tintable_0" -> 0x8B4513). */
Map<String, Integer> tintChannels,
/** Whether this furniture supports player-applied color customization. */
boolean supportsColor,
/** Collision box width in blocks (X/Z axis). */
float hitboxWidth,
/** Collision box height in blocks (Y axis). */
float hitboxHeight,
/** Whether this furniture snaps to adjacent walls on placement. */
boolean snapToWall,
/** Whether this furniture can only be placed on solid ground. */
boolean floorOnly,
/** Whether this furniture can be locked with a key item. */
boolean lockable,
/** Resistance to breaking (higher = harder to destroy). */
float breakResistance,
/** Whether the furniture drops as an item when broken. */
boolean dropOnBreak,
/** Ordered list of seat definitions. Index is used for bitmask operations. */
List<SeatDefinition> seats,
/** Optional sound overrides for interactions. */
FurnitureFeedback feedback,
/** Grouping category for creative menu / UI filtering (e.g., "restraint", "decoration"). */
String category,
/**
* Optional inventory icon model location (e.g., "tiedup:item/wooden_stocks").
*
* <p>Points to a standard {@code item/generated} model JSON that will be used
* as the inventory sprite for this furniture variant when held as a placer item.
* When null, the default {@code tiedup:item/furniture_placer} model is used.</p>
*/
@Nullable ResourceLocation icon
) {
/**
* Find a seat definition by its unique ID.
*
* @param seatId the seat identifier to search for
* @return the matching {@link SeatDefinition}, or null if not found
*/
@Nullable
public SeatDefinition getSeat(String seatId) {
for (SeatDefinition seat : seats) {
if (seat.id().equals(seatId)) return seat;
}
return null;
}
/**
* Get the positional index of a seat (for bitmask operations on lock state, occupancy, etc.).
*
* @param seatId the seat identifier to search for
* @return the zero-based index, or -1 if not found
*/
public int getSeatIndex(String seatId) {
for (int i = 0; i < seats.size(); i++) {
if (seats.get(i).id().equals(seatId)) return i;
}
return -1;
}
}

View File

@@ -0,0 +1,36 @@
package com.tiedup.remake.v2.furniture;
import net.minecraft.resources.ResourceLocation;
import org.jetbrains.annotations.Nullable;
/**
* Sound events for furniture interactions. All fields are optional
* -- null means "use the default sound" at runtime.
*
* <p>Loaded from the {@code "feedback"} block of a furniture JSON definition.
* When the entire block is absent, {@link #EMPTY} is used.</p>
*/
public record FurnitureFeedback(
/** Sound played when a player mounts the furniture. */
@Nullable ResourceLocation mountSound,
/** Sound played when a seat is locked. */
@Nullable ResourceLocation lockSound,
/** Sound played when a seat is unlocked. */
@Nullable ResourceLocation unlockSound,
/** Looping sound played while a player struggles in a locked seat. */
@Nullable ResourceLocation struggleLoopSound,
/** Sound played on successful escape. */
@Nullable ResourceLocation escapeSound,
/** Sound played when an action is denied (e.g., locked seat interaction). */
@Nullable ResourceLocation deniedSound
) {
/** Empty feedback -- all sounds null (use defaults). */
public static final FurnitureFeedback EMPTY = new FurnitureFeedback(
null, null, null, null, null, null
);
}

View File

@@ -0,0 +1,412 @@
package com.tiedup.remake.v2.furniture;
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 java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.EnumSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
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 FurnitureDefinition} instances.
*
* <p>Uses manual field extraction (not Gson deserialization) for strict
* validation control. Invalid required fields cause the entire definition
* to be rejected; optional fields use safe defaults.</p>
*
* <p>Expected JSON files in {@code data/<namespace>/tiedup_furniture/}.</p>
*/
public final class FurnitureParser {
private static final Logger LOGGER = LogManager.getLogger("TiedUpFurniture");
private static final String TAG = "[FurnitureParser]";
/** Strict hex color pattern: # followed by exactly 6 hex digits. */
private static final Pattern HEX_COLOR = Pattern.compile("^#[0-9A-Fa-f]{6}$");
/** Maximum number of seats per furniture (bitmask limit: 8 bits). */
private static final int MAX_SEATS = 8;
private FurnitureParser() {}
/**
* Parse a JSON input stream into a FurnitureDefinition.
*
* @param input the JSON input stream
* @param fileId the resource location of the source file (for error messages)
* @return the parsed definition, or null if the file is invalid
*/
@Nullable
public static FurnitureDefinition 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("{} Failed to parse JSON {}: {}", TAG, fileId, e.getMessage());
return null;
}
}
/**
* Parse a JsonObject into a FurnitureDefinition.
*
* @param root the parsed JSON object
* @param fileId the resource location of the source file (for error messages)
* @return the parsed definition, or null if validation fails
*/
@Nullable
public static FurnitureDefinition parseObject(JsonObject root, ResourceLocation fileId) {
// --- Required: id ---
String idStr = getStringOrNull(root, "id");
if (idStr == null || idStr.isEmpty()) {
LOGGER.error("{} Skipping {}: missing 'id'", TAG, fileId);
return null;
}
ResourceLocation id = ResourceLocation.tryParse(idStr);
if (id == null) {
LOGGER.error("{} Skipping {}: invalid id ResourceLocation '{}'", TAG, fileId, idStr);
return null;
}
// --- Required: display_name ---
String displayName = getStringOrNull(root, "display_name");
if (displayName == null || displayName.isEmpty()) {
LOGGER.error("{} Skipping {}: missing 'display_name'", TAG, 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("{} Skipping {}: missing 'model'", TAG, fileId);
return null;
}
ResourceLocation modelLocation = ResourceLocation.tryParse(modelStr);
if (modelLocation == null) {
LOGGER.error("{} Skipping {}: invalid model ResourceLocation '{}'", TAG, fileId, modelStr);
return null;
}
// --- Optional: tint_channels (strict hex validation) ---
Map<String, Integer> tintChannels = parseTintChannels(root, fileId);
if (tintChannels == null) {
// parseTintChannels returns null on invalid hex -> reject entire furniture
return null;
}
// --- Optional: supports_color (default false) ---
boolean supportsColor = getBooleanOrDefault(root, "supports_color", false);
// --- Optional: hitbox (defaults: 1.0 x 1.0, clamped [0.1, 5.0]) ---
float hitboxWidth = 1.0f;
float hitboxHeight = 1.0f;
if (root.has("hitbox") && root.get("hitbox").isJsonObject()) {
JsonObject hitbox = root.getAsJsonObject("hitbox");
hitboxWidth = clamp(getFloatOrDefault(hitbox, "width", 1.0f), 0.1f, 5.0f);
hitboxHeight = clamp(getFloatOrDefault(hitbox, "height", 1.0f), 0.1f, 5.0f);
}
// --- Optional: placement ---
boolean snapToWall = false;
boolean floorOnly = true;
if (root.has("placement") && root.get("placement").isJsonObject()) {
JsonObject placement = root.getAsJsonObject("placement");
snapToWall = getBooleanOrDefault(placement, "snap_to_wall", false);
floorOnly = getBooleanOrDefault(placement, "floor_only", true);
}
// --- Optional: lockable (default false) ---
boolean lockable = getBooleanOrDefault(root, "lockable", false);
// --- Optional: break_resistance (default 100, clamped [1, 10000]) ---
float breakResistance = clamp(getFloatOrDefault(root, "break_resistance", 100.0f), 1.0f, 10000.0f);
// --- Optional: drop_on_break (default true) ---
boolean dropOnBreak = getBooleanOrDefault(root, "drop_on_break", true);
// --- Required: seats (non-empty array, size [1, 8]) ---
if (!root.has("seats") || !root.get("seats").isJsonArray()) {
LOGGER.error("{} Skipping {}: missing or invalid 'seats' array", TAG, fileId);
return null;
}
JsonArray seatsArray = root.getAsJsonArray("seats");
if (seatsArray.isEmpty()) {
LOGGER.error("{} Skipping {}: 'seats' array is empty", TAG, fileId);
return null;
}
if (seatsArray.size() > MAX_SEATS) {
LOGGER.error("{} Skipping {}: 'seats' array has {} entries (max {})",
TAG, fileId, seatsArray.size(), MAX_SEATS);
return null;
}
List<SeatDefinition> seats = new ArrayList<>(seatsArray.size());
for (int i = 0; i < seatsArray.size(); i++) {
if (!seatsArray.get(i).isJsonObject()) {
LOGGER.error("{} Skipping {}: seats[{}] is not a JSON object", TAG, fileId, i);
return null;
}
SeatDefinition seat = parseSeat(seatsArray.get(i).getAsJsonObject(), i, lockable, fileId);
if (seat == null) {
// parseSeat already logged the error
return null;
}
seats.add(seat);
}
// --- Optional: feedback ---
FurnitureFeedback feedback = FurnitureFeedback.EMPTY;
if (root.has("feedback") && root.get("feedback").isJsonObject()) {
feedback = parseFeedback(root.getAsJsonObject("feedback"), fileId);
}
// --- Optional: category (default "furniture") ---
String category = getStringOrDefault(root, "category", "furniture");
// --- Optional: icon (item model ResourceLocation for inventory sprite) ---
ResourceLocation icon = parseOptionalResourceLocation(root, "icon", fileId);
return new FurnitureDefinition(
id, displayName, translationKey, modelLocation,
tintChannels, supportsColor,
hitboxWidth, hitboxHeight,
snapToWall, floorOnly,
lockable, breakResistance, dropOnBreak,
seats, feedback, category, icon
);
}
// ===== Seat Parsing =====
/**
* Parse a single seat JSON object.
*
* @param obj the seat JSON object
* @param index the seat index (for error messages)
* @param parentLockable the top-level lockable value (used as default)
* @param fileId the source file (for error messages)
* @return the parsed seat, or null on validation failure
*/
@Nullable
private static SeatDefinition parseSeat(JsonObject obj, int index,
boolean parentLockable,
ResourceLocation fileId) {
// Required: id (must not contain ':')
String seatId = getStringOrNull(obj, "id");
if (seatId == null || seatId.isEmpty()) {
LOGGER.error("{} Skipping {}: seats[{}] missing 'id'", TAG, fileId, index);
return null;
}
if (seatId.contains(":")) {
LOGGER.error("{} Skipping {}: seats[{}] id '{}' must not contain ':'",
TAG, fileId, index, seatId);
return null;
}
// Required: armature
String armature = getStringOrNull(obj, "armature");
if (armature == null || armature.isEmpty()) {
LOGGER.error("{} Skipping {}: seats[{}] missing 'armature'", TAG, fileId, index);
return null;
}
// Optional: blocked_regions (unknown region = fatal for entire furniture)
Set<BodyRegionV2> blockedRegions = parseBlockedRegions(obj, index, fileId);
if (blockedRegions == null) {
// parseBlockedRegions returns null ONLY on unknown region name (fatal)
return null;
}
// Optional: lockable (inherits from top-level)
boolean seatLockable = getBooleanOrDefault(obj, "lockable", parentLockable);
// Optional: locked_difficulty (clamped [1, 10000], default 1)
int lockedDifficulty = clampInt(getIntOrDefault(obj, "locked_difficulty", 1), 1, 10000);
// Optional: item_difficulty_bonus (default false)
boolean itemDifficultyBonus = getBooleanOrDefault(obj, "item_difficulty_bonus", false);
return new SeatDefinition(
seatId, armature, blockedRegions,
seatLockable, lockedDifficulty, itemDifficultyBonus
);
}
/**
* Parse blocked_regions for a seat. Returns empty set if field is absent.
* Returns null (fatal) if any region name is unknown.
*/
@Nullable
private static Set<BodyRegionV2> parseBlockedRegions(JsonObject obj, int seatIndex,
ResourceLocation fileId) {
if (!obj.has("blocked_regions") || !obj.get("blocked_regions").isJsonArray()) {
return Collections.unmodifiableSet(EnumSet.noneOf(BodyRegionV2.class));
}
JsonArray arr = obj.getAsJsonArray("blocked_regions");
if (arr.isEmpty()) {
return Collections.unmodifiableSet(EnumSet.noneOf(BodyRegionV2.class));
}
EnumSet<BodyRegionV2> regions = EnumSet.noneOf(BodyRegionV2.class);
for (JsonElement elem : arr) {
String name;
try {
name = elem.getAsString().toUpperCase();
} catch (Exception e) {
LOGGER.error("{} Skipping {}: seats[{}] invalid element in 'blocked_regions': {}",
TAG, fileId, seatIndex, e.getMessage());
return null;
}
BodyRegionV2 region = BodyRegionV2.fromName(name);
if (region == null) {
LOGGER.error("{} Skipping {}: seats[{}] unknown body region '{}'",
TAG, fileId, seatIndex, name);
return null;
}
regions.add(region);
}
return Collections.unmodifiableSet(regions);
}
// ===== Feedback Parsing =====
private static FurnitureFeedback parseFeedback(JsonObject obj, ResourceLocation fileId) {
return new FurnitureFeedback(
parseOptionalResourceLocation(obj, "mount_sound", fileId),
parseOptionalResourceLocation(obj, "lock_sound", fileId),
parseOptionalResourceLocation(obj, "unlock_sound", fileId),
parseOptionalResourceLocation(obj, "struggle_loop_sound", fileId),
parseOptionalResourceLocation(obj, "escape_sound", fileId),
parseOptionalResourceLocation(obj, "denied_sound", fileId)
);
}
// ===== Tint Channel Parsing =====
/**
* Parse tint_channels with strict hex validation.
* Returns empty map if field is absent. Returns null if any value is invalid hex.
*/
@Nullable
private static Map<String, Integer> parseTintChannels(JsonObject root, ResourceLocation fileId) {
if (!root.has("tint_channels") || !root.get("tint_channels").isJsonObject()) {
return Map.of();
}
JsonObject channels = root.getAsJsonObject("tint_channels");
Map<String, Integer> result = new LinkedHashMap<>();
for (Map.Entry<String, JsonElement> entry : channels.entrySet()) {
String hex;
try {
hex = entry.getValue().getAsString();
} catch (Exception e) {
LOGGER.error("{} Skipping {}: tint_channels '{}' value is not a string",
TAG, fileId, entry.getKey());
return null;
}
if (!HEX_COLOR.matcher(hex).matches()) {
LOGGER.error("{} Skipping {}: tint_channels '{}' has invalid hex color '{}' "
+ "(expected '#' followed by 6 hex digits)",
TAG, fileId, entry.getKey(), hex);
return null;
}
int color = Integer.parseInt(hex.substring(1), 16);
result.put(entry.getKey(), color);
}
return Collections.unmodifiableMap(result);
}
// ===== Primitive Helpers =====
@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 String getStringOrDefault(JsonObject obj, String key, String defaultValue) {
String value = getStringOrNull(obj, key);
return (value != null && !value.isEmpty()) ? value : defaultValue;
}
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 float getFloatOrDefault(JsonObject obj, String key, float defaultValue) {
if (!obj.has(key) || obj.get(key).isJsonNull()) return defaultValue;
try {
return obj.get(key).getAsFloat();
} 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 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("{} In {}: invalid ResourceLocation for '{}': '{}'", TAG, fileId, key, value);
}
return loc;
}
// ===== Clamping Helpers =====
private static float clamp(float value, float min, float max) {
return Math.max(min, Math.min(max, value));
}
private static int clampInt(int value, int min, int max) {
return Math.max(min, Math.min(max, value));
}
}

View File

@@ -0,0 +1,180 @@
package com.tiedup.remake.v2.furniture;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.entities.ModEntities;
import com.tiedup.remake.v2.bondage.V2BondageItems;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.context.UseOnContext;
import net.minecraft.world.level.Level;
import net.minecraft.world.phys.Vec3;
import org.jetbrains.annotations.Nullable;
/**
* Singleton item that spawns {@link EntityFurniture} on right-click.
*
* <p>Each ItemStack carries a {@link FurnitureRegistry#NBT_FURNITURE_ID} NBT tag
* that determines which furniture definition to use. This follows the same pattern
* as {@link com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem} where
* a single registered Item serves all data-driven variants.</p>
*
* <p>On use, the item reads the furniture ID from NBT, validates that a
* {@link FurnitureDefinition} exists in the registry, spawns an
* {@link EntityFurniture} at the clicked position, and consumes the item
* (unless in creative mode).</p>
*/
public class FurniturePlacerItem extends Item {
public FurniturePlacerItem() {
super(new Properties().stacksTo(1));
}
// ===== PLACEMENT =====
@Override
public InteractionResult useOn(UseOnContext context) {
Level level = context.getLevel();
if (level.isClientSide) {
return InteractionResult.SUCCESS;
}
ItemStack stack = context.getItemInHand();
String furnitureIdStr = getFurnitureIdFromStack(stack);
if (furnitureIdStr == null) {
return InteractionResult.FAIL;
}
// Validate definition exists
FurnitureDefinition def = FurnitureRegistry.get(furnitureIdStr);
if (def == null) {
TiedUpMod.LOGGER.warn(
"[FurniturePlacerItem] Unknown furniture ID '{}', cannot place",
furnitureIdStr
);
return InteractionResult.FAIL;
}
// Calculate placement position based on clicked face
BlockPos clickedPos = context.getClickedPos();
Direction face = context.getClickedFace();
Vec3 spawnPos;
if (face == Direction.UP) {
// Clicked top of a block: place on top of it
spawnPos = Vec3.atBottomCenterOf(clickedPos.above());
} else if (face == Direction.DOWN) {
// Clicked bottom of a block: place below it
spawnPos = Vec3.atBottomCenterOf(clickedPos.below());
} else {
// Clicked a side: place adjacent to the clicked face
spawnPos = Vec3.atBottomCenterOf(clickedPos.relative(face));
}
// Check floor_only placement restriction: must have solid ground below
BlockPos spawnBlockPos = BlockPos.containing(spawnPos);
if (def.floorOnly()) {
BlockPos below = spawnBlockPos.below();
if (!level.getBlockState(below).isSolidRender(level, below)) {
return InteractionResult.FAIL;
}
}
// Spawn the furniture entity
EntityFurniture furniture = new EntityFurniture(
ModEntities.FURNITURE.get(), level
);
furniture.setFurnitureId(furnitureIdStr);
furniture.moveTo(spawnPos.x, spawnPos.y, spawnPos.z);
// Face the same direction as the player (rounded to nearest 90 degrees)
float yaw = 0f;
if (context.getPlayer() != null) {
float playerYaw = context.getPlayer().getYRot();
yaw = Math.round(playerYaw / 90.0f) * 90.0f;
}
// Snap to wall: if enabled, check 4 cardinal directions for an adjacent wall
// and rotate the furniture to face it (back against wall), overriding player yaw.
if (def.snapToWall()) {
Direction[] directions = {Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST};
for (Direction dir : directions) {
BlockPos wallPos = spawnBlockPos.relative(dir);
if (level.getBlockState(wallPos).isFaceSturdy(level, wallPos, dir.getOpposite())) {
yaw = dir.toYRot();
break;
}
}
}
furniture.setYRot(yaw);
level.addFreshEntity(furniture);
// Consume the item (unless creative)
if (context.getPlayer() != null && !context.getPlayer().isCreative()) {
stack.shrink(1);
}
return InteractionResult.CONSUME;
}
// ===== DISPLAY NAME =====
@Override
public Component getName(ItemStack stack) {
String furnitureIdStr = getFurnitureIdFromStack(stack);
if (furnitureIdStr == null) {
return super.getName(stack);
}
FurnitureDefinition def = FurnitureRegistry.get(furnitureIdStr);
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 specific furniture type.
*
* @param furnitureId the definition ID (must exist in {@link FurnitureRegistry})
* @return a new ItemStack with the {@link FurnitureRegistry#NBT_FURNITURE_ID} NBT tag set,
* or {@link ItemStack#EMPTY} if the placer item is not yet registered
*/
public static ItemStack createStack(ResourceLocation furnitureId) {
if (V2BondageItems.FURNITURE_PLACER == null) return ItemStack.EMPTY;
ItemStack stack = new ItemStack(V2BondageItems.FURNITURE_PLACER.get());
stack.getOrCreateTag().putString(
FurnitureRegistry.NBT_FURNITURE_ID, furnitureId.toString()
);
return stack;
}
// ===== HELPERS =====
/**
* Read the furniture definition ID string from an ItemStack's NBT.
*
* @param stack the item stack to read from
* @return the furniture ID string, or null if the tag is missing or empty
*/
@Nullable
public static String getFurnitureIdFromStack(ItemStack stack) {
if (stack.isEmpty() || !stack.hasTag()) return null;
// noinspection DataFlowIssue -- hasTag() guarantees non-null
if (!stack.getTag().contains(FurnitureRegistry.NBT_FURNITURE_ID, 8)) return null;
String id = stack.getTag().getString(FurnitureRegistry.NBT_FURNITURE_ID);
return id.isEmpty() ? null : id;
}
}

View File

@@ -0,0 +1,99 @@
package com.tiedup.remake.v2.furniture;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import net.minecraft.resources.ResourceLocation;
import org.jetbrains.annotations.Nullable;
/**
* Thread-safe registry for data-driven furniture definitions.
*
* <p>Server-authoritative: definitions are loaded from {@code data/<namespace>/tiedup_furniture/}
* JSON files by the server reload listener, then synced to clients via
* {@code PacketSyncFurnitureDefinitions}. Unlike {@link
* com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry}, there is no {@code mergeAll()}
* because furniture definitions have a single source of truth (server data pack).</p>
*
* <p>Uses volatile atomic swap to ensure the render thread and network threads
* always see a consistent snapshot of definitions.</p>
*
* @see FurnitureDefinition
*/
public final class FurnitureRegistry {
/** NBT key storing the furniture definition ID on furniture entities. */
public static final String NBT_FURNITURE_ID = "tiedup_furniture_id";
/**
* Volatile reference to an unmodifiable map. {@link #reload} builds a new map
* and swaps atomically; consumer threads always see a consistent snapshot.
*/
private static volatile Map<ResourceLocation, FurnitureDefinition> DEFINITIONS = Map.of();
private FurnitureRegistry() {}
/**
* Atomically replace all definitions with a new set.
* Called by the reload listener after parsing all JSON files,
* and by the client sync packet handler after receiving server definitions.
*
* @param newDefs the new definitions map (will be defensively copied)
*/
public static void reload(Map<ResourceLocation, FurnitureDefinition> newDefs) {
DEFINITIONS = Collections.unmodifiableMap(new HashMap<>(newDefs));
}
/**
* Get a definition by its unique ID.
*
* @param id the definition ID (e.g., "tiedup:wooden_stocks")
* @return the definition, or null if not found
*/
@Nullable
public static FurnitureDefinition get(ResourceLocation id) {
return DEFINITIONS.get(id);
}
/**
* Lookup a definition by string ID (from SyncedEntityData or NBT).
*
* @param furnitureIdStr the string form of the ResourceLocation, or null/empty
* @return the definition, or null if the string is blank, unparseable, or unknown
*/
@Nullable
public static FurnitureDefinition get(String furnitureIdStr) {
if (furnitureIdStr == null || furnitureIdStr.isEmpty()) return null;
ResourceLocation id = ResourceLocation.tryParse(furnitureIdStr);
return id != null ? DEFINITIONS.get(id) : null;
}
/**
* Get all registered definitions.
*
* @return unmodifiable collection of all definitions
*/
public static Collection<FurnitureDefinition> getAll() {
return DEFINITIONS.values();
}
/**
* Full map snapshot for packet serialization.
*
* <p>The returned map is the same unmodifiable reference held internally,
* so it is safe to iterate during packet encoding without copying.</p>
*
* @return unmodifiable map of all definitions keyed by ID
*/
public static Map<ResourceLocation, FurnitureDefinition> getAllMap() {
return DEFINITIONS;
}
/**
* Clear all definitions. Called on world unload or for testing.
*/
public static void clear() {
DEFINITIONS = Map.of();
}
}

View File

@@ -0,0 +1,88 @@
package com.tiedup.remake.v2.furniture;
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_furniture/}
* for JSON files and populates the {@link FurnitureRegistry}.
*
* <p>Unlike the data-driven item system (which has both client and server listeners),
* furniture definitions are server-authoritative only. The registry is atomically
* replaced via {@link FurnitureRegistry#reload(Map)} on each reload.</p>
*
* <p>Registered via {@link net.minecraftforge.event.AddReloadListenerEvent} in
* {@link com.tiedup.remake.core.TiedUpMod.ForgeEvents}.</p>
*/
public class FurnitureServerReloadListener extends SimplePreparableReloadListener<Void> {
private static final Logger LOGGER = LogManager.getLogger("TiedUpFurniture");
/** Resource directory containing furniture definition JSON files (under data/). */
private static final String DIRECTORY = "tiedup_furniture";
@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, FurnitureDefinition> 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()) {
FurnitureDefinition def = FurnitureParser.parse(input, fileId);
if (def != null) {
// Check for duplicate IDs
if (newDefs.containsKey(def.id())) {
LOGGER.warn("[TiedUpFurniture] Server: Duplicate furniture ID '{}' from file '{}' -- overwriting previous definition",
def.id(), fileId);
}
newDefs.put(def.id(), def);
LOGGER.debug("[TiedUpFurniture] Server loaded: {} -> '{}'", def.id(), def.displayName());
} else {
skipped++;
}
} catch (Exception e) {
LOGGER.error("[TiedUpFurniture] Server: Failed to read resource {}: {}", fileId, e.getMessage());
skipped++;
}
}
// Atomically replace all definitions in the registry
FurnitureRegistry.reload(newDefs);
// Broadcast updated definitions to all connected players
net.minecraft.server.MinecraftServer server =
net.minecraftforge.server.ServerLifecycleHooks.getCurrentServer();
if (server != null) {
for (net.minecraft.server.level.ServerPlayer p : server.getPlayerList().getPlayers()) {
com.tiedup.remake.v2.furniture.network.PacketSyncFurnitureDefinitions.sendToPlayer(p);
}
}
LOGGER.info("[TiedUpFurniture] Server loaded {} furniture definitions ({} skipped) from {} JSON files",
newDefs.size(), skipped, resources.size());
}
}

View File

@@ -0,0 +1,50 @@
package com.tiedup.remake.v2.furniture;
import com.tiedup.remake.v2.BodyRegionV2;
import java.util.List;
import java.util.Set;
import net.minecraft.world.entity.Entity;
import org.jetbrains.annotations.Nullable;
/**
* Universal interface for entities that hold players in constrained poses.
*
* <p>Implemented by EntityFurniture (static) and optionally by monsters/NPCs.
* All downstream systems (packets, animation, rendering) check ISeatProvider,
* never EntityFurniture directly.</p>
*/
public interface ISeatProvider {
/** All seat definitions for this entity. */
List<SeatDefinition> getSeats();
/** Which seat is this passenger in? Null if not seated. */
@Nullable
SeatDefinition getSeatForPassenger(Entity passenger);
/** Assign a passenger to a specific seat. */
void assignSeat(Entity passenger, String seatId);
/** Release a passenger's seat assignment. */
void releaseSeat(Entity passenger);
/** Is this specific seat locked? */
boolean isSeatLocked(String seatId);
/** Lock/unlock a specific seat. */
void setSeatLocked(String seatId, boolean locked);
/** The locked difficulty for this seat. */
int getLockedDifficulty(String seatId);
/** Blocked body regions for a specific seat. */
Set<BodyRegionV2> getBlockedRegions(String seatId);
/** Whether items on non-blocked regions add to escape difficulty. */
boolean hasItemDifficultyBonus(String seatId);
/** Convenience: get the entity this interface is attached to. */
default Entity asEntity() {
return (Entity) this;
}
}

View File

@@ -0,0 +1,31 @@
package com.tiedup.remake.v2.furniture;
import com.tiedup.remake.v2.BodyRegionV2;
import java.util.Set;
/**
* Immutable definition for a single seat on a furniture piece or monster.
*
* <p>Loaded from JSON (furniture) or defined programmatically (monsters).
* Each seat describes the physical constraints imposed on a seated player,
* including which body regions are blocked and escape difficulty.</p>
*/
public record SeatDefinition(
/** Unique seat identifier within this furniture (e.g., "main", "left"). */
String id,
/** GLB armature name (e.g., "Player_main"). */
String armatureName,
/** Body regions physically controlled by this seat. */
Set<BodyRegionV2> blockedRegions,
/** Whether this seat can be locked with a key. */
boolean lockable,
/** Struggle difficulty when locked (raw resistance, range 1-10000). */
int lockedDifficulty,
/** Whether items on non-blocked regions add to escape difficulty. */
boolean itemDifficultyBonus
) {}

View File

@@ -0,0 +1,146 @@
package com.tiedup.remake.v2.furniture.client;
import com.tiedup.remake.client.animation.context.RegionBoneMapper;
import com.tiedup.remake.client.gltf.GltfData;
import com.tiedup.remake.client.gltf.GltfPoseConverter;
import com.tiedup.remake.v2.BodyRegionV2;
import dev.kosmx.playerAnim.core.data.KeyframeAnimation;
import java.util.HashSet;
import java.util.Set;
import org.jetbrains.annotations.Nullable;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
/**
* Builds a {@link KeyframeAnimation} for a player seated on furniture.
*
* <p>The furniture animation layer sits at priority 43 (above the item layer at 42),
* so it wins on shared bones. To allow bondage items on non-blocked regions to still
* animate (e.g., a gag on the head while the chair blocks arms+legs), this factory
* enables ONLY the bones corresponding to the seat's blocked regions and disables
* all others. Disabled parts pass through to the lower-priority item layer.</p>
*
* <p>Conversion flow:
* <ol>
* <li>Convert the raw glTF animation clip to a PlayerAnimator {@link KeyframeAnimation}
* using {@link GltfPoseConverter#convertWithSkeleton}</li>
* <li>Create a mutable copy of the animation</li>
* <li>Map blocked {@link BodyRegionV2}s to PlayerAnimator bone names via
* {@link RegionBoneMapper#getPartsForRegion}</li>
* <li>Disable all bones NOT in the blocked set</li>
* <li>Build and return the immutable result</li>
* </ol>
*
* <p>In V1 (seat skeleton not yet parsed), returns null. Furniture animation
* requires V2 skeleton parsing to provide the rest pose data needed by
* {@link GltfPoseConverter}.</p>
*
* @see com.tiedup.remake.client.animation.BondageAnimationManager#playFurniture
* @see RegionBoneMapper
*/
@OnlyIn(Dist.CLIENT)
public final class FurnitureAnimationContext {
private static final Logger LOGGER = LogManager.getLogger("FurnitureAnimation");
private FurnitureAnimationContext() {}
/**
* Create a KeyframeAnimation for a player seated on furniture.
* Enables ONLY bones in blocked regions, disables all others.
*
* @param seatClip the seat animation clip (from
* {@link FurnitureGltfData#seatAnimations()})
* @param seatSkeleton the seat skeleton data providing rest pose and joint names
* (from {@link FurnitureGltfData#seatSkeletons()}); null in V1
* @param blockedRegions the body regions the furniture controls for this seat
* @return a KeyframeAnimation with only blocked-region bones enabled, or null if
* the skeleton is unavailable (V1) or conversion fails
*/
@Nullable
public static KeyframeAnimation create(
GltfData.AnimationClip seatClip,
@Nullable GltfData seatSkeleton,
Set<BodyRegionV2> blockedRegions) {
if (seatClip == null) {
LOGGER.warn("[FurnitureAnim] Cannot create animation: seatClip is null");
return null;
}
if (seatSkeleton == null) {
// V1: skeleton parsing not yet implemented. Furniture animation requires
// rest pose data for glTF-to-PlayerAnimator conversion.
LOGGER.debug("[FurnitureAnim] Seat skeleton unavailable (V1), skipping animation");
return null;
}
if (blockedRegions == null || blockedRegions.isEmpty()) {
LOGGER.debug("[FurnitureAnim] No blocked regions, skipping animation");
return null;
}
// Step 1: Convert the raw clip to a full KeyframeAnimation using skeleton data.
// convertWithSkeleton returns an animation with ALL parts enabled.
KeyframeAnimation fullAnim;
try {
fullAnim = GltfPoseConverter.convertWithSkeleton(
seatSkeleton, seatClip, "furniture_seat");
} catch (Exception e) {
LOGGER.error("[FurnitureAnim] Failed to convert seat animation clip", e);
return null;
}
// Step 2: Compute which PlayerAnimator parts correspond to blocked regions.
Set<String> blockedParts = new HashSet<>();
for (BodyRegionV2 region : blockedRegions) {
blockedParts.addAll(RegionBoneMapper.getPartsForRegion(region));
}
if (blockedParts.isEmpty()) {
// Blocked regions don't map to any animation bones (e.g., only NECK/FINGERS/TAIL/WINGS)
LOGGER.debug("[FurnitureAnim] Blocked regions {} map to zero bones, skipping", blockedRegions);
return null;
}
// Step 3: Compute disabled parts = ALL parts MINUS blocked parts.
Set<String> disabledParts = new HashSet<>(RegionBoneMapper.ALL_PARTS);
disabledParts.removeAll(blockedParts);
if (disabledParts.isEmpty()) {
// All parts are blocked by the furniture -- return the full animation as-is.
return fullAnim;
}
// Step 4: Create a mutable copy and disable non-blocked bones.
KeyframeAnimation.AnimationBuilder builder = fullAnim.mutableCopy();
disableParts(builder, disabledParts);
KeyframeAnimation result = builder.build();
LOGGER.debug("[FurnitureAnim] Created animation: blocked={}, enabled={}, disabled={}",
blockedRegions, blockedParts, disabledParts);
return result;
}
/**
* Disable all animation axes on the specified parts.
*
* <p>Uses the same pattern as
* {@link com.tiedup.remake.client.animation.context.ContextAnimationFactory}.
* Unknown part names are silently ignored.</p>
*
* @param builder the mutable animation builder
* @param disabledParts set of PlayerAnimator part names to disable
*/
private static void disableParts(
KeyframeAnimation.AnimationBuilder builder, Set<String> disabledParts) {
for (String partName : disabledParts) {
KeyframeAnimation.StateCollection part = builder.getPart(partName);
if (part != null) {
part.setEnabled(false);
}
}
}
}

View File

@@ -0,0 +1,177 @@
package com.tiedup.remake.v2.furniture.client;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.math.Axis;
import com.tiedup.remake.client.gltf.GltfData;
import com.tiedup.remake.client.gltf.GltfMeshRenderer;
import com.tiedup.remake.client.gltf.GltfSkinningEngine;
import com.tiedup.remake.v2.furniture.EntityFurniture;
import com.tiedup.remake.v2.furniture.FurnitureDefinition;
import java.util.Map;
import net.minecraft.client.renderer.MultiBufferSource;
import net.minecraft.client.renderer.RenderType;
import net.minecraft.client.renderer.entity.EntityRenderer;
import net.minecraft.client.renderer.entity.EntityRendererProvider;
import net.minecraft.client.renderer.texture.OverlayTexture;
import net.minecraft.resources.ResourceLocation;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import org.joml.Matrix4f;
/**
* EntityRenderer for data-driven furniture entities.
*
* <p>Renders the furniture mesh from GLB data loaded via {@link FurnitureGltfCache},
* using the existing {@link GltfMeshRenderer} pipeline for CPU-skinned GLTF rendering.
* Supports both static rest-pose and animated rendering based on the entity's
* synched animation state ({@link EntityFurniture#getAnimState()}).</p>
*
* <p>Tint channels from the {@link FurnitureDefinition} are applied via
* {@link GltfMeshRenderer#renderSkinnedTinted} when the definition specifies
* non-empty tint channel defaults and the mesh has multiple primitives.</p>
*
* <p>This is a non-living entity renderer (extends {@code EntityRenderer}, not
* {@code LivingEntityRenderer}), so there is no hurt overlay or death animation.
* Overlay coords use {@link OverlayTexture#NO_OVERLAY} instead of the
* living-entity overlay that reads entity health (which would NPE).</p>
*
* @see EntityFurniture
* @see FurnitureGltfCache
* @see GltfMeshRenderer
*/
@OnlyIn(Dist.CLIENT)
public class FurnitureEntityRenderer extends EntityRenderer<EntityFurniture> {
public FurnitureEntityRenderer(EntityRendererProvider.Context ctx) {
super(ctx);
}
@Override
public void render(
EntityFurniture entity,
float yaw,
float partialTick,
PoseStack poseStack,
MultiBufferSource buffer,
int packedLight
) {
FurnitureDefinition def = entity.getDefinition();
if (def == null) return;
FurnitureGltfData data = FurnitureGltfCache.get(def.modelLocation());
if (data == null || data.furnitureMesh() == null) return;
GltfData meshData = data.furnitureMesh();
// Compute joint matrices: animated if there is an active clip, static otherwise
GltfData.AnimationClip activeClip = resolveActiveAnimation(entity, meshData);
Matrix4f[] joints;
if (activeClip != null) {
float time = computeAnimationTime(entity, activeClip, partialTick);
joints = GltfSkinningEngine.computeJointMatricesAnimated(meshData, activeClip, time);
} else {
joints = GltfSkinningEngine.computeJointMatrices(meshData);
}
poseStack.pushPose();
// Apply entity yaw rotation around the Y axis.
// The entity's yaw is set during placement and synced via IEntityAdditionalSpawnData.
poseStack.mulPose(Axis.YP.rotationDegrees(-yaw));
// Non-living entities use NO_OVERLAY (no red hurt flash, no death tint).
// LivingEntityRenderer.getOverlayCoords(null, ...) would NPE because it
// accesses entity health.
int packedOverlay = OverlayTexture.NO_OVERLAY;
// Render with tint support if the definition has tint channels and the mesh
// has multiple primitives (tintable and non-tintable parts).
Map<String, Integer> tintColors = def.tintChannels();
if (!tintColors.isEmpty() && meshData.primitives().size() > 1) {
RenderType renderType = GltfMeshRenderer.getRenderTypeForDefaultTexture();
GltfMeshRenderer.renderSkinnedTinted(
meshData, joints, poseStack, buffer,
packedLight, packedOverlay, renderType, tintColors
);
} else {
GltfMeshRenderer.renderSkinned(
meshData, joints, poseStack, buffer,
packedLight, packedOverlay
);
}
poseStack.popPose();
// super.render() handles debug hitbox rendering and name tag display
super.render(entity, yaw, partialTick, poseStack, buffer, packedLight);
}
/**
* Map the entity's synched animation state to a named animation clip from the GLB.
*
* <p>Falls back to "Idle" if the specific state animation is not found in the mesh.
* Returns null if the mesh has no animations at all (static furniture).</p>
*
* @param entity the furniture entity
* @param meshData the parsed GLB mesh data
* @return the resolved animation clip, or null for static rendering
*/
private GltfData.AnimationClip resolveActiveAnimation(
EntityFurniture entity, GltfData meshData
) {
String animName = switch (entity.getAnimState()) {
case EntityFurniture.STATE_OCCUPIED -> "Occupied";
case EntityFurniture.STATE_LOCKING -> "LockClose";
case EntityFurniture.STATE_STRUGGLE -> "Shake";
case EntityFurniture.STATE_UNLOCKING -> "LockOpen";
case EntityFurniture.STATE_ENTERING -> "Occupied"; // furniture plays Occupied during player enter transition
case EntityFurniture.STATE_EXITING -> "Idle"; // furniture transitions to Idle during player exit
default -> "Idle";
};
GltfData.AnimationClip clip = meshData.getAnimation(animName);
if (clip == null && entity.getAnimState() != EntityFurniture.STATE_IDLE) {
// Specific state animation missing: fall back to Idle
clip = meshData.getAnimation("Idle");
}
// Returns null if there are no animations at all (static mesh)
return clip;
}
/**
* Compute the animation time in frame-space for the skinning engine.
*
* <p>Uses the entity's tick count plus partial tick for smooth interpolation.
* The time is looped via modulo against the clip's frame count. A playback speed
* of 1.0 means one frame per tick (20 FPS, matching Minecraft's tick rate).</p>
*
* @param entity the furniture entity (provides tick count)
* @param clip the active animation clip (provides frame count)
* @param partialTick fractional tick for interpolation (0.0 to 1.0)
* @return time in frame-space, suitable for {@link GltfSkinningEngine#computeJointMatricesAnimated}
*/
private float computeAnimationTime(
EntityFurniture entity, GltfData.AnimationClip clip, float partialTick
) {
int frameCount = clip.frameCount();
if (frameCount <= 1) return 0f;
// 1 frame per tick = 20 FPS playback, matching Minecraft tick rate.
// partialTick smooths between ticks for frame-rate-independent display.
float time = (entity.tickCount + partialTick);
// Loop within the valid frame range [0, frameCount - 1]
return time % frameCount;
}
/**
* GLB pipeline does not use the vanilla texture atlas system.
* Textures are baked into the GLB file and applied via the custom RenderType
* in {@link GltfMeshRenderer}.
*
* @return null because the GLB pipeline manages its own textures
*/
@Override
public ResourceLocation getTextureLocation(EntityFurniture entity) {
return null;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,95 @@
package com.tiedup.remake.v2.furniture.client;
import java.io.InputStream;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.jetbrains.annotations.Nullable;
import net.minecraft.client.Minecraft;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.packs.resources.Resource;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
/**
* Lazy-loading cache for parsed multi-armature furniture GLB data.
*
* <p>Loads .glb files via Minecraft's ResourceManager on first access and parses them
* with {@link FurnitureGlbParser}. Thread-safe via {@link ConcurrentHashMap}.
*
* <p>Call {@link #clear()} on resource reload (e.g., F3+T) to invalidate stale entries.
*
* <p>This class is client-only and must never be referenced from server code.
*/
@OnlyIn(Dist.CLIENT)
public final class FurnitureGltfCache {
private static final Logger LOGGER = LogManager.getLogger("FurnitureGltf");
/**
* Sentinel value stored in the cache when loading fails, to avoid retrying
* broken resources on every frame.
*/
private static final FurnitureGltfData FAILED_SENTINEL = new FurnitureGltfData(
null,
Map.of(),
Map.of(),
Map.of()
);
private static final Map<ResourceLocation, FurnitureGltfData> CACHE = new ConcurrentHashMap<>();
private FurnitureGltfCache() {}
/**
* Get parsed furniture GLB data for a resource, loading and parsing on first access.
*
* @param modelLocation resource location of the .glb file
* (e.g., {@code tiedup:models/furniture/wooden_stocks.glb})
* @return parsed {@link FurnitureGltfData}, or {@code null} if loading/parsing failed
*/
@Nullable
public static FurnitureGltfData get(ResourceLocation modelLocation) {
FurnitureGltfData cached = CACHE.computeIfAbsent(modelLocation, FurnitureGltfCache::load);
return cached == FAILED_SENTINEL ? null : cached;
}
/**
* Load and parse a furniture GLB from the resource manager.
*
* @return parsed data, or the {@link #FAILED_SENTINEL} on failure
*/
private static FurnitureGltfData load(ResourceLocation loc) {
try {
Resource resource = Minecraft.getInstance()
.getResourceManager()
.getResource(loc)
.orElse(null);
if (resource == null) {
LOGGER.error("[FurnitureGltf] Resource not found: {}", loc);
return FAILED_SENTINEL;
}
try (InputStream is = resource.open()) {
FurnitureGltfData data = FurnitureGlbParser.parse(is, loc.toString());
LOGGER.debug("[FurnitureGltf] Cached: {}", loc);
return data;
}
} catch (Exception e) {
LOGGER.error("[FurnitureGltf] Failed to load furniture GLB: {}", loc, e);
return FAILED_SENTINEL;
}
}
/**
* Clear all cached data. Call on resource reload (F3+T) or dimension change.
*/
public static void clear() {
int size = CACHE.size();
CACHE.clear();
if (size > 0) {
LOGGER.info("[FurnitureGltf] Cache cleared ({} entries)", size);
}
}
}

View File

@@ -0,0 +1,56 @@
package com.tiedup.remake.v2.furniture.client;
import com.tiedup.remake.client.gltf.GltfData;
import java.util.Map;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import org.joml.Quaternionf;
import org.joml.Vector3f;
/**
* Parsed multi-armature GLB data for a furniture piece.
*
* <p>A furniture GLB may contain:
* <ul>
* <li>A <b>furniture armature</b> with mesh/skeleton for the furniture itself</li>
* <li>Zero or more <b>Player_*</b> armatures defining seat positions and player animations</li>
* </ul>
*
* <p>The furniture mesh is parsed into a standard {@link GltfData} for rendering via the
* existing {@code GltfMeshRenderer}. Seat transforms and animations are extracted from
* Player_* armatures and keyed by seat ID (derived from armature name, e.g.
* {@code "Player_main"} becomes seat ID {@code "main"}).
*/
@OnlyIn(Dist.CLIENT)
public record FurnitureGltfData(
/** Furniture mesh data (renderable via GltfMeshRenderer). */
GltfData furnitureMesh,
/** Per-seat root transforms from Player_* armatures: seatId to transform. */
Map<String, SeatTransform> seatTransforms,
/** Per-seat player animations: seatId to (animName to clip). */
Map<String, Map<String, GltfData.AnimationClip>> seatAnimations,
/**
* Per-seat player skeleton data (for GltfPoseConverter).
* Joints are filtered through {@code GltfBoneMapper.isKnownBone()},
* rest poses are converted to Minecraft space, and animations are
* remapped to match the filtered joint indices.
*/
Map<String, GltfData> seatSkeletons
) {
/**
* Root transform of a Player_* armature, defining where a seated player is
* positioned and oriented relative to the furniture origin.
*
* @param seatId seat identifier (e.g., "main", "left")
* @param position translation offset in glTF space (meters, Y-up)
* @param rotation orientation quaternion in glTF space
*/
public record SeatTransform(
String seatId,
Vector3f position,
Quaternionf rotation
) {}
}

View File

@@ -0,0 +1,73 @@
package com.tiedup.remake.v2.furniture.client;
import com.tiedup.remake.v2.furniture.FurnitureDefinition;
import net.minecraft.resources.ResourceLocation;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import org.jetbrains.annotations.Nullable;
import org.joml.Vector3f;
/**
* Client-only helper to resolve seat world positions from parsed GLB data.
*
* <p>This class exists to isolate the {@link FurnitureGltfCache} dependency
* from {@link com.tiedup.remake.v2.furniture.EntityFurniture EntityFurniture},
* which is loaded on both client and dedicated server. The dedicated server
* never touches this class, preventing classloader errors from the
* {@code @OnlyIn(Dist.CLIENT)} cache/parser classes.</p>
*/
@OnlyIn(Dist.CLIENT)
public final class FurnitureSeatPositionHelper {
private FurnitureSeatPositionHelper() {}
/**
* Look up the seat transform from the parsed GLB data and compute
* the world position for a passenger in that seat.
*
* @param def the furniture definition (provides modelLocation)
* @param seatId the seat identifier to look up
* @param furnitureX furniture entity X position
* @param furnitureY furniture entity Y position
* @param furnitureZ furniture entity Z position
* @param furnitureYRot furniture entity Y rotation in degrees
* @return the world position [x, y, z] for the passenger, or null if
* the GLB data or seat transform is unavailable
*/
@Nullable
public static double[] getSeatWorldPosition(
FurnitureDefinition def,
String seatId,
double furnitureX, double furnitureY, double furnitureZ,
float furnitureYRot
) {
ResourceLocation modelLoc = def.modelLocation();
if (modelLoc == null) return null;
FurnitureGltfData gltfData = FurnitureGltfCache.get(modelLoc);
if (gltfData == null) return null;
FurnitureGltfData.SeatTransform transform = gltfData.seatTransforms().get(seatId);
if (transform == null) return null;
// The seat transform position is in Minecraft model space (post-conversion):
// X and Y are negated from glTF. We need to rotate by the entity's yaw.
Vector3f pos = transform.position();
float yawRad = (float) Math.toRadians(-furnitureYRot);
float cos = (float) Math.cos(yawRad);
float sin = (float) Math.sin(yawRad);
// Rotate the local seat offset by the furniture's yaw around the Y axis
float sx = pos.x();
float sy = pos.y();
float sz = pos.z();
float rx = sx * cos - sz * sin;
float rz = sx * sin + sz * cos;
return new double[] {
furnitureX + rx,
furnitureY + sy,
furnitureZ + rz
};
}
}

View File

@@ -0,0 +1,536 @@
package com.tiedup.remake.v2.furniture.network;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.items.ItemLockpick;
import com.tiedup.remake.minigame.ContinuousStruggleMiniGameState;
import com.tiedup.remake.minigame.LockpickMiniGameState;
import com.tiedup.remake.minigame.LockpickSessionManager;
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.network.minigame.PacketLockpickMiniGameState;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.IV2BondageItem;
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
import com.tiedup.remake.v2.furniture.EntityFurniture;
import com.tiedup.remake.v2.furniture.ISeatProvider;
import com.tiedup.remake.v2.furniture.SeatDefinition;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Supplier;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.phys.Vec3;
import net.minecraftforge.network.NetworkEvent;
/**
* Client-to-server packet: seated player initiates a struggle escape, or a
* third party standing nearby initiates a lockpick escape.
*
* <p>Escape methods:
* <ul>
* <li><b>STRUGGLE (0)</b>: Sender must BE the seated passenger. Always
* allowed regardless of blocked regions.</li>
* <li><b>LOCKPICK (1)</b>: Sender must NOT be the seated passenger (third
* party standing nearby). Must have a lockpick in their inventory.</li>
* </ul>
*
* <p>Difficulty is computed as: {@code total = min(seat.lockedDifficulty + itemBonus, 600)},
* where {@code itemBonus} is the sum of {@link IV2BondageItem#getEscapeDifficulty(ItemStack)}
* for all equipped items on NON-blocked body regions.</p>
*
* <p>Wire format: int furnitureEntityId (4) + byte escapeMethod (1)</p>
*
* <p>Direction: Client to Server (C2S)</p>
*/
public class PacketFurnitureEscape {
/** Escape method: the seated player struggles to break free. */
public static final byte METHOD_STRUGGLE = 0;
/** Escape method: a third party lockpicks the seat lock. */
public static final byte METHOD_LOCKPICK = 1;
/** Maximum total escape difficulty (cap). */
private static final int MAX_DIFFICULTY = 600;
private final int furnitureEntityId;
private final byte escapeMethod;
public PacketFurnitureEscape(int furnitureEntityId, byte escapeMethod) {
this.furnitureEntityId = furnitureEntityId;
this.escapeMethod = escapeMethod;
}
// ==================== Codec ====================
public static void encode(PacketFurnitureEscape msg, FriendlyByteBuf buf) {
buf.writeInt(msg.furnitureEntityId);
buf.writeByte(msg.escapeMethod);
}
public static PacketFurnitureEscape decode(FriendlyByteBuf buf) {
return new PacketFurnitureEscape(buf.readInt(), buf.readByte());
}
// ==================== Handler ====================
public static void handle(
PacketFurnitureEscape msg,
Supplier<NetworkEvent.Context> ctxSupplier
) {
NetworkEvent.Context ctx = ctxSupplier.get();
ctx.enqueueWork(() -> handleOnServer(msg, ctx));
ctx.setPacketHandled(true);
}
private static void handleOnServer(PacketFurnitureEscape msg, NetworkEvent.Context ctx) {
ServerPlayer sender = ctx.getSender();
if (sender == null) return;
// Rate limit: prevent escape spam
if (!PacketRateLimiter.allowPacket(sender, "struggle")) return;
// Resolve the furniture entity
Entity entity = sender.level().getEntity(msg.furnitureEntityId);
if (entity == null) return;
if (!(entity instanceof ISeatProvider provider)) return;
if (sender.distanceTo(entity) > 5.0) return;
if (!entity.isAlive() || entity.isRemoved()) return;
// Validate escape method
if (msg.escapeMethod != METHOD_STRUGGLE && msg.escapeMethod != METHOD_LOCKPICK) {
TiedUpMod.LOGGER.warn(
"[PacketFurnitureEscape] Invalid escape method {} from {}",
msg.escapeMethod, sender.getName().getString()
);
return;
}
if (msg.escapeMethod == METHOD_STRUGGLE) {
handleStruggle(sender, entity, provider);
} else {
handleLockpick(sender, entity, provider);
}
}
// ==================== Struggle ====================
/**
* Sender must BE the seated passenger. Always allowed regardless of blocked
* regions.
*/
private static void handleStruggle(
ServerPlayer sender,
Entity furnitureEntity,
ISeatProvider provider
) {
// Sender must be riding this furniture
if (!furnitureEntity.hasPassenger(sender)) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureEscape] Struggle: {} is not a passenger of furniture {}",
sender.getName().getString(), furnitureEntity.getId()
);
return;
}
// Find sender's seat
SeatDefinition seat = provider.getSeatForPassenger(sender);
if (seat == null) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureEscape] Struggle: {} has no assigned seat",
sender.getName().getString()
);
return;
}
// Seat must be locked (no point struggling if unlocked — just dismount)
if (!provider.isSeatLocked(seat.id())) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureEscape] Struggle: seat '{}' is not locked, no struggle needed",
seat.id()
);
return;
}
// Compute difficulty
int baseDifficulty = provider.getLockedDifficulty(seat.id());
int itemBonus = computeItemDifficultyBonus(sender, provider, seat);
int totalDifficulty = Math.min(baseDifficulty + itemBonus, MAX_DIFFICULTY);
TiedUpMod.LOGGER.debug(
"[PacketFurnitureEscape] Struggle: {} on seat '{}' — difficulty {} (base {} + items {})",
sender.getName().getString(), seat.id(),
totalDifficulty, baseDifficulty, itemBonus
);
// Difficulty 0: immediate escape (no minigame needed)
if (totalDifficulty == 0) {
provider.setSeatLocked(seat.id(), false);
sender.getPersistentData().remove("tiedup_locked_furniture");
sender.stopRiding();
// Broadcast updated state
if (furnitureEntity instanceof EntityFurniture furniture) {
PacketSyncFurnitureState.sendToTracking(furniture);
}
TiedUpMod.LOGGER.info(
"[PacketFurnitureEscape] {} escaped furniture {} (difficulty was 0)",
sender.getName().getString(), furnitureEntity.getId()
);
return;
}
// Respect server config: if struggle minigame is disabled, skip
if (!com.tiedup.remake.core.ModConfig.SERVER.struggleMiniGameEnabled.get()) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureEscape] Struggle minigame disabled by server config"
);
return;
}
// Launch struggle minigame session via StruggleSessionManager
StruggleSessionManager manager = StruggleSessionManager.getInstance();
ContinuousStruggleMiniGameState session = manager.startFurnitureStruggleSession(
sender, furnitureEntity.getId(), seat.id(), totalDifficulty
);
if (session != null) {
// Send START packet to open the struggle GUI on the client
ModNetwork.sendToPlayer(
new PacketContinuousStruggleState(
session.getSessionId(),
ContinuousStruggleMiniGameState.UpdateType.START,
session.getCurrentDirection().getIndex(),
session.getCurrentResistance(),
session.getMaxResistance(),
true // locked context
),
sender
);
// Set furniture animation state to STRUGGLE
if (furnitureEntity instanceof EntityFurniture furniture) {
furniture.setAnimState(EntityFurniture.STATE_STRUGGLE);
PacketSyncFurnitureState.sendToTracking(furniture);
}
}
}
// ==================== Lockpick ====================
/**
* Sender must NOT be the seated passenger (third party standing nearby).
* Must have a lockpick item in inventory. Targets the locked occupied seat
* closest to the sender's look direction.
*/
private static void handleLockpick(
ServerPlayer sender,
Entity furnitureEntity,
ISeatProvider provider
) {
// Sender must NOT be riding this furniture (third party assistance)
if (furnitureEntity.hasPassenger(sender)) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureEscape] Lockpick: {} cannot lockpick while seated",
sender.getName().getString()
);
return;
}
// Sender must have a lockpick in their inventory
if (!hasLockpickInInventory(sender)) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureEscape] Lockpick: {} has no lockpick",
sender.getName().getString()
);
return;
}
// Use look-direction-based seat targeting (same vector math as
// EntityFurniture.findNearestOccupiedLockableSeat) instead of
// blindly picking the first locked seat.
SeatDefinition targetSeat = findNearestLockedOccupiedSeat(sender, provider, furnitureEntity);
if (targetSeat == null) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureEscape] Lockpick: no locked occupied seat found"
);
return;
}
// Find the passenger in this seat (needed for item bonus computation)
Entity passenger = findPassengerInSeat(provider, furnitureEntity, targetSeat.id());
// Compute difficulty
int baseDifficulty = provider.getLockedDifficulty(targetSeat.id());
int itemBonus = 0;
if (passenger instanceof LivingEntity livingPassenger) {
itemBonus = computeItemDifficultyBonus(livingPassenger, provider, targetSeat);
}
int totalDifficulty = Math.min(baseDifficulty + itemBonus, MAX_DIFFICULTY);
TiedUpMod.LOGGER.debug(
"[PacketFurnitureEscape] Lockpick: {} on seat '{}' -- difficulty {} (base {} + items {})",
sender.getName().getString(), targetSeat.id(),
totalDifficulty, baseDifficulty, itemBonus
);
// Difficulty 0: immediate success — unlock + dismount + consume lockpick
if (totalDifficulty == 0) {
completeLockpickSuccess(sender, furnitureEntity, provider, targetSeat, passenger);
return;
}
// Respect server config: if lockpick minigame is disabled, skip
if (!com.tiedup.remake.core.ModConfig.SERVER.lockpickMiniGameEnabled.get()) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureEscape] Lockpick minigame disabled by server config"
);
return;
}
// Find the lockpick to determine remaining uses and sweet spot width
ItemStack lockpickStack = ItemLockpick.findLockpickInInventory(sender);
if (lockpickStack.isEmpty()) return; // double-check; hasLockpickInInventory passed above
int remainingUses = lockpickStack.getMaxDamage() - lockpickStack.getDamageValue();
// Sweet spot width scales inversely with difficulty: harder locks = narrower sweet spot.
// Base width 0.15 at difficulty 1, down to 0.03 at MAX_DIFFICULTY.
float sweetSpotWidth = Math.max(0.03f, 0.15f - (totalDifficulty / (float) MAX_DIFFICULTY) * 0.12f);
// Start lockpick session via LockpickSessionManager.
// The existing lockpick session uses a targetSlot (BodyRegionV2 ordinal) for
// bondage items. For furniture, we repurpose targetSlot as the furniture entity ID
// and store the seat ID in a context tag so the completion callback can find it.
// For now, we use the simplified approach: start the session and let the existing
// PacketLockpickAttempt handler manage the sweet-spot interaction. On success,
// the furniture-specific completion is handled by a post-session check.
LockpickSessionManager lockpickManager = LockpickSessionManager.getInstance();
LockpickMiniGameState session = lockpickManager.startLockpickSession(
sender,
furnitureEntity.getId(), // repurpose targetSlot as entity ID
sweetSpotWidth
);
if (session == null) {
TiedUpMod.LOGGER.warn(
"[PacketFurnitureEscape] Failed to create lockpick session for {}",
sender.getName().getString()
);
return;
}
session.setRemainingUses(remainingUses);
// Store furniture context in the sender's persistent data so the
// lockpick attempt handler can resolve the furniture on success.
// This is cleaned up when the session ends.
net.minecraft.nbt.CompoundTag ctx = new net.minecraft.nbt.CompoundTag();
ctx.putInt("furniture_id", furnitureEntity.getId());
ctx.putString("seat_id", targetSeat.id());
sender.getPersistentData().put("tiedup_furniture_lockpick_ctx", ctx);
// Send initial lockpick state to open the minigame GUI on the client
ModNetwork.sendToPlayer(
new PacketLockpickMiniGameState(
session.getSessionId(),
session.getSweetSpotCenter(),
session.getSweetSpotWidth(),
session.getCurrentPosition(),
session.getRemainingUses()
),
sender
);
TiedUpMod.LOGGER.info(
"[PacketFurnitureEscape] {} started lockpick on seat '{}' of furniture {} (difficulty {}, sweet spot width {})",
sender.getName().getString(), targetSeat.id(),
furnitureEntity.getId(), totalDifficulty, sweetSpotWidth
);
}
/**
* Complete a successful lockpick: unlock the seat, clear reconnection tag,
* dismount the passenger, consume/damage the lockpick, and broadcast state.
*/
private static void completeLockpickSuccess(
ServerPlayer sender,
Entity furnitureEntity,
ISeatProvider provider,
SeatDefinition targetSeat,
Entity passenger
) {
provider.setSeatLocked(targetSeat.id(), false);
if (passenger instanceof ServerPlayer passengerPlayer) {
passengerPlayer.getPersistentData().remove("tiedup_locked_furniture");
}
if (passenger != null) {
passenger.stopRiding();
}
// Damage the lockpick (1 durability per successful pick)
ItemStack lockpickStack = ItemLockpick.findLockpickInInventory(sender);
if (!lockpickStack.isEmpty()) {
lockpickStack.setDamageValue(lockpickStack.getDamageValue() + 1);
if (lockpickStack.getDamageValue() >= lockpickStack.getMaxDamage()) {
lockpickStack.shrink(1);
}
}
// Broadcast updated state
if (furnitureEntity instanceof EntityFurniture furniture) {
PacketSyncFurnitureState.sendToTracking(furniture);
}
TiedUpMod.LOGGER.info(
"[PacketFurnitureEscape] {} lockpicked seat '{}' on furniture {} (difficulty was 0)",
sender.getName().getString(), targetSeat.id(), furnitureEntity.getId()
);
}
// ==================== Helpers ====================
/**
* Compute the item difficulty bonus: sum of {@code getEscapeDifficulty(stack)}
* for all V2 bondage items equipped on NON-blocked body regions.
*
* <p>Only applies if the seat's {@code itemDifficultyBonus} flag is true.</p>
*/
private static int computeItemDifficultyBonus(
LivingEntity passenger,
ISeatProvider provider,
SeatDefinition seat
) {
if (!provider.hasItemDifficultyBonus(seat.id())) {
return 0;
}
Set<BodyRegionV2> blockedRegions = provider.getBlockedRegions(seat.id());
Map<BodyRegionV2, ItemStack> equipped = V2EquipmentHelper.getAllEquipped(passenger);
int bonus = 0;
for (Map.Entry<BodyRegionV2, ItemStack> entry : equipped.entrySet()) {
// Skip items on blocked regions (those are "held" by the furniture)
if (blockedRegions.contains(entry.getKey())) continue;
ItemStack stack = entry.getValue();
if (stack.getItem() instanceof IV2BondageItem bondageItem) {
bonus += bondageItem.getEscapeDifficulty(stack);
}
}
return bonus;
}
/**
* Check if the sender has a lockpick item anywhere in their inventory.
*/
private static boolean hasLockpickInInventory(ServerPlayer player) {
for (ItemStack stack : player.getInventory().items) {
if (stack.getItem() instanceof ItemLockpick) return true;
}
// Also check offhand
if (player.getOffhandItem().getItem() instanceof ItemLockpick) return true;
return false;
}
/**
* Find the locked and occupied seat whose approximate world position has the
* smallest angle to the player's look direction.
*
* <p>Uses the same vector math as
* {@link EntityFurniture#findNearestOccupiedLockableSeat} to ensure
* consistent targeting between key interactions and lockpick attempts.</p>
*
* @param player the player performing the lockpick (provides look direction)
* @param provider the seat provider (furniture entity)
* @param furnitureEntity the furniture entity (provides position and yaw)
* @return the best matching seat, or null if no locked occupied seats exist
*/
private static SeatDefinition findNearestLockedOccupiedSeat(
ServerPlayer player,
ISeatProvider provider,
Entity furnitureEntity
) {
List<SeatDefinition> seats = provider.getSeats();
if (seats.isEmpty()) return null;
Vec3 playerPos = player.getEyePosition();
Vec3 lookDir = player.getLookAngle();
float yawRad = (float) Math.toRadians(furnitureEntity.getYRot());
// Entity-local right axis (perpendicular to facing in the XZ plane)
double rightX = -Math.sin(yawRad + Math.PI / 2.0);
double rightZ = Math.cos(yawRad + Math.PI / 2.0);
SeatDefinition best = null;
double bestScore = Double.MAX_VALUE;
int seatCount = seats.size();
for (int i = 0; i < seatCount; i++) {
SeatDefinition seat = seats.get(i);
// Only consider locked seats that have a passenger
if (!provider.isSeatLocked(seat.id())) continue;
boolean hasPassenger = false;
for (Entity p : furnitureEntity.getPassengers()) {
SeatDefinition ps = provider.getSeatForPassenger(p);
if (ps != null && ps.id().equals(seat.id())) {
hasPassenger = true;
break;
}
}
if (!hasPassenger) continue;
// Approximate seat world position along the right axis
double offset = seatCount == 1 ? 0.0 : (i - (seatCount - 1) / 2.0);
Vec3 seatWorldPos = new Vec3(
furnitureEntity.getX() + rightX * offset,
furnitureEntity.getY() + 0.5,
furnitureEntity.getZ() + rightZ * offset
);
Vec3 toSeat = seatWorldPos.subtract(playerPos);
double distSq = toSeat.lengthSqr();
if (distSq < 1e-6) {
best = seat;
bestScore = -1.0;
continue;
}
Vec3 toSeatNorm = toSeat.normalize();
double dot = lookDir.dot(toSeatNorm);
double score = -dot; // lower = better (looking more directly at seat)
if (score < bestScore) {
bestScore = score;
best = seat;
}
}
return best;
}
/**
* Find the passenger entity sitting in a specific seat.
*/
private static Entity findPassengerInSeat(
ISeatProvider provider,
Entity furnitureEntity,
String seatId
) {
for (Entity passenger : furnitureEntity.getPassengers()) {
SeatDefinition passengerSeat = provider.getSeatForPassenger(passenger);
if (passengerSeat != null && passengerSeat.id().equals(seatId)) {
return passenger;
}
}
return null;
}
}

View File

@@ -0,0 +1,241 @@
package com.tiedup.remake.v2.furniture.network;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.items.base.ItemCollar;
import com.tiedup.remake.network.PacketRateLimiter;
import com.tiedup.remake.state.IBondageState;
import com.tiedup.remake.util.KidnappedHelper;
import com.tiedup.remake.v2.furniture.EntityFurniture;
import com.tiedup.remake.v2.furniture.FurnitureDefinition;
import com.tiedup.remake.v2.furniture.FurnitureFeedback;
import com.tiedup.remake.v2.furniture.ISeatProvider;
import com.tiedup.remake.v2.furniture.SeatDefinition;
import java.util.UUID;
import java.util.function.Supplier;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.sounds.SoundEvent;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.phys.AABB;
import net.minecraftforge.network.NetworkEvent;
/**
* Client-to-server packet: master forces a captive onto a furniture seat.
*
* <p>The sender must own the captive's collar (verified via
* {@link ItemCollar#isOwner(ItemStack, net.minecraft.world.entity.player.Player)}),
* the captive must be alive and within 5 blocks of both sender and furniture,
* and the furniture must have an available seat.</p>
*
* <p>Wire format: int furnitureEntityId (4) + UUID captiveUUID (16)</p>
*
* <p>Direction: Client to Server (C2S)</p>
*/
public class PacketFurnitureForcemount {
private final int furnitureEntityId;
private final UUID captiveUUID;
public PacketFurnitureForcemount(int furnitureEntityId, UUID captiveUUID) {
this.furnitureEntityId = furnitureEntityId;
this.captiveUUID = captiveUUID;
}
// ==================== Codec ====================
public static void encode(PacketFurnitureForcemount msg, FriendlyByteBuf buf) {
buf.writeInt(msg.furnitureEntityId);
buf.writeUUID(msg.captiveUUID);
}
public static PacketFurnitureForcemount decode(FriendlyByteBuf buf) {
return new PacketFurnitureForcemount(buf.readInt(), buf.readUUID());
}
// ==================== Handler ====================
public static void handle(
PacketFurnitureForcemount msg,
Supplier<NetworkEvent.Context> ctxSupplier
) {
NetworkEvent.Context ctx = ctxSupplier.get();
ctx.enqueueWork(() -> handleOnServer(msg, ctx));
ctx.setPacketHandled(true);
}
private static void handleOnServer(PacketFurnitureForcemount msg, NetworkEvent.Context ctx) {
ServerPlayer sender = ctx.getSender();
if (sender == null) return;
// Rate limit: prevent force-mount spam
if (!PacketRateLimiter.allowPacket(sender, "action")) return;
// Resolve the furniture entity
Entity entity = sender.level().getEntity(msg.furnitureEntityId);
if (entity == null) return;
if (!(entity instanceof ISeatProvider provider)) return;
if (sender.distanceTo(entity) > 5.0) return;
if (!entity.isAlive() || entity.isRemoved()) return;
// Look up captive by UUID in the sender's level
LivingEntity captive = findCaptiveByUUID(sender, msg.captiveUUID);
if (captive == null) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureForcemount] Captive not found: {}",
msg.captiveUUID
);
return;
}
// Captive must be alive
if (!captive.isAlive() || captive.isRemoved()) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureForcemount] Captive is not alive: {}",
captive.getName().getString()
);
return;
}
// Captive must be within 5 blocks of both sender and furniture
if (sender.distanceTo(captive) > 5.0) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureForcemount] Captive too far from sender"
);
return;
}
if (captive.distanceTo(entity) > 5.0) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureForcemount] Captive too far from furniture"
);
return;
}
// Verify collar ownership: captive must have a collar owned by sender
IBondageState captiveState = KidnappedHelper.getKidnappedState(captive);
if (captiveState == null || !captiveState.hasCollar()) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureForcemount] Captive has no collar: {}",
captive.getName().getString()
);
return;
}
ItemStack collarStack = captiveState.getEquipment(BodyRegionV2.NECK);
if (collarStack.isEmpty()
|| !(collarStack.getItem() instanceof ItemCollar collar)) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureForcemount] Invalid collar item on captive"
);
return;
}
// Collar must be owned by sender (or sender has admin permission)
if (!collar.isOwner(collarStack, sender) && !sender.hasPermissions(2)) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureForcemount] {} is not the collar owner of {}",
sender.getName().getString(),
captive.getName().getString()
);
return;
}
// Find the first available (unoccupied) seat
String availableSeatId = findFirstAvailableSeat(provider, entity);
if (availableSeatId == null) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureForcemount] No available seat on furniture entity {}",
msg.furnitureEntityId
);
return;
}
// Force-mount: startRiding triggers EntityFurniture.addPassenger which
// assigns the first available seat automatically. We use force=true to
// bypass any canRide checks on the captive.
boolean success = captive.startRiding(entity, true);
if (success) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureForcemount] {} force-mounted {} onto furniture {}",
sender.getName().getString(),
captive.getName().getString(),
msg.furnitureEntityId
);
// Play mount sound from FurnitureFeedback
if (entity instanceof EntityFurniture furniture) {
FurnitureDefinition def = furniture.getDefinition();
if (def != null) {
FurnitureFeedback feedback = def.feedback();
ResourceLocation mountSoundRL = feedback.mountSound();
if (mountSoundRL != null) {
SoundEvent sound = SoundEvent.createVariableRangeEvent(mountSoundRL);
entity.level().playSound(
null,
entity.getX(), entity.getY(), entity.getZ(),
sound, SoundSource.BLOCKS,
1.0f, 1.0f
);
}
}
// Broadcast updated state to tracking clients
PacketSyncFurnitureState.sendToTracking(furniture);
}
} else {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureForcemount] startRiding failed for {} on furniture {}",
captive.getName().getString(),
msg.furnitureEntityId
);
}
}
// ==================== Helpers ====================
/**
* Find a living entity by UUID within a reasonable range of the sender.
* Checks players first (O(1) lookup), then falls back to entity search.
*/
private static LivingEntity findCaptiveByUUID(ServerPlayer sender, UUID uuid) {
// Try player lookup first (fast)
net.minecraft.world.entity.player.Player player =
sender.level().getPlayerByUUID(uuid);
if (player != null) return player;
// Search nearby entities (64 block radius)
AABB searchBox = sender.getBoundingBox().inflate(64);
for (LivingEntity nearby : sender.level()
.getEntitiesOfClass(LivingEntity.class, searchBox)) {
if (nearby.getUUID().equals(uuid)) {
return nearby;
}
}
return null;
}
/**
* Find the first available (unoccupied) seat on the furniture.
*
* @return the seat ID, or null if all seats are occupied
*/
private static String findFirstAvailableSeat(ISeatProvider provider, Entity furniture) {
for (SeatDefinition seat : provider.getSeats()) {
boolean occupied = false;
for (Entity passenger : furniture.getPassengers()) {
SeatDefinition passengerSeat = provider.getSeatForPassenger(passenger);
if (passengerSeat != null && passengerSeat.id().equals(seat.id())) {
occupied = true;
break;
}
}
if (!occupied) return seat.id();
}
return null;
}
}

View File

@@ -0,0 +1,188 @@
package com.tiedup.remake.v2.furniture.network;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.items.ItemMasterKey;
import com.tiedup.remake.network.PacketRateLimiter;
import com.tiedup.remake.v2.furniture.EntityFurniture;
import com.tiedup.remake.v2.furniture.FurnitureDefinition;
import com.tiedup.remake.v2.furniture.FurnitureFeedback;
import com.tiedup.remake.v2.furniture.ISeatProvider;
import com.tiedup.remake.v2.furniture.SeatDefinition;
import java.util.function.Supplier;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.sounds.SoundEvent;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.entity.Entity;
import net.minecraftforge.network.NetworkEvent;
/**
* Client-to-server packet: toggle lock/unlock on a specific furniture seat.
*
* <p>The sender must hold a key item (ItemMasterKey) in their main hand,
* the seat must be lockable and occupied (someone sitting in it), and the
* sender must be within 5 blocks of the furniture entity.</p>
*
* <p>On success, toggles the lock state and broadcasts a
* {@link PacketSyncFurnitureState} to all tracking clients.</p>
*
* <p>Wire format: int entityId (4) + utf seatId (variable)</p>
*
* <p>Direction: Client to Server (C2S)</p>
*/
public class PacketFurnitureLock {
private final int entityId;
private final String seatId;
public PacketFurnitureLock(int entityId, String seatId) {
this.entityId = entityId;
this.seatId = seatId;
}
// ==================== Codec ====================
public static void encode(PacketFurnitureLock msg, FriendlyByteBuf buf) {
buf.writeInt(msg.entityId);
buf.writeUtf(msg.seatId);
}
public static PacketFurnitureLock decode(FriendlyByteBuf buf) {
return new PacketFurnitureLock(buf.readInt(), buf.readUtf());
}
// ==================== Handler ====================
public static void handle(
PacketFurnitureLock msg,
Supplier<NetworkEvent.Context> ctxSupplier
) {
NetworkEvent.Context ctx = ctxSupplier.get();
ctx.enqueueWork(() -> handleOnServer(msg, ctx));
ctx.setPacketHandled(true);
}
private static void handleOnServer(PacketFurnitureLock msg, NetworkEvent.Context ctx) {
ServerPlayer sender = ctx.getSender();
if (sender == null) return;
// Rate limit: prevent lock toggle spam
if (!PacketRateLimiter.allowPacket(sender, "action")) return;
// Resolve the target entity
Entity entity = sender.level().getEntity(msg.entityId);
if (entity == null) return;
if (!(entity instanceof ISeatProvider provider)) return;
if (sender.distanceTo(entity) > 5.0) return;
if (!entity.isAlive() || entity.isRemoved()) return;
// Sender must hold a key item in either hand
boolean hasKey = (sender.getMainHandItem().getItem() instanceof ItemMasterKey)
|| (sender.getOffhandItem().getItem() instanceof ItemMasterKey);
if (!hasKey) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureLock] {} does not hold a key item in either hand",
sender.getName().getString()
);
return;
}
// Validate the seat exists and is lockable
SeatDefinition seat = findSeatById(provider, msg.seatId);
if (seat == null) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureLock] Seat '{}' not found on entity {}",
msg.seatId, msg.entityId
);
return;
}
if (!seat.lockable()) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureLock] Seat '{}' is not lockable",
msg.seatId
);
return;
}
// Seat must be occupied (someone sitting in it)
if (!isSeatOccupied(provider, entity, msg.seatId)) {
TiedUpMod.LOGGER.debug(
"[PacketFurnitureLock] Seat '{}' is not occupied",
msg.seatId
);
return;
}
// Toggle the lock state
boolean wasLocked = provider.isSeatLocked(msg.seatId);
provider.setSeatLocked(msg.seatId, !wasLocked);
TiedUpMod.LOGGER.debug(
"[PacketFurnitureLock] {} {} seat '{}' on furniture entity {}",
sender.getName().getString(),
wasLocked ? "unlocked" : "locked",
msg.seatId,
msg.entityId
);
// Play lock/unlock sound and set animation state
if (entity instanceof EntityFurniture furniture) {
FurnitureDefinition def = furniture.getDefinition();
if (def != null) {
FurnitureFeedback feedback = def.feedback();
ResourceLocation soundRL = wasLocked
? feedback.unlockSound()
: feedback.lockSound();
if (soundRL != null) {
SoundEvent sound = SoundEvent.createVariableRangeEvent(soundRL);
entity.level().playSound(
null, // null = play for all nearby players
entity.getX(), entity.getY(), entity.getZ(),
sound, SoundSource.BLOCKS,
1.0f, 1.0f
);
}
}
// Set lock/unlock animation state. The next updateAnimState() call
// (from tick or passenger change) will reset it to OCCUPIED/IDLE.
boolean nowLocked = !wasLocked;
furniture.setAnimState(nowLocked
? EntityFurniture.STATE_LOCKING
: EntityFurniture.STATE_UNLOCKING);
// Broadcast updated state to all tracking clients
PacketSyncFurnitureState.sendToTracking(furniture);
}
}
// ==================== Helpers ====================
/**
* Find a SeatDefinition by ID from the provider's seat list.
*/
private static SeatDefinition findSeatById(ISeatProvider provider, String seatId) {
for (SeatDefinition seat : provider.getSeats()) {
if (seat.id().equals(seatId)) return seat;
}
return null;
}
/**
* Check if a seat is occupied by any passenger.
*/
private static boolean isSeatOccupied(
ISeatProvider provider,
Entity furnitureEntity,
String seatId
) {
for (Entity passenger : furnitureEntity.getPassengers()) {
SeatDefinition passengerSeat = provider.getSeatForPassenger(passenger);
if (passengerSeat != null && passengerSeat.id().equals(seatId)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,308 @@
package com.tiedup.remake.v2.furniture.network;
import com.tiedup.remake.network.ModNetwork;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.furniture.FurnitureDefinition;
import com.tiedup.remake.v2.furniture.FurnitureFeedback;
import com.tiedup.remake.v2.furniture.FurnitureRegistry;
import com.tiedup.remake.v2.furniture.SeatDefinition;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Supplier;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerPlayer;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import net.minecraftforge.fml.loading.FMLEnvironment;
import net.minecraftforge.network.NetworkEvent;
import net.minecraftforge.network.PacketDistributor;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
/**
* Server-to-client packet that syncs ALL furniture definitions from the
* {@link FurnitureRegistry} to a client.
*
* <p>Sent on player login and after {@code /reload}. The client handler
* calls {@link FurnitureRegistry#reload(Map)} with the deserialized
* definitions, replacing its entire local cache.</p>
*
* <p>Wire format: varint definition count, then for each definition
* the full set of fields including nested {@link SeatDefinition} list
* and {@link FurnitureFeedback} optional sounds.</p>
*
* <p>Direction: Server to Client (S2C)</p>
*/
public class PacketSyncFurnitureDefinitions {
private static final Logger LOGGER = LogManager.getLogger("PacketSyncFurnitureDefinitions");
/**
* Safety cap on the number of definitions to prevent memory exhaustion
* from malformed or malicious packets.
*/
private static final int MAX_DEFINITIONS = 10_000;
/**
* Safety cap on the number of seats per furniture definition.
*/
private static final int MAX_SEATS = 64;
/**
* Safety cap on the number of tint channels per definition.
*/
private static final int MAX_TINT_CHANNELS = 32;
/**
* Safety cap on the number of blocked regions per seat.
*/
private static final int MAX_BLOCKED_REGIONS = BodyRegionV2.values().length;
private final Map<ResourceLocation, FurnitureDefinition> definitions;
public PacketSyncFurnitureDefinitions(Map<ResourceLocation, FurnitureDefinition> definitions) {
this.definitions = definitions;
}
// ==================== Codec ====================
public static void encode(PacketSyncFurnitureDefinitions msg, FriendlyByteBuf buf) {
buf.writeVarInt(msg.definitions.size());
for (FurnitureDefinition def : msg.definitions.values()) {
// Identity
buf.writeResourceLocation(def.id());
buf.writeUtf(def.displayName());
// Optional translation key
boolean hasTranslationKey = def.translationKey() != null;
buf.writeBoolean(hasTranslationKey);
if (hasTranslationKey) {
buf.writeUtf(def.translationKey());
}
// Model
buf.writeResourceLocation(def.modelLocation());
// Tint channels
buf.writeVarInt(def.tintChannels().size());
for (Map.Entry<String, Integer> entry : def.tintChannels().entrySet()) {
buf.writeUtf(entry.getKey());
buf.writeInt(entry.getValue());
}
// Booleans and floats for placement/physics
buf.writeBoolean(def.supportsColor());
buf.writeFloat(def.hitboxWidth());
buf.writeFloat(def.hitboxHeight());
buf.writeBoolean(def.snapToWall());
buf.writeBoolean(def.floorOnly());
buf.writeBoolean(def.lockable());
buf.writeFloat(def.breakResistance());
buf.writeBoolean(def.dropOnBreak());
// Seats
buf.writeVarInt(def.seats().size());
for (SeatDefinition seat : def.seats()) {
encodeSeat(seat, buf);
}
// Feedback (6 optional ResourceLocations)
encodeFeedback(def.feedback(), buf);
// Category
buf.writeUtf(def.category());
// Icon (optional model ResourceLocation)
writeOptionalRL(buf, def.icon());
}
}
private static void encodeSeat(SeatDefinition seat, FriendlyByteBuf buf) {
buf.writeUtf(seat.id());
buf.writeUtf(seat.armatureName());
// Blocked regions as string names
buf.writeVarInt(seat.blockedRegions().size());
for (BodyRegionV2 region : seat.blockedRegions()) {
buf.writeUtf(region.name());
}
buf.writeBoolean(seat.lockable());
buf.writeVarInt(seat.lockedDifficulty());
buf.writeBoolean(seat.itemDifficultyBonus());
}
private static void encodeFeedback(FurnitureFeedback feedback, FriendlyByteBuf buf) {
writeOptionalRL(buf, feedback.mountSound());
writeOptionalRL(buf, feedback.lockSound());
writeOptionalRL(buf, feedback.unlockSound());
writeOptionalRL(buf, feedback.struggleLoopSound());
writeOptionalRL(buf, feedback.escapeSound());
writeOptionalRL(buf, feedback.deniedSound());
}
private static void writeOptionalRL(FriendlyByteBuf buf, ResourceLocation rl) {
buf.writeBoolean(rl != null);
if (rl != null) {
buf.writeResourceLocation(rl);
}
}
public static PacketSyncFurnitureDefinitions decode(FriendlyByteBuf buf) {
int count = Math.min(buf.readVarInt(), MAX_DEFINITIONS);
Map<ResourceLocation, FurnitureDefinition> defs = new HashMap<>(count);
for (int i = 0; i < count; i++) {
// Identity
ResourceLocation id = buf.readResourceLocation();
String displayName = buf.readUtf();
// Optional translation key
String translationKey = buf.readBoolean() ? buf.readUtf() : null;
// Model
ResourceLocation modelLocation = buf.readResourceLocation();
// Tint channels
int tintCount = Math.min(buf.readVarInt(), MAX_TINT_CHANNELS);
Map<String, Integer> tintChannels = new HashMap<>(tintCount);
for (int t = 0; t < tintCount; t++) {
tintChannels.put(buf.readUtf(), buf.readInt());
}
// Booleans and floats
boolean supportsColor = buf.readBoolean();
float hitboxWidth = buf.readFloat();
float hitboxHeight = buf.readFloat();
boolean snapToWall = buf.readBoolean();
boolean floorOnly = buf.readBoolean();
boolean lockable = buf.readBoolean();
float breakResistance = buf.readFloat();
boolean dropOnBreak = buf.readBoolean();
// Seats
int seatCount = Math.min(buf.readVarInt(), MAX_SEATS);
List<SeatDefinition> seats = new ArrayList<>(seatCount);
for (int s = 0; s < seatCount; s++) {
seats.add(decodeSeat(buf));
}
// Feedback
FurnitureFeedback feedback = decodeFeedback(buf);
// Category
String category = buf.readUtf();
// Icon (optional model ResourceLocation)
ResourceLocation icon = readOptionalRL(buf);
FurnitureDefinition def = new FurnitureDefinition(
id, displayName, translationKey, modelLocation,
Map.copyOf(tintChannels), supportsColor,
hitboxWidth, hitboxHeight, snapToWall, floorOnly,
lockable, breakResistance, dropOnBreak,
List.copyOf(seats), feedback, category, icon
);
defs.put(id, def);
}
return new PacketSyncFurnitureDefinitions(defs);
}
private static SeatDefinition decodeSeat(FriendlyByteBuf buf) {
String id = buf.readUtf();
String armatureName = buf.readUtf();
int regionCount = Math.min(buf.readVarInt(), MAX_BLOCKED_REGIONS);
Set<BodyRegionV2> blockedRegions = new HashSet<>(regionCount);
for (int r = 0; r < regionCount; r++) {
BodyRegionV2 region = BodyRegionV2.fromName(buf.readUtf());
if (region != null) {
blockedRegions.add(region);
}
// Silently skip unknown region names for forward compatibility
}
boolean lockable = buf.readBoolean();
int lockedDifficulty = buf.readVarInt();
boolean itemDifficultyBonus = buf.readBoolean();
return new SeatDefinition(
id, armatureName, Set.copyOf(blockedRegions),
lockable, lockedDifficulty, itemDifficultyBonus
);
}
private static FurnitureFeedback decodeFeedback(FriendlyByteBuf buf) {
ResourceLocation mountSound = readOptionalRL(buf);
ResourceLocation lockSound = readOptionalRL(buf);
ResourceLocation unlockSound = readOptionalRL(buf);
ResourceLocation struggleLoopSound = readOptionalRL(buf);
ResourceLocation escapeSound = readOptionalRL(buf);
ResourceLocation deniedSound = readOptionalRL(buf);
return new FurnitureFeedback(
mountSound, lockSound, unlockSound,
struggleLoopSound, escapeSound, deniedSound
);
}
private static ResourceLocation readOptionalRL(FriendlyByteBuf buf) {
return buf.readBoolean() ? buf.readResourceLocation() : null;
}
// ==================== Handler ====================
public static void handle(
PacketSyncFurnitureDefinitions 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(PacketSyncFurnitureDefinitions msg) {
FurnitureRegistry.reload(msg.definitions);
LOGGER.debug("Client received {} furniture definitions from server",
msg.definitions.size());
}
// ==================== Server-side Helpers ====================
/**
* Send all current furniture definitions to a single player.
* Call this on player login (PlayerLoggedInEvent) and after /reload.
*
* @param player the player to send definitions to
*/
public static void sendToPlayer(ServerPlayer player) {
ModNetwork.CHANNEL.send(
PacketDistributor.PLAYER.with(() -> player),
new PacketSyncFurnitureDefinitions(FurnitureRegistry.getAllMap())
);
}
/**
* Send all current furniture definitions to every connected player.
* Call this after /reload completes.
*/
public static void sendToAll() {
ModNetwork.CHANNEL.send(
PacketDistributor.ALL.noArg(),
new PacketSyncFurnitureDefinitions(FurnitureRegistry.getAllMap())
);
}
}

View File

@@ -0,0 +1,104 @@
package com.tiedup.remake.v2.furniture.network;
import com.tiedup.remake.network.ModNetwork;
import com.tiedup.remake.v2.furniture.EntityFurniture;
import java.util.function.Supplier;
import net.minecraft.client.Minecraft;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.world.entity.Entity;
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;
/**
* Server-to-client packet that syncs a furniture entity's lock bitmask and
* animation state.
*
* <p>While {@link EntityFurniture} uses {@code SynchedEntityData} for these
* fields (which handles baseline sync), this packet provides an immediate,
* explicit update when the server modifies lock state or animation state
* (e.g., after a lock toggle interaction or a struggle minigame state change).
* SynchedEntityData batches dirty entries once per tick, so this packet
* ensures clients see the change within the same network flush.</p>
*
* <p>Wire format (6 bytes): int entityId (4) + byte lockBits (1) + byte animState (1)</p>
*
* <p>Direction: Server to Client (S2C)</p>
*/
public class PacketSyncFurnitureState {
private final int entityId;
private final byte lockBits;
private final byte animState;
public PacketSyncFurnitureState(int entityId, byte lockBits, byte animState) {
this.entityId = entityId;
this.lockBits = lockBits;
this.animState = animState;
}
// ==================== Codec ====================
public static void encode(PacketSyncFurnitureState msg, FriendlyByteBuf buf) {
buf.writeInt(msg.entityId);
buf.writeByte(msg.lockBits);
buf.writeByte(msg.animState);
}
public static PacketSyncFurnitureState decode(FriendlyByteBuf buf) {
return new PacketSyncFurnitureState(
buf.readInt(),
buf.readByte(),
buf.readByte()
);
}
// ==================== Handler ====================
public static void handle(
PacketSyncFurnitureState 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(PacketSyncFurnitureState msg) {
Level level = Minecraft.getInstance().level;
if (level == null) return;
Entity entity = level.getEntity(msg.entityId);
if (entity instanceof EntityFurniture furniture) {
furniture.setSeatLockBitsRaw(msg.lockBits);
furniture.setAnimState(msg.animState);
}
}
// ==================== Server-side Helper ====================
/**
* Send the current lock + anim state of the given furniture entity to all
* players tracking it. Call this from the server after modifying lock bits
* or animation state.
*
* @param furniture the furniture entity whose state changed (must be server-side)
*/
public static void sendToTracking(EntityFurniture furniture) {
ModNetwork.sendToTracking(
new PacketSyncFurnitureState(
furniture.getId(),
furniture.getSeatLockBits(),
furniture.getAnimState()
),
furniture
);
}
}