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