Clean repo for open source release

Remove build artifacts, dev tool configs, unused dependencies,
and third-party source dumps. Add proper README, update .gitignore,
clean up Makefile.
This commit is contained in:
NotEvil
2026-04-12 00:51:22 +02:00
parent 2e7a1d403b
commit f6466360b6
1947 changed files with 238025 additions and 1 deletions

View File

@@ -0,0 +1,288 @@
package com.tiedup.remake.blocks;
import com.tiedup.remake.blocks.entity.CellCoreBlockEntity;
import com.tiedup.remake.cells.*;
import com.tiedup.remake.core.SystemMessageManager;
import com.tiedup.remake.network.ModNetwork;
import com.tiedup.remake.network.cell.PacketOpenCoreMenu;
import java.util.UUID;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.Component;
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.LivingEntity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.BlockGetter;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.BaseEntityBlock;
import net.minecraft.world.level.block.RenderShape;
import net.minecraft.world.level.block.SoundType;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockBehaviour;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.material.MapColor;
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;
/**
* Cell Core block - the anchor for a Cell System V2 cell.
*
* Placed into a wall of an enclosed room. On placement, runs flood-fill to
* detect the room boundaries and registers a new cell. On removal, destroys
* the cell.
*
* Obsidian-tier hardness to prevent easy breaking by prisoners.
*/
public class BlockCellCore extends BaseEntityBlock {
public BlockCellCore() {
super(
BlockBehaviour.Properties.of()
.mapColor(MapColor.STONE)
.strength(50.0f, 1200.0f)
.requiresCorrectToolForDrops()
.sound(SoundType.STONE)
.noOcclusion()
.dynamicShape()
);
}
@Nullable
@Override
public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return new CellCoreBlockEntity(pos, state);
}
@Override
public RenderShape getRenderShape(BlockState state) {
return RenderShape.MODEL;
}
// ==================== DYNAMIC SHAPE (disguise-aware) ====================
@SuppressWarnings("deprecation")
@Override
public VoxelShape getShape(
BlockState state,
BlockGetter level,
BlockPos pos,
CollisionContext context
) {
if (level.getBlockEntity(pos) instanceof CellCoreBlockEntity core) {
BlockState disguise = core.getDisguiseState();
if (disguise == null) disguise = core.resolveDisguise();
if (disguise != null) {
return disguise.getShape(level, pos, context);
}
}
return Shapes.block();
}
@SuppressWarnings("deprecation")
@Override
public VoxelShape getCollisionShape(
BlockState state,
BlockGetter level,
BlockPos pos,
CollisionContext context
) {
if (level.getBlockEntity(pos) instanceof CellCoreBlockEntity core) {
BlockState disguise = core.getDisguiseState();
if (disguise == null) disguise = core.resolveDisguise();
if (disguise != null) {
return disguise.getCollisionShape(level, pos, context);
}
}
return Shapes.block();
}
/**
* Right-click handler: open the Cell Core menu for the cell owner.
*/
@SuppressWarnings("deprecation")
@Override
public InteractionResult use(
BlockState state,
Level level,
BlockPos pos,
Player player,
InteractionHand hand,
BlockHitResult hit
) {
if (level.isClientSide) {
return InteractionResult.SUCCESS;
}
if (!(player instanceof ServerPlayer serverPlayer)) {
return InteractionResult.PASS;
}
BlockEntity be = level.getBlockEntity(pos);
if (
!(be instanceof CellCoreBlockEntity core) ||
core.getCellId() == null
) {
return InteractionResult.PASS;
}
CellRegistryV2 registry = CellRegistryV2.get((ServerLevel) level);
CellDataV2 cell = registry.getCell(core.getCellId());
if (cell == null) {
return InteractionResult.PASS;
}
// Check ownership (owner, camp cell, or OP level 2)
if (!cell.canPlayerManage(player.getUUID(), player.hasPermissions(2))) {
player.displayClientMessage(
Component.translatable("msg.tiedup.cell_core.not_owner"),
true
);
return InteractionResult.CONSUME;
}
// Send packet with all cell stats
ModNetwork.sendToPlayer(
new PacketOpenCoreMenu(
pos,
cell.getId(),
cell.getName() != null ? cell.getName() : "",
cell.getState().getSerializedName(),
cell.getInteriorBlocks().size(),
cell.getWallBlocks().size(),
cell.getBreachedPositions().size(),
cell.getPrisonerCount(),
cell.getBeds().size(),
cell.getDoors().size(),
cell.getAnchors().size(),
core.getSpawnPoint() != null,
core.getDeliveryPoint() != null,
core.getDisguiseState() != null
),
serverPlayer
);
return InteractionResult.CONSUME;
}
/**
* On placement: run flood-fill to detect the room, create a cell if successful.
* If the room cannot be detected, break the block back and show an error.
*/
@Override
public void setPlacedBy(
Level level,
BlockPos pos,
BlockState state,
@Nullable LivingEntity placer,
ItemStack stack
) {
super.setPlacedBy(level, pos, state, placer, stack);
if (
level instanceof ServerLevel serverLevel &&
placer instanceof Player player
) {
BlockEntity be = level.getBlockEntity(pos);
if (!(be instanceof CellCoreBlockEntity core)) return;
FloodFillResult result = FloodFillAlgorithm.tryFill(level, pos);
if (result.isSuccess()) {
CellRegistryV2 registry = CellRegistryV2.get(serverLevel);
CellDataV2 cell = registry.createCell(
pos,
result,
player.getUUID()
);
core.setCellId(cell.getId());
core.setInteriorFace(result.getInteriorFace());
player.displayClientMessage(
Component.translatable(
"msg.tiedup.cell_core.created",
result.getInterior().size(),
result.getWalls().size()
),
true
);
} else {
// Failed — break the block back (can't form a cell here)
level.destroyBlock(pos, true);
player.displayClientMessage(
Component.translatable(result.getErrorKey()),
true
);
}
}
}
/**
* On removal: destroy the cell in the registry.
*/
@SuppressWarnings("deprecation")
@Override
public void onRemove(
BlockState state,
Level level,
BlockPos pos,
BlockState newState,
boolean movedByPiston
) {
if (!state.is(newState.getBlock())) {
if (level instanceof ServerLevel serverLevel) {
BlockEntity be = level.getBlockEntity(pos);
if (
be instanceof CellCoreBlockEntity core &&
core.getCellId() != null
) {
CellRegistryV2 registry = CellRegistryV2.get(serverLevel);
CellDataV2 cell = registry.getCell(core.getCellId());
if (cell != null) {
cell.setState(CellState.COMPROMISED);
// Free all prisoners
for (UUID prisonerId : cell.getPrisonerIds()) {
registry.releasePrisoner(
cell.getId(),
prisonerId,
serverLevel.getServer()
);
}
// Notify owner
if (cell.getOwnerId() != null) {
ServerPlayer owner = serverLevel
.getServer()
.getPlayerList()
.getPlayer(cell.getOwnerId());
if (owner != null) {
String cellName =
cell.getName() != null
? cell.getName()
: "Cell " +
cell
.getId()
.toString()
.substring(0, 8);
SystemMessageManager.sendToPlayer(
owner,
SystemMessageManager.MessageCategory.WARNING,
cellName + " has been destroyed!"
);
}
}
registry.removeCell(cell.getId());
}
}
}
}
super.onRemove(state, level, pos, newState, movedByPiston);
}
}

View File

@@ -0,0 +1,34 @@
package com.tiedup.remake.blocks;
import net.minecraft.world.level.block.DoorBlock;
import net.minecraft.world.level.block.SoundType;
import net.minecraft.world.level.block.state.BlockBehaviour;
import net.minecraft.world.level.block.state.properties.BlockSetType;
import net.minecraft.world.level.material.MapColor;
/**
* Cell Door Block - Iron-like door that cannot be opened by hand.
*
* Phase 16: Blocks
*
* Features:
* - Cannot be opened by clicking (requires redstone)
* - Uses iron door behavior
* - Sturdy construction
*
* Based on original BlockKidnapDoorBase from 1.12.2
*/
public class BlockCellDoor extends DoorBlock {
public BlockCellDoor() {
super(
BlockBehaviour.Properties.of()
.mapColor(MapColor.METAL)
.strength(5.0f, 45.0f)
.sound(SoundType.METAL)
.requiresCorrectToolForDrops()
.noOcclusion(),
BlockSetType.IRON // J'ai remis moi même
);
}
}

View File

@@ -0,0 +1,346 @@
package com.tiedup.remake.blocks;
import com.tiedup.remake.blocks.entity.IronBarDoorBlockEntity;
import com.tiedup.remake.core.SystemMessageManager;
import com.tiedup.remake.items.ItemCellKey;
import com.tiedup.remake.items.ItemKey;
import com.tiedup.remake.items.ItemMasterKey;
import java.util.UUID;
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.item.ItemStack;
import net.minecraft.world.item.context.BlockPlaceContext;
import net.minecraft.world.level.BlockGetter;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.LevelAccessor;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockBehaviour;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.StateDefinition;
import net.minecraft.world.level.block.state.properties.*;
import net.minecraft.world.level.material.MapColor;
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;
/**
* Iron Bar Door - A door made of iron bars that can be locked.
*
* Phase: Kidnapper Revamp - Cell System
*
* Features:
* - Lockable with ItemKey (stores key UUID)
* - Can be unlocked with matching key, ItemCellKey, or ItemMasterKey
* - When locked, cannot be opened by redstone or by hand
* - Visually connects to iron bars (like vanilla iron bars)
* - Uses DoorBlock mechanics for open/close state
*/
public class BlockIronBarDoor extends DoorBlock implements EntityBlock {
// VoxelShapes for different orientations - centered like iron bars (7-9 = 2 pixels thick)
protected static final VoxelShape SOUTH_AABB = Block.box(
0.0,
0.0,
7.0,
16.0,
16.0,
9.0
);
protected static final VoxelShape NORTH_AABB = Block.box(
0.0,
0.0,
7.0,
16.0,
16.0,
9.0
);
protected static final VoxelShape WEST_AABB = Block.box(
7.0,
0.0,
0.0,
9.0,
16.0,
16.0
);
protected static final VoxelShape EAST_AABB = Block.box(
7.0,
0.0,
0.0,
9.0,
16.0,
16.0
);
public BlockIronBarDoor() {
super(
BlockBehaviour.Properties.of()
.mapColor(MapColor.METAL)
.strength(5.0f, 45.0f)
.sound(SoundType.METAL)
.requiresCorrectToolForDrops()
.noOcclusion(),
BlockSetType.IRON
);
}
// ==================== SHAPE ====================
@Override
public VoxelShape getShape(
BlockState state,
BlockGetter level,
BlockPos pos,
CollisionContext context
) {
Direction facing = state.getValue(FACING);
boolean open = state.getValue(OPEN);
boolean hingeRight = state.getValue(HINGE) == DoorHingeSide.RIGHT;
Direction effectiveDirection;
if (open) {
effectiveDirection = hingeRight
? facing.getCounterClockWise()
: facing.getClockWise();
} else {
effectiveDirection = facing;
}
return switch (effectiveDirection) {
case NORTH -> NORTH_AABB;
case SOUTH -> SOUTH_AABB;
case WEST -> WEST_AABB;
case EAST -> EAST_AABB;
default -> SOUTH_AABB;
};
}
// ==================== INTERACTION ====================
@Override
public InteractionResult use(
BlockState state,
Level level,
BlockPos pos,
Player player,
InteractionHand hand,
BlockHitResult hit
) {
// Get the BlockEntity
BlockEntity be = level.getBlockEntity(
getBlockEntityPos(state, pos, level)
);
if (!(be instanceof IronBarDoorBlockEntity doorEntity)) {
return InteractionResult.PASS;
}
ItemStack heldItem = player.getItemInHand(hand);
// Handle key interactions
if (heldItem.getItem() instanceof ItemMasterKey) {
// Master key can always unlock
if (doorEntity.isLocked()) {
if (!level.isClientSide) {
doorEntity.setLocked(false);
SystemMessageManager.sendToPlayer(
player,
SystemMessageManager.MessageCategory.INFO,
"Door unlocked with master key"
);
}
return InteractionResult.sidedSuccess(level.isClientSide);
}
} else if (heldItem.getItem() instanceof ItemCellKey) {
// Cell key can unlock any iron bar door
if (doorEntity.isLocked()) {
if (!level.isClientSide) {
doorEntity.setLocked(false);
SystemMessageManager.sendToPlayer(
player,
SystemMessageManager.MessageCategory.INFO,
"Door unlocked with cell key"
);
}
return InteractionResult.sidedSuccess(level.isClientSide);
}
} else if (heldItem.getItem() instanceof ItemKey key) {
UUID keyUUID = key.getKeyUUID(heldItem);
if (doorEntity.isLocked()) {
// Try to unlock
if (doorEntity.matchesKey(keyUUID)) {
if (!level.isClientSide) {
doorEntity.setLocked(false);
SystemMessageManager.sendToPlayer(
player,
SystemMessageManager.MessageCategory.INFO,
"Door unlocked"
);
}
return InteractionResult.sidedSuccess(level.isClientSide);
} else {
if (!level.isClientSide) {
SystemMessageManager.sendToPlayer(
player,
SystemMessageManager.MessageCategory.ERROR,
"This key doesn't fit this lock"
);
}
return InteractionResult.FAIL;
}
} else {
// Lock the door with this key
if (!level.isClientSide) {
doorEntity.setLockedByKeyUUID(keyUUID);
SystemMessageManager.sendToPlayer(
player,
SystemMessageManager.MessageCategory.INFO,
"Door locked"
);
}
return InteractionResult.sidedSuccess(level.isClientSide);
}
}
// If locked and no valid key, deny access
if (doorEntity.isLocked()) {
if (!level.isClientSide) {
SystemMessageManager.sendToPlayer(
player,
SystemMessageManager.MessageCategory.ERROR,
"This door is locked"
);
}
return InteractionResult.FAIL;
}
// Iron doors don't open by hand normally, but we allow it if unlocked
// Toggle the door state
if (!level.isClientSide) {
boolean newOpen = !state.getValue(OPEN);
setOpen(player, level, state, pos, newOpen);
}
return InteractionResult.sidedSuccess(level.isClientSide);
}
/**
* Get the BlockEntity position (handles double-height doors).
*/
private BlockPos getBlockEntityPos(
BlockState state,
BlockPos pos,
Level level
) {
// LOW FIX: Check if HALF property exists before accessing (WorldEdit/SetBlock safety)
if (!state.hasProperty(HALF)) {
// Invalid state - assume lower half
return pos;
}
// BlockEntity is always on the lower half
if (state.getValue(HALF) == DoubleBlockHalf.UPPER) {
return pos.below();
}
return pos;
}
// ==================== REDSTONE ====================
@Override
public void neighborChanged(
BlockState state,
Level level,
BlockPos pos,
Block neighborBlock,
BlockPos neighborPos,
boolean movedByPiston
) {
// Check if locked before allowing redstone control
BlockEntity be = level.getBlockEntity(
getBlockEntityPos(state, pos, level)
);
if (
be instanceof IronBarDoorBlockEntity doorEntity &&
doorEntity.isLocked()
) {
// Door is locked, ignore redstone
return;
}
// Allow normal redstone behavior if unlocked
super.neighborChanged(
state,
level,
pos,
neighborBlock,
neighborPos,
movedByPiston
);
}
// ==================== BLOCK ENTITY ====================
@Nullable
@Override
public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
// Only create block entity for the lower half
if (state.getValue(HALF) == DoubleBlockHalf.LOWER) {
return new IronBarDoorBlockEntity(pos, state);
}
return null;
}
// ==================== PLACEMENT ====================
@Override
public void setPlacedBy(
Level level,
BlockPos pos,
BlockState state,
@Nullable net.minecraft.world.entity.LivingEntity placer,
ItemStack stack
) {
super.setPlacedBy(level, pos, state, placer, stack);
// BlockEntity is automatically created for lower half
}
@Override
public void onRemove(
BlockState state,
Level level,
BlockPos pos,
BlockState newState,
boolean movedByPiston
) {
if (!state.is(newState.getBlock())) {
// Block entity is removed automatically
}
super.onRemove(state, level, pos, newState, movedByPiston);
}
// ==================== VISUAL ====================
@Override
public float getShadeBrightness(
BlockState state,
BlockGetter level,
BlockPos pos
) {
// Allow some light through like iron bars
return 1.0f;
}
@Override
public boolean propagatesSkylightDown(
BlockState state,
BlockGetter level,
BlockPos pos
) {
return true;
}
}

View File

@@ -0,0 +1,305 @@
package com.tiedup.remake.blocks;
import com.tiedup.remake.blocks.entity.KidnapBombBlockEntity;
import com.tiedup.remake.core.SystemMessageManager;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.entities.EntityKidnapBomb;
import com.tiedup.remake.util.BondageItemLoaderUtility;
import java.util.List;
import javax.annotation.Nullable;
import net.minecraft.ChatFormatting;
import net.minecraft.core.BlockPos;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.chat.Component;
import net.minecraft.sounds.SoundEvents;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.item.TooltipFlag;
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.SoundType;
import net.minecraft.world.level.block.TntBlock;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockBehaviour;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.gameevent.GameEvent;
import net.minecraft.world.level.storage.loot.LootParams;
import net.minecraft.world.level.storage.loot.parameters.LootContextParams;
import net.minecraft.world.phys.BlockHitResult;
/**
* Kidnap Bomb Block - TNT that applies bondage on explosion.
*
* Phase 16: Blocks
*
* Features:
* - TNT-like block that can be ignited
* - Stores bondage items via BlockEntity
* - On explosion, applies items to entities in radius (no block damage)
* - Can be loaded by right-clicking with bondage items
*
* Based on original BlockKidnapBomb from 1.12.2
*/
public class BlockKidnapBomb
extends TntBlock
implements EntityBlock, ICanBeLoaded
{
public BlockKidnapBomb() {
super(
BlockBehaviour.Properties.of()
.strength(0.0f)
.sound(SoundType.GRASS)
.ignitedByLava()
);
}
// ========================================
// BLOCK ENTITY
// ========================================
@Nullable
@Override
public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return new KidnapBombBlockEntity(pos, state);
}
@Nullable
public KidnapBombBlockEntity getBombEntity(
BlockGetter level,
BlockPos pos
) {
BlockEntity be = level.getBlockEntity(pos);
return be instanceof KidnapBombBlockEntity
? (KidnapBombBlockEntity) be
: null;
}
// ========================================
// EXPLOSION HANDLING
// ========================================
@Override
public void onCaughtFire(
BlockState state,
Level level,
BlockPos pos,
@Nullable net.minecraft.core.Direction face,
@Nullable LivingEntity igniter
) {
if (!level.isClientSide) {
KidnapBombBlockEntity bombTile = getBombEntity(level, pos);
explode(level, pos, bombTile, igniter);
}
}
/**
* Spawn the primed kidnap bomb entity.
*/
public void explode(
Level level,
BlockPos pos,
@Nullable KidnapBombBlockEntity bombTile,
@Nullable LivingEntity igniter
) {
if (!level.isClientSide) {
EntityKidnapBomb entity = new EntityKidnapBomb(
level,
pos.getX() + 0.5,
pos.getY(),
pos.getZ() + 0.5,
igniter,
bombTile
);
level.addFreshEntity(entity);
level.playSound(
null,
entity.getX(),
entity.getY(),
entity.getZ(),
SoundEvents.TNT_PRIMED,
SoundSource.BLOCKS,
1.0f,
1.0f
);
level.gameEvent(igniter, GameEvent.PRIME_FUSE, pos);
TiedUpMod.LOGGER.info(
"[BlockKidnapBomb] Bomb primed at {} by {}",
pos,
igniter != null ? igniter.getName().getString() : "unknown"
);
}
}
// ========================================
// LOADING ITEMS
// ========================================
@Override
public InteractionResult use(
BlockState state,
Level level,
BlockPos pos,
Player player,
InteractionHand hand,
BlockHitResult hit
) {
ItemStack heldItem = player.getItemInHand(hand);
// First check if it's flint and steel (default TNT behavior)
if (
heldItem.is(Items.FLINT_AND_STEEL) || heldItem.is(Items.FIRE_CHARGE)
) {
return super.use(state, level, pos, player, hand, hit);
}
if (hand != InteractionHand.MAIN_HAND) {
return InteractionResult.PASS;
}
if (heldItem.isEmpty()) {
return InteractionResult.PASS;
}
// Check if it's a bondage item
if (!BondageItemLoaderUtility.isLoadableBondageItem(heldItem)) {
return InteractionResult.PASS;
}
// Server-side only
if (level.isClientSide) {
return InteractionResult.SUCCESS;
}
KidnapBombBlockEntity bomb = getBombEntity(level, pos);
if (bomb == null) {
return InteractionResult.PASS;
}
// Try to load the held item into the appropriate slot
if (
BondageItemLoaderUtility.loadItemIntoHolder(bomb, heldItem, player)
) {
SystemMessageManager.sendToPlayer(
player,
"Item loaded into bomb",
ChatFormatting.YELLOW
);
return InteractionResult.SUCCESS;
}
return InteractionResult.PASS;
}
// ========================================
// DROPS WITH NBT
// ========================================
@Override
public List<ItemStack> getDrops(
BlockState state,
LootParams.Builder params
) {
BlockEntity be = params.getOptionalParameter(
LootContextParams.BLOCK_ENTITY
);
ItemStack stack = new ItemStack(this);
if (be instanceof KidnapBombBlockEntity bomb) {
CompoundTag beTag = new CompoundTag();
bomb.writeBondageData(beTag);
if (!beTag.isEmpty()) {
stack.addTagElement("BlockEntityTag", beTag);
}
}
return List.of(stack);
}
// ========================================
// TOOLTIP
// ========================================
@Override
public void appendHoverText(
ItemStack stack,
@Nullable BlockGetter level,
List<Component> tooltip,
TooltipFlag flag
) {
tooltip.add(
Component.translatable("block.tiedup.kidnap_bomb.desc").withStyle(
ChatFormatting.GRAY
)
);
CompoundTag nbt = stack.getTag();
if (nbt != null && nbt.contains("BlockEntityTag")) {
CompoundTag beTag = nbt.getCompound("BlockEntityTag");
// Check if loaded with any items
if (
beTag.contains("bind") ||
beTag.contains("gag") ||
beTag.contains("blindfold") ||
beTag.contains("earplugs") ||
beTag.contains("collar")
) {
tooltip.add(
Component.literal("Loaded:").withStyle(
ChatFormatting.YELLOW
)
);
// List loaded items
BondageItemLoaderUtility.addItemToTooltip(
tooltip,
beTag,
"bind"
);
BondageItemLoaderUtility.addItemToTooltip(
tooltip,
beTag,
"gag"
);
BondageItemLoaderUtility.addItemToTooltip(
tooltip,
beTag,
"blindfold"
);
BondageItemLoaderUtility.addItemToTooltip(
tooltip,
beTag,
"earplugs"
);
BondageItemLoaderUtility.addItemToTooltip(
tooltip,
beTag,
"collar"
);
BondageItemLoaderUtility.addItemToTooltip(
tooltip,
beTag,
"clothes"
);
} else {
tooltip.add(
Component.literal("Empty").withStyle(ChatFormatting.GREEN)
);
}
} else {
tooltip.add(
Component.literal("Empty").withStyle(ChatFormatting.GREEN)
);
}
}
}

View File

@@ -0,0 +1,247 @@
package com.tiedup.remake.blocks;
import com.tiedup.remake.blocks.entity.MarkerBlockEntity;
import com.tiedup.remake.blocks.entity.ModBlockEntities;
import com.tiedup.remake.cells.MarkerType;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.entities.EntityKidnapper;
import com.tiedup.remake.items.ItemAdminWand;
import java.util.List;
import java.util.UUID;
import net.minecraft.core.BlockPos;
import net.minecraft.core.particles.ParticleTypes;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.util.RandomSource;
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.BaseEntityBlock;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.RenderShape;
import net.minecraft.world.level.block.SoundType;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockBehaviour;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.material.MapColor;
import net.minecraft.world.level.material.PushReaction;
import net.minecraft.world.phys.AABB;
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;
/**
* Marker Block - Invisible block used to define cell spawn points.
*
* Phase: Kidnapper Revamp - Cell System
*
* Features:
* - Invisible (no render)
* - No collision
* - Stores cell UUID via BlockEntity
* - Destroying removes the cell from registry
*
* Placed by structure generation or Admin Wand.
*/
public class BlockMarker extends BaseEntityBlock {
// Small shape for selection/picking
private static final VoxelShape SHAPE = Block.box(6, 6, 6, 10, 10, 10);
public BlockMarker() {
super(
BlockBehaviour.Properties.of()
.mapColor(MapColor.NONE)
.strength(0.5f) // Easy to break
.sound(SoundType.WOOD)
.noCollission()
.noOcclusion()
.pushReaction(PushReaction.DESTROY)
);
}
// ==================== SHAPE ====================
@Override
public VoxelShape getShape(
BlockState state,
BlockGetter level,
BlockPos pos,
CollisionContext context
) {
// Small hitbox for selection
return SHAPE;
}
@Override
public VoxelShape getCollisionShape(
BlockState state,
BlockGetter level,
BlockPos pos,
CollisionContext context
) {
// No collision
return Shapes.empty();
}
@Override
public VoxelShape getVisualShape(
BlockState state,
BlockGetter level,
BlockPos pos,
CollisionContext context
) {
// No visual shape
return Shapes.empty();
}
// ==================== RENDER ====================
@Override
public RenderShape getRenderShape(BlockState state) {
// Invisible - no model rendering
return RenderShape.INVISIBLE;
}
@Override
public float getShadeBrightness(
BlockState state,
BlockGetter level,
BlockPos pos
) {
// Full brightness (no shadow)
return 1.0f;
}
@Override
public boolean propagatesSkylightDown(
BlockState state,
BlockGetter level,
BlockPos pos
) {
return true;
}
// ==================== PARTICLE FEEDBACK ====================
/**
* Spawn particles to make the marker visible when a player is holding a wand.
* This provides visual feedback for the otherwise invisible block.
*/
@Override
public void animateTick(
BlockState state,
Level level,
BlockPos pos,
RandomSource random
) {
// Only show particles if a nearby player is holding an AdminWand
Player nearestPlayer = level.getNearestPlayer(
pos.getX() + 0.5,
pos.getY() + 0.5,
pos.getZ() + 0.5,
16.0, // Detection range
false // Include spectators
);
if (nearestPlayer == null) return;
// Check if player is holding an Admin Wand
boolean holdingWand =
nearestPlayer.getMainHandItem().getItem() instanceof
ItemAdminWand ||
nearestPlayer.getOffhandItem().getItem() instanceof ItemAdminWand;
if (!holdingWand) return;
// Spawn subtle particles at the marker position
// Use END_ROD for a glowing effect
if (random.nextInt(3) == 0) {
// 1 in 3 chance per tick for subtle effect
level.addParticle(
ParticleTypes.END_ROD,
pos.getX() + 0.5 + (random.nextDouble() - 0.5) * 0.3,
pos.getY() + 0.5 + (random.nextDouble() - 0.5) * 0.3,
pos.getZ() + 0.5 + (random.nextDouble() - 0.5) * 0.3,
0,
0.02,
0 // Slight upward drift
);
}
}
// ==================== BLOCK ENTITY ====================
@Nullable
@Override
public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return new MarkerBlockEntity(pos, state);
}
// ==================== DESTRUCTION ====================
@Override
public void onRemove(
BlockState state,
Level level,
BlockPos pos,
BlockState newState,
boolean movedByPiston
) {
if (!state.is(newState.getBlock())) {
// Block is being destroyed, remove the cell from registry
if (level instanceof ServerLevel serverLevel) {
BlockEntity be = level.getBlockEntity(pos);
if (be instanceof MarkerBlockEntity marker) {
// Alert kidnappers if this was a WALL marker (potential breach)
if (marker.getMarkerType() == MarkerType.WALL) {
alertNearbyKidnappersOfBreach(
serverLevel,
pos,
marker.getCellId()
);
}
marker.deleteCell();
}
}
}
super.onRemove(state, level, pos, newState, movedByPiston);
}
/**
* Alert nearby kidnappers when a WALL marker is destroyed.
* This indicates a potential breach in the cell that prisoners could escape through.
*
* @param level The server level
* @param breachPos The position of the destroyed wall
* @param cellId The cell UUID (may be null)
*/
private void alertNearbyKidnappersOfBreach(
ServerLevel level,
BlockPos breachPos,
UUID cellId
) {
if (cellId == null) {
return;
}
// Find kidnappers within 50 blocks
AABB searchBox = new AABB(breachPos).inflate(50, 20, 50);
List<EntityKidnapper> kidnappers = level.getEntitiesOfClass(
EntityKidnapper.class,
searchBox
);
if (!kidnappers.isEmpty()) {
TiedUpMod.LOGGER.info(
"[BlockMarker] WALL destroyed at {}, alerting {} kidnappers",
breachPos.toShortString(),
kidnappers.size()
);
for (EntityKidnapper kidnapper : kidnappers) {
kidnapper.onCellBreach(breachPos, cellId);
}
}
}
}

View File

@@ -0,0 +1,395 @@
package com.tiedup.remake.blocks;
import com.tiedup.remake.blocks.entity.TrapBlockEntity;
import com.tiedup.remake.core.SystemMessageManager;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.state.IBondageState;
import com.tiedup.remake.util.BondageItemLoaderUtility;
import com.tiedup.remake.util.KidnappedHelper;
import java.util.List;
import javax.annotation.Nullable;
import net.minecraft.ChatFormatting;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.chat.Component;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.TooltipFlag;
import net.minecraft.world.item.context.BlockPlaceContext;
import net.minecraft.world.level.BlockGetter;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.LevelAccessor;
import net.minecraft.world.level.LevelReader;
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.RenderShape;
import net.minecraft.world.level.block.SoundType;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockBehaviour;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.storage.loot.LootParams;
import net.minecraft.world.level.storage.loot.parameters.LootContextParams;
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;
/**
* Rope Trap Block - Trap that ties up entities when they walk on it.
*
* Phase 16: Blocks
*
* Features:
* - Flat block (1 pixel tall) placed on solid surfaces
* - Can be loaded with bondage items by right-clicking
* - When armed and entity walks on it, applies all stored items
* - Destroys itself after triggering
* - Preserves NBT data when broken
*
* Based on original BlockRopesTrap from 1.12.2
*/
public class BlockRopeTrap extends Block implements EntityBlock, ICanBeLoaded {
// Shape: 1 pixel tall carpet-like block
protected static final VoxelShape TRAP_SHAPE = Block.box(
0.0,
0.0,
0.0,
16.0,
1.0,
16.0
);
public BlockRopeTrap() {
super(
BlockBehaviour.Properties.of()
.strength(1.0f, 0.5f)
.sound(SoundType.WOOL)
.noOcclusion()
.noCollission() // Entities can walk through/on it
);
}
// ========================================
// SHAPE AND RENDERING
// ========================================
@Override
public VoxelShape getShape(
BlockState state,
BlockGetter level,
BlockPos pos,
CollisionContext context
) {
return TRAP_SHAPE;
}
@Override
public VoxelShape getCollisionShape(
BlockState state,
BlockGetter level,
BlockPos pos,
CollisionContext context
) {
return Shapes.empty(); // No collision - entities walk through
}
@Override
public RenderShape getRenderShape(BlockState state) {
return RenderShape.MODEL;
}
// ========================================
// PLACEMENT RULES
// ========================================
@Override
public boolean canSurvive(
BlockState state,
LevelReader level,
BlockPos pos
) {
BlockPos below = pos.below();
BlockState belowState = level.getBlockState(below);
// Can only be placed on full solid blocks
return belowState.isFaceSturdy(level, below, Direction.UP);
}
@Nullable
@Override
public BlockState getStateForPlacement(BlockPlaceContext context) {
if (
!canSurvive(
defaultBlockState(),
context.getLevel(),
context.getClickedPos()
)
) {
return null;
}
return defaultBlockState();
}
@Override
public BlockState updateShape(
BlockState state,
Direction facing,
BlockState facingState,
LevelAccessor level,
BlockPos pos,
BlockPos facingPos
) {
// Break if support block is removed
if (facing == Direction.DOWN && !canSurvive(state, level, pos)) {
return Blocks.AIR.defaultBlockState();
}
return super.updateShape(
state,
facing,
facingState,
level,
pos,
facingPos
);
}
// ========================================
// BLOCK ENTITY
// ========================================
@Nullable
@Override
public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return new TrapBlockEntity(pos, state);
}
@Nullable
public TrapBlockEntity getTrapEntity(BlockGetter level, BlockPos pos) {
BlockEntity be = level.getBlockEntity(pos);
return be instanceof TrapBlockEntity ? (TrapBlockEntity) be : null;
}
// ========================================
// TRAP TRIGGER
// ========================================
@Override
public void entityInside(
BlockState state,
Level level,
BlockPos pos,
Entity entity
) {
if (level.isClientSide) return;
// Only affect living entities
if (!(entity instanceof LivingEntity living)) return;
// Get target's kidnapped state
IBondageState targetState = KidnappedHelper.getKidnappedState(living);
if (targetState == null) return;
// Don't trigger if already tied
if (targetState.isTiedUp()) return;
// Get trap data
TrapBlockEntity trap = getTrapEntity(level, pos);
if (trap == null || !trap.isArmed()) return;
// Apply all bondage items
ItemStack bind = trap.getBind();
ItemStack gag = trap.getGag();
ItemStack blindfold = trap.getBlindfold();
ItemStack earplugs = trap.getEarplugs();
ItemStack collar = trap.getCollar();
ItemStack clothes = trap.getClothes();
targetState.applyBondage(
bind,
gag,
blindfold,
earplugs,
collar,
clothes
);
// Destroy the trap
level.destroyBlock(pos, false);
// Notify target
if (entity instanceof Player player) {
player.displayClientMessage(
Component.translatable("tiedup.trap.triggered").withStyle(
ChatFormatting.RED
),
true
);
}
TiedUpMod.LOGGER.info(
"[BlockRopeTrap] Trap triggered at {} on {}",
pos,
entity.getName().getString()
);
}
// ========================================
// LOADING ITEMS
// ========================================
@Override
public InteractionResult use(
BlockState state,
Level level,
BlockPos pos,
Player player,
InteractionHand hand,
BlockHitResult hit
) {
if (hand != InteractionHand.MAIN_HAND) {
return InteractionResult.PASS;
}
ItemStack heldItem = player.getItemInHand(hand);
// Empty hand = do nothing
if (heldItem.isEmpty()) {
return InteractionResult.PASS;
}
// Check if it's a bondage item
if (!BondageItemLoaderUtility.isLoadableBondageItem(heldItem)) {
return InteractionResult.PASS;
}
// Server-side only
if (level.isClientSide) {
return InteractionResult.SUCCESS;
}
TrapBlockEntity trap = getTrapEntity(level, pos);
if (trap == null) {
return InteractionResult.PASS;
}
// Try to load the held item into the appropriate slot
if (
BondageItemLoaderUtility.loadItemIntoHolder(trap, heldItem, player)
) {
SystemMessageManager.sendToPlayer(
player,
"Item loaded into trap",
ChatFormatting.YELLOW
);
return InteractionResult.SUCCESS;
}
return InteractionResult.PASS;
}
// ========================================
// DROPS WITH NBT
// ========================================
@Override
public List<ItemStack> getDrops(
BlockState state,
LootParams.Builder params
) {
BlockEntity be = params.getOptionalParameter(
LootContextParams.BLOCK_ENTITY
);
ItemStack stack = new ItemStack(this);
if (be instanceof TrapBlockEntity trap) {
CompoundTag beTag = new CompoundTag();
trap.writeBondageData(beTag);
if (!beTag.isEmpty()) {
stack.addTagElement("BlockEntityTag", beTag);
}
}
return List.of(stack);
}
// ========================================
// TOOLTIP
// ========================================
@Override
public void appendHoverText(
ItemStack stack,
@Nullable BlockGetter level,
List<Component> tooltip,
TooltipFlag flag
) {
tooltip.add(
Component.translatable("block.tiedup.rope_trap.desc").withStyle(
ChatFormatting.GRAY
)
);
CompoundTag nbt = stack.getTag();
if (nbt != null && nbt.contains("BlockEntityTag")) {
CompoundTag beTag = nbt.getCompound("BlockEntityTag");
// Check if armed
if (beTag.contains("bind")) {
tooltip.add(
Component.literal("Armed").withStyle(
ChatFormatting.DARK_RED
)
);
// List loaded items
BondageItemLoaderUtility.addItemToTooltip(
tooltip,
beTag,
"bind"
);
BondageItemLoaderUtility.addItemToTooltip(
tooltip,
beTag,
"gag"
);
BondageItemLoaderUtility.addItemToTooltip(
tooltip,
beTag,
"blindfold"
);
BondageItemLoaderUtility.addItemToTooltip(
tooltip,
beTag,
"earplugs"
);
BondageItemLoaderUtility.addItemToTooltip(
tooltip,
beTag,
"collar"
);
BondageItemLoaderUtility.addItemToTooltip(
tooltip,
beTag,
"clothes"
);
} else {
tooltip.add(
Component.literal("Disarmed").withStyle(
ChatFormatting.GREEN
)
);
}
} else {
tooltip.add(
Component.literal("Disarmed").withStyle(ChatFormatting.GREEN)
);
}
}
}

View File

@@ -0,0 +1,254 @@
package com.tiedup.remake.blocks;
import com.tiedup.remake.blocks.entity.TrappedChestBlockEntity;
import com.tiedup.remake.core.SystemMessageManager;
import com.tiedup.remake.items.base.*;
import com.tiedup.remake.state.IBondageState;
import com.tiedup.remake.util.BondageItemLoaderUtility;
import com.tiedup.remake.util.KidnappedHelper;
import java.util.List;
import java.util.function.Supplier;
import javax.annotation.Nullable;
import net.minecraft.ChatFormatting;
import net.minecraft.core.BlockPos;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.chat.Component;
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.TooltipFlag;
import net.minecraft.world.level.BlockGetter;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.ChestBlock;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.entity.BlockEntityType;
import net.minecraft.world.level.block.entity.ChestBlockEntity;
import net.minecraft.world.level.block.state.BlockBehaviour;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.storage.loot.LootParams;
import net.minecraft.world.level.storage.loot.parameters.LootContextParams;
import net.minecraft.world.phys.BlockHitResult;
/**
* Trapped Chest Block - Chest that traps players when opened.
*
* Phase 16: Blocks
*
* Extends vanilla ChestBlock for proper chest behavior.
* Sneak + right-click to load bondage items.
* Normal open triggers the trap if armed.
*/
public class BlockTrappedChest extends ChestBlock implements ICanBeLoaded {
public BlockTrappedChest() {
super(BlockBehaviour.Properties.of().strength(2.5f).noOcclusion(), () ->
com.tiedup.remake.blocks.entity.ModBlockEntities.TRAPPED_CHEST.get()
);
}
// ========================================
// BLOCK ENTITY
// ========================================
@Override
public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return new TrappedChestBlockEntity(pos, state);
}
@Nullable
public TrappedChestBlockEntity getTrapEntity(
BlockGetter level,
BlockPos pos
) {
BlockEntity be = level.getBlockEntity(pos);
return be instanceof TrappedChestBlockEntity
? (TrappedChestBlockEntity) be
: null;
}
// ========================================
// INTERACTION - TRAP TRIGGER
// ========================================
@Override
public InteractionResult use(
BlockState state,
Level level,
BlockPos pos,
Player player,
InteractionHand hand,
BlockHitResult hit
) {
if (hand != InteractionHand.MAIN_HAND) {
return InteractionResult.PASS;
}
ItemStack heldItem = player.getItemInHand(hand);
// Check if holding a bondage item = load it (don't open chest)
if (BondageItemLoaderUtility.isLoadableBondageItem(heldItem)) {
// Server-side only
if (!level.isClientSide) {
TrappedChestBlockEntity chest = getTrapEntity(level, pos);
if (
chest != null &&
BondageItemLoaderUtility.loadItemIntoHolder(
chest,
heldItem,
player
)
) {
SystemMessageManager.sendToPlayer(
player,
"Item loaded into trap",
ChatFormatting.YELLOW
);
}
}
return InteractionResult.SUCCESS;
}
// Normal open - check for trap trigger first (server-side)
if (!level.isClientSide) {
TrappedChestBlockEntity chest = getTrapEntity(level, pos);
if (chest != null && chest.isArmed()) {
IBondageState playerState = KidnappedHelper.getKidnappedState(
player
);
if (playerState != null && !playerState.isTiedUp()) {
// Apply bondage
playerState.applyBondage(
chest.getBind(),
chest.getGag(),
chest.getBlindfold(),
chest.getEarplugs(),
chest.getCollar(),
chest.getClothes()
);
// Clear the chest trap contents
chest.setBind(ItemStack.EMPTY);
chest.setGag(ItemStack.EMPTY);
chest.setBlindfold(ItemStack.EMPTY);
chest.setEarplugs(ItemStack.EMPTY);
chest.setCollar(ItemStack.EMPTY);
chest.setClothes(ItemStack.EMPTY);
SystemMessageManager.sendToPlayer(
player,
SystemMessageManager.MessageCategory.WARNING,
"You fell into a trap!"
);
// FIX: Don't open chest GUI after trap triggers
return InteractionResult.SUCCESS;
}
}
}
// Normal chest behavior (open GUI) - only if trap didn't trigger
return super.use(state, level, pos, player, hand, hit);
}
// ========================================
// DROPS WITH NBT
// ========================================
@Override
public List<ItemStack> getDrops(
BlockState state,
LootParams.Builder params
) {
List<ItemStack> drops = super.getDrops(state, params);
BlockEntity be = params.getOptionalParameter(
LootContextParams.BLOCK_ENTITY
);
if (be instanceof TrappedChestBlockEntity chest && chest.isArmed()) {
// Add trap data to the first drop (the chest itself)
if (!drops.isEmpty()) {
ItemStack stack = drops.get(0);
CompoundTag beTag = new CompoundTag();
chest.writeBondageData(beTag);
if (!beTag.isEmpty()) {
stack.addTagElement("BlockEntityTag", beTag);
}
}
}
return drops;
}
// ========================================
// TOOLTIP
// ========================================
@Override
public void appendHoverText(
ItemStack stack,
@Nullable BlockGetter level,
List<Component> tooltip,
TooltipFlag flag
) {
tooltip.add(
Component.translatable("block.tiedup.trapped_chest.desc").withStyle(
ChatFormatting.GRAY
)
);
CompoundTag nbt = stack.getTag();
if (nbt != null && nbt.contains("BlockEntityTag")) {
CompoundTag beTag = nbt.getCompound("BlockEntityTag");
if (
beTag.contains("bind") ||
beTag.contains("gag") ||
beTag.contains("blindfold") ||
beTag.contains("earplugs") ||
beTag.contains("collar")
) {
tooltip.add(
Component.literal("Armed").withStyle(
ChatFormatting.DARK_RED
)
);
BondageItemLoaderUtility.addItemToTooltip(
tooltip,
beTag,
"bind"
);
BondageItemLoaderUtility.addItemToTooltip(
tooltip,
beTag,
"gag"
);
BondageItemLoaderUtility.addItemToTooltip(
tooltip,
beTag,
"blindfold"
);
BondageItemLoaderUtility.addItemToTooltip(
tooltip,
beTag,
"earplugs"
);
BondageItemLoaderUtility.addItemToTooltip(
tooltip,
beTag,
"collar"
);
} else {
tooltip.add(
Component.literal("Disarmed").withStyle(
ChatFormatting.GREEN
)
);
}
} else {
tooltip.add(
Component.literal("Disarmed").withStyle(ChatFormatting.GREEN)
);
}
}
}

View File

@@ -0,0 +1,17 @@
package com.tiedup.remake.blocks;
/**
* Marker interface for blocks that can have bondage items loaded into them.
*
* Phase 16: Blocks
*
* Implemented by:
* - BlockRopesTrap - applies items when entity walks on it
* - BlockTrappedBed - applies items when player sleeps
* - BlockKidnapBomb - passes items to explosion entity
*
* These blocks have associated BlockEntities that store the bondage items.
*/
public interface ICanBeLoaded {
// Marker interface - no methods required
}

View File

@@ -0,0 +1,225 @@
package com.tiedup.remake.blocks;
import com.tiedup.remake.core.TiedUpMod;
import java.util.function.Supplier;
import net.minecraft.world.item.BlockItem;
import net.minecraft.world.item.DoubleHighBlockItem;
import net.minecraft.world.item.Item;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.DoorBlock;
import net.minecraft.world.level.block.SlabBlock;
import net.minecraft.world.level.block.SoundType;
import net.minecraft.world.level.block.StairBlock;
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;
/**
* Mod Blocks Registration
*
* Phase 16: Blocks
*
* Handles registration of all TiedUp blocks using DeferredRegister.
*
* Blocks:
* - Padded block + variants (slab, stairs, pane)
* - Rope trap
* - Trapped bed
* - Cell door
* - Kidnap bomb
*/
public class ModBlocks {
// DeferredRegister for blocks
public static final DeferredRegister<Block> BLOCKS =
DeferredRegister.create(ForgeRegistries.BLOCKS, TiedUpMod.MOD_ID);
// DeferredRegister for block items (linked to ModItems)
public static final DeferredRegister<Item> BLOCK_ITEMS =
DeferredRegister.create(ForgeRegistries.ITEMS, TiedUpMod.MOD_ID);
// ========================================
// PADDED BLOCKS
// ========================================
/**
* Base padded block properties.
* Cloth material, soft, quiet.
*/
private static BlockBehaviour.Properties paddedProperties() {
return BlockBehaviour.Properties.of()
.mapColor(MapColor.WOOL)
.strength(0.9f, 45.0f)
.sound(SoundType.WOOL);
}
/**
* Padded Block - Basic soft block.
*/
public static final RegistryObject<Block> PADDED_BLOCK = registerBlock(
"padded_block",
() -> new Block(paddedProperties())
);
/**
* Padded Slab - Half-height padded block.
*/
public static final RegistryObject<Block> PADDED_SLAB = registerBlock(
"padded_slab",
() -> new SlabBlock(paddedProperties())
);
/**
* Padded Stairs - Stair variant of padded block.
*/
public static final RegistryObject<Block> PADDED_STAIRS = registerBlock(
"padded_stairs",
() ->
new StairBlock(
() -> PADDED_BLOCK.get().defaultBlockState(),
paddedProperties()
)
);
// ========================================
// TRAP BLOCKS
// ========================================
/**
* Rope Trap - Flat trap that ties up entities that walk on it.
* Uses BlockEntity to store bondage items.
*/
public static final RegistryObject<Block> ROPE_TRAP = registerBlock(
"rope_trap",
BlockRopeTrap::new
);
/**
* Kidnap Bomb - TNT that applies bondage on explosion.
* Uses BlockEntity to store bondage items.
*/
public static final RegistryObject<Block> KIDNAP_BOMB = registerBlock(
"kidnap_bomb",
BlockKidnapBomb::new
);
/**
* Trapped Chest - Chest that traps players when opened.
* Uses BlockEntity to store bondage items.
*/
public static final RegistryObject<Block> TRAPPED_CHEST = registerBlock(
"trapped_chest",
BlockTrappedChest::new
);
// ========================================
// DOOR BLOCKS
// ========================================
/**
* Cell Door - Iron-like door that requires redstone to open.
* Cannot be opened by hand.
*/
public static final RegistryObject<BlockCellDoor> CELL_DOOR =
registerDoorBlock("cell_door", BlockCellDoor::new);
// ========================================
// CELL SYSTEM BLOCKS
// ========================================
/**
* Marker Block - Invisible block for cell spawn points.
* Stores cell UUID and links to CellRegistry.
*/
public static final RegistryObject<Block> MARKER = registerBlockNoItem(
"marker",
BlockMarker::new
);
/**
* Iron Bar Door - Lockable door made of iron bars.
* Can be locked with keys and unlocked with matching key, cell key, or master key.
*/
public static final RegistryObject<BlockIronBarDoor> IRON_BAR_DOOR =
registerDoorBlock("iron_bar_door", BlockIronBarDoor::new);
/**
* Cell Core - Anchor block for Cell System V2.
* Placed into a wall; runs flood-fill to detect the room and register a cell.
*/
public static final RegistryObject<Block> CELL_CORE = registerBlock(
"cell_core",
BlockCellCore::new
);
// ========================================
// REGISTRATION HELPERS
// ========================================
/**
* Register a block and its corresponding BlockItem.
*
* @param name Block registry name
* @param blockSupplier Block supplier
* @return RegistryObject for the block
*/
private static <T extends Block> RegistryObject<T> registerBlock(
String name,
Supplier<T> blockSupplier
) {
RegistryObject<T> block = BLOCKS.register(name, blockSupplier);
registerBlockItem(name, block);
return block;
}
/**
* Register a BlockItem for a block.
*
* @param name Item registry name (same as block)
* @param block The block to create an item for
*/
private static <T extends Block> void registerBlockItem(
String name,
RegistryObject<T> block
) {
BLOCK_ITEMS.register(name, () ->
new BlockItem(block.get(), new Item.Properties())
);
}
/**
* Register a block without an item.
* Used for blocks that need special item handling (e.g., trapped bed, doors).
*
* @param name Block registry name
* @param blockSupplier Block supplier
* @return RegistryObject for the block
*/
private static <T extends Block> RegistryObject<T> registerBlockNoItem(
String name,
Supplier<T> blockSupplier
) {
return BLOCKS.register(name, blockSupplier);
}
/**
* Register a door block with DoubleHighBlockItem.
* Doors are double-height blocks and need special item handling.
*
* @param name Block registry name
* @param blockSupplier Block supplier (must return DoorBlock or subclass)
* @return RegistryObject for the block
*/
private static <T extends DoorBlock> RegistryObject<T> registerDoorBlock(
String name,
Supplier<T> blockSupplier
) {
RegistryObject<T> block = BLOCKS.register(name, blockSupplier);
BLOCK_ITEMS.register(name, () ->
new DoubleHighBlockItem(block.get(), new Item.Properties())
);
return block;
}
}

View File

@@ -0,0 +1,342 @@
package com.tiedup.remake.blocks.entity;
import com.tiedup.remake.items.base.ItemBind;
import com.tiedup.remake.items.base.ItemBlindfold;
import com.tiedup.remake.items.base.ItemCollar;
import com.tiedup.remake.items.base.ItemEarplugs;
import com.tiedup.remake.items.base.ItemGag;
import com.tiedup.remake.items.clothes.GenericClothes;
import javax.annotation.Nullable;
import net.minecraft.core.BlockPos;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.game.ClientGamePacketListener;
import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
import net.minecraft.world.item.ItemStack;
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 BlockEntity for blocks that store bondage items.
*
* Phase 16: Blocks
*
* Stores up to 6 bondage items:
* - Bind (ropes, chains, straitjacket, etc.)
* - Gag
* - Blindfold
* - Earplugs
* - Collar
* - Clothes
*
* Features:
* - Full NBT serialization
* - Network synchronization for client rendering
* - Item type validation on load
*
* Based on original TileEntityBondageItemHandler from 1.12.2
*/
public abstract class BondageItemBlockEntity
extends BlockEntity
implements IBondageItemHolder
{
// ========================================
// STORED ITEMS
// ========================================
private ItemStack bind = ItemStack.EMPTY;
private ItemStack gag = ItemStack.EMPTY;
private ItemStack blindfold = ItemStack.EMPTY;
private ItemStack earplugs = ItemStack.EMPTY;
private ItemStack collar = ItemStack.EMPTY;
private ItemStack clothes = ItemStack.EMPTY;
/**
* Off-mode prevents network updates.
* Used when reading NBT for tooltips without affecting the world.
*/
private final boolean offMode;
// ========================================
// CONSTRUCTORS
// ========================================
public BondageItemBlockEntity(
BlockEntityType<?> type,
BlockPos pos,
BlockState state
) {
this(type, pos, state, false);
}
public BondageItemBlockEntity(
BlockEntityType<?> type,
BlockPos pos,
BlockState state,
boolean offMode
) {
super(type, pos, state);
this.offMode = offMode;
}
// ========================================
// BIND
// ========================================
@Override
public ItemStack getBind() {
return this.bind;
}
@Override
public void setBind(ItemStack bind) {
this.bind = bind != null ? bind : ItemStack.EMPTY;
this.setChangedAndSync();
}
// ========================================
// GAG
// ========================================
@Override
public ItemStack getGag() {
return this.gag;
}
@Override
public void setGag(ItemStack gag) {
this.gag = gag != null ? gag : ItemStack.EMPTY;
this.setChangedAndSync();
}
// ========================================
// BLINDFOLD
// ========================================
@Override
public ItemStack getBlindfold() {
return this.blindfold;
}
@Override
public void setBlindfold(ItemStack blindfold) {
this.blindfold = blindfold != null ? blindfold : ItemStack.EMPTY;
this.setChangedAndSync();
}
// ========================================
// EARPLUGS
// ========================================
@Override
public ItemStack getEarplugs() {
return this.earplugs;
}
@Override
public void setEarplugs(ItemStack earplugs) {
this.earplugs = earplugs != null ? earplugs : ItemStack.EMPTY;
this.setChangedAndSync();
}
// ========================================
// COLLAR
// ========================================
@Override
public ItemStack getCollar() {
return this.collar;
}
@Override
public void setCollar(ItemStack collar) {
this.collar = collar != null ? collar : ItemStack.EMPTY;
this.setChangedAndSync();
}
// ========================================
// CLOTHES
// ========================================
@Override
public ItemStack getClothes() {
return this.clothes;
}
@Override
public void setClothes(ItemStack clothes) {
this.clothes = clothes != null ? clothes : ItemStack.EMPTY;
this.setChangedAndSync();
}
// ========================================
// STATE
// ========================================
@Override
public boolean isArmed() {
return !this.bind.isEmpty();
}
/**
* Clear all stored bondage items.
* Called after applying items to a target.
*/
public void clearAllItems() {
this.bind = ItemStack.EMPTY;
this.gag = ItemStack.EMPTY;
this.blindfold = ItemStack.EMPTY;
this.earplugs = ItemStack.EMPTY;
this.collar = ItemStack.EMPTY;
this.clothes = ItemStack.EMPTY;
this.setChangedAndSync();
}
// ========================================
// NBT SERIALIZATION
// ========================================
@Override
public void load(CompoundTag tag) {
super.load(tag);
this.readBondageData(tag);
}
@Override
protected void saveAdditional(CompoundTag tag) {
super.saveAdditional(tag);
this.writeBondageData(tag);
}
@Override
public void readBondageData(CompoundTag tag) {
// Read bind with type validation
if (tag.contains("bind")) {
ItemStack bindStack = ItemStack.of(tag.getCompound("bind"));
if (
!bindStack.isEmpty() && bindStack.getItem() instanceof ItemBind
) {
this.bind = bindStack;
}
}
// Read gag with type validation
if (tag.contains("gag")) {
ItemStack gagStack = ItemStack.of(tag.getCompound("gag"));
if (!gagStack.isEmpty() && gagStack.getItem() instanceof ItemGag) {
this.gag = gagStack;
}
}
// Read blindfold with type validation
if (tag.contains("blindfold")) {
ItemStack blindfoldStack = ItemStack.of(
tag.getCompound("blindfold")
);
if (
!blindfoldStack.isEmpty() &&
blindfoldStack.getItem() instanceof ItemBlindfold
) {
this.blindfold = blindfoldStack;
}
}
// Read earplugs with type validation
if (tag.contains("earplugs")) {
ItemStack earplugsStack = ItemStack.of(tag.getCompound("earplugs"));
if (
!earplugsStack.isEmpty() &&
earplugsStack.getItem() instanceof ItemEarplugs
) {
this.earplugs = earplugsStack;
}
}
// Read collar with type validation
if (tag.contains("collar")) {
ItemStack collarStack = ItemStack.of(tag.getCompound("collar"));
if (
!collarStack.isEmpty() &&
collarStack.getItem() instanceof ItemCollar
) {
this.collar = collarStack;
}
}
// Read clothes with type validation
if (tag.contains("clothes")) {
ItemStack clothesStack = ItemStack.of(tag.getCompound("clothes"));
if (
!clothesStack.isEmpty() &&
clothesStack.getItem() instanceof GenericClothes
) {
this.clothes = clothesStack;
}
}
}
@Override
public CompoundTag writeBondageData(CompoundTag tag) {
if (!this.bind.isEmpty()) {
tag.put("bind", this.bind.save(new CompoundTag()));
}
if (!this.gag.isEmpty()) {
tag.put("gag", this.gag.save(new CompoundTag()));
}
if (!this.blindfold.isEmpty()) {
tag.put("blindfold", this.blindfold.save(new CompoundTag()));
}
if (!this.earplugs.isEmpty()) {
tag.put("earplugs", this.earplugs.save(new CompoundTag()));
}
if (!this.collar.isEmpty()) {
tag.put("collar", this.collar.save(new CompoundTag()));
}
if (!this.clothes.isEmpty()) {
tag.put("clothes", this.clothes.save(new CompoundTag()));
}
return tag;
}
// ========================================
// NETWORK SYNC
// ========================================
/**
* Mark dirty and sync to clients.
*/
protected void setChangedAndSync() {
if (!this.offMode && this.level != null) {
this.setChanged();
// Notify clients of block update
this.level.sendBlockUpdated(
this.worldPosition,
this.getBlockState(),
this.getBlockState(),
3
);
}
}
@Override
public CompoundTag getUpdateTag() {
CompoundTag tag = super.getUpdateTag();
this.writeBondageData(tag);
return tag;
}
@Nullable
@Override
public Packet<ClientGamePacketListener> getUpdatePacket() {
return ClientboundBlockEntityDataPacket.create(this);
}
@Override
public void handleUpdateTag(CompoundTag tag) {
if (!this.offMode) {
this.readBondageData(tag);
}
}
}

View File

@@ -0,0 +1,532 @@
package com.tiedup.remake.blocks.entity;
import com.tiedup.remake.cells.*;
import com.tiedup.remake.core.TiedUpMod;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.ListTag;
import net.minecraft.nbt.NbtUtils;
import net.minecraft.nbt.Tag;
import net.minecraft.network.Connection;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.game.ClientGamePacketListener;
import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraftforge.client.model.data.ModelData;
import net.minecraftforge.client.model.data.ModelProperty;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
/**
* Block entity for the Cell Core block.
*
* Stores the cell ID link, spawn/delivery points, disguise block,
* and which face of the Core points into the cell interior.
*/
public class CellCoreBlockEntity extends BlockEntity {
/** Shared ModelProperty for disguise — defined here (not in client-only CellCoreBakedModel) to avoid server classloading issues. */
public static final ModelProperty<BlockState> DISGUISE_PROPERTY =
new ModelProperty<>();
@Nullable
private UUID cellId;
@Nullable
private BlockPos spawnPoint;
@Nullable
private BlockPos deliveryPoint;
@Nullable
private BlockState disguiseState;
@Nullable
private Direction interiorFace;
@Nullable
private List<BlockPos> pathWaypoints;
/** Transient: pathWaypoints set by MarkerBlockEntity during V1→V2 conversion */
@Nullable
private transient List<BlockPos> pendingPathWaypoints;
public CellCoreBlockEntity(BlockPos pos, BlockState state) {
super(ModBlockEntities.CELL_CORE.get(), pos, state);
}
// ==================== LIFECYCLE ====================
@Override
public void onLoad() {
super.onLoad();
if (!(level instanceof ServerLevel serverLevel)) {
return;
}
// If we have a cellId but the cell isn't in CellRegistryV2,
// this is a structure-placed Core that needs flood-fill registration.
if (cellId != null) {
CellRegistryV2 registry = CellRegistryV2.get(serverLevel);
if (
registry.getCell(cellId) == null &&
registry.getCellAtCore(worldPosition) == null
) {
// Structure-placed Core: run flood-fill and create cell
FloodFillResult result = FloodFillAlgorithm.tryFill(
serverLevel,
worldPosition
);
CellDataV2 newCell;
if (result.isSuccess()) {
newCell = registry.createCell(worldPosition, result, null);
if (result.getInteriorFace() != null) {
this.interiorFace = result.getInteriorFace();
}
} else {
// Flood-fill failed (e.g. chunk not fully loaded) — create minimal cell
newCell = new CellDataV2(UUID.randomUUID(), worldPosition);
registry.registerExistingCell(newCell);
TiedUpMod.LOGGER.warn(
"[CellCoreBlockEntity] Flood-fill failed at {}: {}. Created minimal cell.",
worldPosition.toShortString(),
result.getErrorKey()
);
}
// Update our cellId to match the new cell
this.cellId = newCell.getId();
// Transfer spawn/delivery to cell data
newCell.setSpawnPoint(this.spawnPoint);
newCell.setDeliveryPoint(this.deliveryPoint);
// Transfer pathWaypoints: persistent field (from NBT) or V1 migration pending
List<BlockPos> waypointsToTransfer = null;
if (
this.pathWaypoints != null && !this.pathWaypoints.isEmpty()
) {
waypointsToTransfer = this.pathWaypoints;
} else if (
this.pendingPathWaypoints != null &&
!this.pendingPathWaypoints.isEmpty()
) {
waypointsToTransfer = this.pendingPathWaypoints;
this.pendingPathWaypoints = null;
}
if (waypointsToTransfer != null) {
newCell.setPathWaypoints(waypointsToTransfer);
TiedUpMod.LOGGER.info(
"[CellCoreBlockEntity] Transferred {} pathWaypoints to cell {}",
waypointsToTransfer.size(),
cellId.toString().substring(0, 8)
);
}
// Mark as camp-owned and link to nearest camp
newCell.setOwnerType(CellOwnerType.CAMP);
CampOwnership ownership = CampOwnership.get(serverLevel);
CampOwnership.CampData nearestCamp =
ownership.findNearestAliveCamp(worldPosition, 40);
if (nearestCamp != null) {
newCell.setOwnerId(nearestCamp.getCampId());
TiedUpMod.LOGGER.info(
"[CellCoreBlockEntity] Created cell {} linked to camp {} at {}",
cellId.toString().substring(0, 8),
nearestCamp.getCampId().toString().substring(0, 8),
worldPosition.toShortString()
);
} else {
// No camp yet: generate deterministic camp ID (same algo as MarkerBlockEntity)
UUID structureCampId = generateStructureCampId(
worldPosition
);
newCell.setOwnerId(structureCampId);
TiedUpMod.LOGGER.info(
"[CellCoreBlockEntity] Created cell {} with structure camp ID {} at {}",
cellId.toString().substring(0, 8),
structureCampId.toString().substring(0, 8),
worldPosition.toShortString()
);
}
registry.updateCampIndex(newCell, null);
registry.setDirty();
setChangedAndSync();
} else {
// Existing cell: sync pathWaypoints from CellDataV2 → Core BE
// (so re-saving structures picks them up in the new offset format)
CellDataV2 existingCell = registry.getCell(cellId);
if (existingCell == null) {
existingCell = registry.getCellAtCore(worldPosition);
}
if (existingCell != null) {
if (
this.pathWaypoints == null ||
this.pathWaypoints.isEmpty()
) {
List<BlockPos> cellWaypoints =
existingCell.getPathWaypoints();
if (!cellWaypoints.isEmpty()) {
this.pathWaypoints = new ArrayList<>(cellWaypoints);
setChanged(); // persist without network sync
}
}
}
}
}
}
/**
* Generate a deterministic camp ID based on structure position.
* Uses the same 128-block grid algorithm as MarkerBlockEntity.
*/
private static UUID generateStructureCampId(BlockPos pos) {
int gridX = (pos.getX() / 128) * 128;
int gridZ = (pos.getZ() / 128) * 128;
long mostSigBits = ((long) gridX << 32) | (gridZ & 0xFFFFFFFFL);
long leastSigBits = 0x8000000000000000L | (gridX ^ gridZ);
return new UUID(mostSigBits, leastSigBits);
}
// ==================== GETTERS/SETTERS ====================
@Nullable
public UUID getCellId() {
return cellId;
}
public void setCellId(@Nullable UUID cellId) {
this.cellId = cellId;
setChangedAndSync();
}
@Nullable
public BlockPos getSpawnPoint() {
return spawnPoint;
}
public void setSpawnPoint(@Nullable BlockPos spawnPoint) {
this.spawnPoint = spawnPoint;
setChangedAndSync();
}
@Nullable
public BlockPos getDeliveryPoint() {
return deliveryPoint;
}
public void setDeliveryPoint(@Nullable BlockPos deliveryPoint) {
this.deliveryPoint = deliveryPoint;
setChangedAndSync();
}
@Nullable
public BlockState getDisguiseState() {
return disguiseState;
}
public void setDisguiseState(@Nullable BlockState disguiseState) {
this.disguiseState = disguiseState;
setChangedAndSync();
requestModelDataUpdate();
}
@Nullable
public Direction getInteriorFace() {
return interiorFace;
}
public void setInteriorFace(@Nullable Direction interiorFace) {
this.interiorFace = interiorFace;
setChangedAndSync();
}
@Nullable
public List<BlockPos> getPathWaypoints() {
return pathWaypoints;
}
public void setPathWaypoints(@Nullable List<BlockPos> pathWaypoints) {
this.pathWaypoints = pathWaypoints;
setChangedAndSync();
}
public void setPendingPathWaypoints(List<BlockPos> waypoints) {
this.pendingPathWaypoints = waypoints;
this.pathWaypoints = waypoints; // also persist
}
// ==================== MODEL DATA (Camouflage) ====================
@Override
public @NotNull ModelData getModelData() {
BlockState disguise = resolveDisguise();
if (disguise != null) {
return ModelData.builder()
.with(DISGUISE_PROPERTY, disguise)
.build();
}
return ModelData.EMPTY;
}
@Nullable
public BlockState resolveDisguise() {
// Explicit disguise takes priority (preserves full BlockState including slab type, etc.)
if (disguiseState != null) {
return disguiseState;
}
// Auto-detect most common solid neighbor
if (level != null) {
return detectMostCommonNeighbor();
}
return null;
}
@Nullable
private BlockState detectMostCommonNeighbor() {
if (level == null) return null;
// Track full BlockState (preserves slab type, stair facing, etc.)
Map<BlockState, Integer> counts = new HashMap<>();
for (Direction dir : Direction.values()) {
BlockPos neighborPos = worldPosition.relative(dir);
BlockState neighbor = level.getBlockState(neighborPos);
if (
neighbor.getBlock() instanceof
com.tiedup.remake.blocks.BlockCellCore
) continue;
if (
neighbor.isSolidRender(level, neighborPos) ||
neighbor.getBlock() instanceof
net.minecraft.world.level.block.SlabBlock
) {
counts.merge(neighbor, 1, Integer::sum);
}
}
if (counts.isEmpty()) return null;
BlockState mostCommon = null;
int maxCount = 0;
for (Map.Entry<BlockState, Integer> entry : counts.entrySet()) {
if (entry.getValue() > maxCount) {
maxCount = entry.getValue();
mostCommon = entry.getKey();
}
}
return mostCommon;
}
// ==================== NBT ====================
@Override
protected void saveAdditional(CompoundTag tag) {
super.saveAdditional(tag);
if (cellId != null) tag.putUUID("cellId", cellId);
// Save positions as relative offsets from core position (survives structure placement + rotation)
if (spawnPoint != null) {
tag.put(
"spawnOffset",
NbtUtils.writeBlockPos(toOffset(spawnPoint, worldPosition))
);
}
if (deliveryPoint != null) {
tag.put(
"deliveryOffset",
NbtUtils.writeBlockPos(toOffset(deliveryPoint, worldPosition))
);
}
if (pathWaypoints != null && !pathWaypoints.isEmpty()) {
ListTag list = new ListTag();
for (BlockPos wp : pathWaypoints) {
list.add(NbtUtils.writeBlockPos(toOffset(wp, worldPosition)));
}
tag.put("pathWaypointOffsets", list);
}
if (disguiseState != null) tag.put(
"disguiseState",
NbtUtils.writeBlockState(disguiseState)
);
if (interiorFace != null) tag.putString(
"interiorFace",
interiorFace.getSerializedName()
);
}
@Override
public void load(CompoundTag tag) {
super.load(tag);
cellId = tag.contains("cellId") ? tag.getUUID("cellId") : null;
// Spawn point: new relative offset format, then old absolute fallback
if (tag.contains("spawnOffset")) {
spawnPoint = fromOffset(
NbtUtils.readBlockPos(tag.getCompound("spawnOffset")),
worldPosition
);
} else if (tag.contains("spawnPoint")) {
spawnPoint = NbtUtils.readBlockPos(tag.getCompound("spawnPoint"));
} else {
spawnPoint = null;
}
// Delivery point: new relative offset format, then old absolute fallback
if (tag.contains("deliveryOffset")) {
deliveryPoint = fromOffset(
NbtUtils.readBlockPos(tag.getCompound("deliveryOffset")),
worldPosition
);
} else if (tag.contains("deliveryPoint")) {
deliveryPoint = NbtUtils.readBlockPos(
tag.getCompound("deliveryPoint")
);
} else {
deliveryPoint = null;
}
// Path waypoints (relative offsets)
if (tag.contains("pathWaypointOffsets")) {
ListTag list = tag.getList("pathWaypointOffsets", Tag.TAG_COMPOUND);
pathWaypoints = new ArrayList<>();
for (int i = 0; i < list.size(); i++) {
pathWaypoints.add(
fromOffset(
NbtUtils.readBlockPos(list.getCompound(i)),
worldPosition
)
);
}
} else {
pathWaypoints = null;
}
// Retrocompat: old saves stored "disguiseBlock" as ResourceLocation string
if (tag.contains("disguiseState")) {
disguiseState = NbtUtils.readBlockState(
BuiltInRegistries.BLOCK.asLookup(),
tag.getCompound("disguiseState")
);
} else if (tag.contains("disguiseBlock")) {
// V1 compat: convert old ResourceLocation to default BlockState
ResourceLocation oldId = ResourceLocation.tryParse(
tag.getString("disguiseBlock")
);
if (oldId != null) {
Block block = BuiltInRegistries.BLOCK.get(oldId);
disguiseState = (block !=
net.minecraft.world.level.block.Blocks.AIR)
? block.defaultBlockState()
: null;
}
} else {
disguiseState = null;
}
interiorFace = tag.contains("interiorFace")
? Direction.byName(tag.getString("interiorFace"))
: null;
}
// ==================== OFFSET HELPERS ====================
/** Convert absolute position to relative offset from origin. */
private static BlockPos toOffset(BlockPos absolute, BlockPos origin) {
return new BlockPos(
absolute.getX() - origin.getX(),
absolute.getY() - origin.getY(),
absolute.getZ() - origin.getZ()
);
}
/** Convert relative offset back to absolute position. */
private static BlockPos fromOffset(BlockPos offset, BlockPos origin) {
return origin.offset(offset.getX(), offset.getY(), offset.getZ());
}
// ==================== NETWORK SYNC ====================
private void setChangedAndSync() {
if (level != null) {
setChanged();
level.sendBlockUpdated(
worldPosition,
getBlockState(),
getBlockState(),
3
);
}
}
@Override
public CompoundTag getUpdateTag() {
CompoundTag tag = super.getUpdateTag();
saveAdditional(tag);
return tag;
}
@Nullable
@Override
public Packet<ClientGamePacketListener> getUpdatePacket() {
return ClientboundBlockEntityDataPacket.create(this);
}
@Override
public void onDataPacket(
Connection net,
ClientboundBlockEntityDataPacket pkt
) {
CompoundTag tag = pkt.getTag();
if (tag != null) {
handleUpdateTag(tag);
}
}
@Override
public void handleUpdateTag(CompoundTag tag) {
load(tag);
requestModelDataUpdate();
// Force chunk section re-render to pick up new model data immediately.
// Enqueue to main thread since handleUpdateTag may run on network thread.
if (level != null && level.isClientSide()) {
net.minecraft.client.Minecraft.getInstance().tell(
this::markRenderDirty
);
}
}
/**
* Marks the chunk section containing this block entity for re-rendering.
* Must be called on the client main thread only.
*/
private void markRenderDirty() {
if (level == null || !level.isClientSide()) return;
net.minecraft.client.Minecraft mc =
net.minecraft.client.Minecraft.getInstance();
if (mc.levelRenderer != null) {
mc.levelRenderer.blockChanged(
level,
worldPosition,
getBlockState(),
getBlockState(),
8
);
}
}
}

View File

@@ -0,0 +1,91 @@
package com.tiedup.remake.blocks.entity;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.world.item.ItemStack;
/**
* Interface for BlockEntities that store bondage items.
*
* Phase 16: Blocks
*
* Defines the contract for storing and retrieving bondage items:
* - Bind (ropes, chains, etc.)
* - Gag
* - Blindfold
* - Earplugs
* - Collar
* - Clothes
*
* Based on original ITileEntityBondageItemHolder from 1.12.2
*/
public interface IBondageItemHolder {
// ========================================
// BIND
// ========================================
ItemStack getBind();
void setBind(ItemStack bind);
// ========================================
// GAG
// ========================================
ItemStack getGag();
void setGag(ItemStack gag);
// ========================================
// BLINDFOLD
// ========================================
ItemStack getBlindfold();
void setBlindfold(ItemStack blindfold);
// ========================================
// EARPLUGS
// ========================================
ItemStack getEarplugs();
void setEarplugs(ItemStack earplugs);
// ========================================
// COLLAR
// ========================================
ItemStack getCollar();
void setCollar(ItemStack collar);
// ========================================
// CLOTHES
// ========================================
ItemStack getClothes();
void setClothes(ItemStack clothes);
// ========================================
// NBT SERIALIZATION
// ========================================
/**
* Read bondage items from NBT.
* @param tag The compound tag to read from
*/
void readBondageData(CompoundTag tag);
/**
* Write bondage items to NBT.
* @param tag The compound tag to write to
* @return The modified compound tag
*/
CompoundTag writeBondageData(CompoundTag tag);
// ========================================
// STATE
// ========================================
/**
* Check if this holder has any bondage items loaded.
* Typically checks if bind is present.
* @return true if armed/loaded
*/
boolean isArmed();
}

View File

@@ -0,0 +1,168 @@
package com.tiedup.remake.blocks.entity;
import com.tiedup.remake.items.base.ILockable;
import java.util.UUID;
import javax.annotation.Nullable;
import net.minecraft.core.BlockPos;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.game.ClientGamePacketListener;
import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
/**
* BlockEntity for iron bar door blocks.
*
* Phase: Kidnapper Revamp - Cell System
*
* Stores the lock state and key UUID for the door.
* Implements a block-based version of the ILockable pattern.
*/
public class IronBarDoorBlockEntity extends BlockEntity {
@Nullable
private UUID lockedByKeyUUID;
private boolean locked = false;
public IronBarDoorBlockEntity(BlockPos pos, BlockState state) {
super(ModBlockEntities.IRON_BAR_DOOR.get(), pos, state);
}
// ==================== LOCK STATE ====================
/**
* Check if this door is locked.
*/
public boolean isLocked() {
return locked;
}
/**
* Set the locked state.
*
* @param locked true to lock, false to unlock
*/
public void setLocked(boolean locked) {
this.locked = locked;
if (!locked) {
this.lockedByKeyUUID = null;
}
setChangedAndSync();
}
// ==================== KEY UUID ====================
/**
* Get the UUID of the key that locked this door.
*/
@Nullable
public UUID getLockedByKeyUUID() {
return lockedByKeyUUID;
}
/**
* Lock this door with a specific key.
*
* @param keyUUID The key UUID, or null to unlock
*/
public void setLockedByKeyUUID(@Nullable UUID keyUUID) {
this.lockedByKeyUUID = keyUUID;
this.locked = keyUUID != null;
setChangedAndSync();
}
/**
* Check if a key matches this door's lock.
*
* @param keyUUID The key UUID to test
* @return true if the key matches
*/
public boolean matchesKey(UUID keyUUID) {
if (keyUUID == null) return false;
return lockedByKeyUUID != null && lockedByKeyUUID.equals(keyUUID);
}
/**
* Check if this door can be unlocked by the given key.
* Matches either the specific key or any master key.
*
* @param keyUUID The key UUID to test
* @param isMasterKey Whether the key is a master key
* @return true if the door can be unlocked
*/
public boolean canUnlockWith(UUID keyUUID, boolean isMasterKey) {
if (!locked) return true;
if (isMasterKey) return true;
return matchesKey(keyUUID);
}
// ==================== NBT SERIALIZATION ====================
@Override
public void load(CompoundTag tag) {
super.load(tag);
this.locked = tag.getBoolean(ILockable.NBT_LOCKED);
if (tag.contains(ILockable.NBT_LOCKED_BY_KEY_UUID)) {
this.lockedByKeyUUID = tag.getUUID(
ILockable.NBT_LOCKED_BY_KEY_UUID
);
} else {
this.lockedByKeyUUID = null;
}
}
@Override
protected void saveAdditional(CompoundTag tag) {
super.saveAdditional(tag);
tag.putBoolean(ILockable.NBT_LOCKED, locked);
if (lockedByKeyUUID != null) {
tag.putUUID(ILockable.NBT_LOCKED_BY_KEY_UUID, lockedByKeyUUID);
}
}
// ==================== NETWORK SYNC ====================
protected void setChangedAndSync() {
if (level != null) {
setChanged();
level.sendBlockUpdated(
worldPosition,
getBlockState(),
getBlockState(),
3
);
}
}
@Override
public CompoundTag getUpdateTag() {
CompoundTag tag = super.getUpdateTag();
tag.putBoolean(ILockable.NBT_LOCKED, locked);
if (lockedByKeyUUID != null) {
tag.putUUID(ILockable.NBT_LOCKED_BY_KEY_UUID, lockedByKeyUUID);
}
return tag;
}
@Nullable
@Override
public Packet<ClientGamePacketListener> getUpdatePacket() {
return ClientboundBlockEntityDataPacket.create(this);
}
@Override
public void handleUpdateTag(CompoundTag tag) {
this.locked = tag.getBoolean(ILockable.NBT_LOCKED);
if (tag.contains(ILockable.NBT_LOCKED_BY_KEY_UUID)) {
this.lockedByKeyUUID = tag.getUUID(
ILockable.NBT_LOCKED_BY_KEY_UUID
);
}
}
}

View File

@@ -0,0 +1,32 @@
package com.tiedup.remake.blocks.entity;
import net.minecraft.core.BlockPos;
import net.minecraft.world.level.block.state.BlockState;
/**
* BlockEntity for kidnap bomb blocks.
*
* Phase 16: Blocks
*
* Stores bondage items that will be applied when the bomb explodes.
* Simple extension of BondageItemBlockEntity.
*
* Based on original TileEntityKidnapBomb from 1.12.2
*/
public class KidnapBombBlockEntity extends BondageItemBlockEntity {
public KidnapBombBlockEntity(BlockPos pos, BlockState state) {
super(ModBlockEntities.KIDNAP_BOMB.get(), pos, state);
}
/**
* Constructor with off-mode for tooltip reading.
*/
public KidnapBombBlockEntity(
BlockPos pos,
BlockState state,
boolean offMode
) {
super(ModBlockEntities.KIDNAP_BOMB.get(), pos, state, offMode);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,114 @@
package com.tiedup.remake.blocks.entity;
import com.tiedup.remake.blocks.ModBlocks;
import com.tiedup.remake.core.TiedUpMod;
import net.minecraft.world.level.block.entity.BlockEntityType;
import net.minecraftforge.registries.DeferredRegister;
import net.minecraftforge.registries.ForgeRegistries;
import net.minecraftforge.registries.RegistryObject;
/**
* Mod Block Entities Registration
*
* Phase 16: Blocks
*
* Handles registration of all TiedUp block entities using DeferredRegister.
*/
public class ModBlockEntities {
// DeferredRegister for block entity types
public static final DeferredRegister<BlockEntityType<?>> BLOCK_ENTITIES =
DeferredRegister.create(
ForgeRegistries.BLOCK_ENTITY_TYPES,
TiedUpMod.MOD_ID
);
// ========================================
// TRAP BLOCK ENTITIES
// ========================================
/**
* Trap block entity - stores bondage items for rope trap.
*/
public static final RegistryObject<BlockEntityType<TrapBlockEntity>> TRAP =
BLOCK_ENTITIES.register("trap", () ->
BlockEntityType.Builder.of(
TrapBlockEntity::new,
ModBlocks.ROPE_TRAP.get()
).build(null)
);
// LOW FIX: Removed BED BLOCK ENTITIES section - feature not implemented
// ========================================
// BOMB BLOCK ENTITIES
// ========================================
/**
* Kidnap bomb block entity - stores bondage items for explosion effect.
*/
public static final RegistryObject<
BlockEntityType<KidnapBombBlockEntity>
> KIDNAP_BOMB = BLOCK_ENTITIES.register("kidnap_bomb", () ->
BlockEntityType.Builder.of(
KidnapBombBlockEntity::new,
ModBlocks.KIDNAP_BOMB.get()
).build(null)
);
// ========================================
// CHEST BLOCK ENTITIES
// ========================================
/**
* Trapped chest block entity - stores bondage items for when player opens it.
*/
public static final RegistryObject<
BlockEntityType<TrappedChestBlockEntity>
> TRAPPED_CHEST = BLOCK_ENTITIES.register("trapped_chest", () ->
BlockEntityType.Builder.of(
TrappedChestBlockEntity::new,
ModBlocks.TRAPPED_CHEST.get()
).build(null)
);
// ========================================
// CELL SYSTEM BLOCK ENTITIES
// ========================================
/**
* Marker block entity - stores cell UUID for cell system.
*/
public static final RegistryObject<
BlockEntityType<MarkerBlockEntity>
> MARKER = BLOCK_ENTITIES.register("marker", () ->
BlockEntityType.Builder.of(
MarkerBlockEntity::new,
ModBlocks.MARKER.get()
).build(null)
);
/**
* Iron bar door block entity - stores lock state and key UUID.
*/
public static final RegistryObject<
BlockEntityType<IronBarDoorBlockEntity>
> IRON_BAR_DOOR = BLOCK_ENTITIES.register("iron_bar_door", () ->
BlockEntityType.Builder.of(
IronBarDoorBlockEntity::new,
ModBlocks.IRON_BAR_DOOR.get()
).build(null)
);
/**
* Cell Core block entity - stores cell ID, spawn/delivery points, and disguise.
*/
public static final RegistryObject<
BlockEntityType<CellCoreBlockEntity>
> CELL_CORE = BLOCK_ENTITIES.register("cell_core", () ->
BlockEntityType.Builder.of(
CellCoreBlockEntity::new,
ModBlocks.CELL_CORE.get()
).build(null)
);
}

View File

@@ -0,0 +1,28 @@
package com.tiedup.remake.blocks.entity;
import net.minecraft.core.BlockPos;
import net.minecraft.world.level.block.state.BlockState;
/**
* BlockEntity for rope trap blocks.
*
* Phase 16: Blocks
*
* Stores bondage items that will be applied when an entity walks on the trap.
* Simple extension of BondageItemBlockEntity.
*
* Based on original TileEntityTrap from 1.12.2
*/
public class TrapBlockEntity extends BondageItemBlockEntity {
public TrapBlockEntity(BlockPos pos, BlockState state) {
super(ModBlockEntities.TRAP.get(), pos, state);
}
/**
* Constructor with off-mode for tooltip reading.
*/
public TrapBlockEntity(BlockPos pos, BlockState state, boolean offMode) {
super(ModBlockEntities.TRAP.get(), pos, state, offMode);
}
}

View File

@@ -0,0 +1,230 @@
package com.tiedup.remake.blocks.entity;
import com.tiedup.remake.items.base.*;
import com.tiedup.remake.items.clothes.GenericClothes;
import javax.annotation.Nullable;
import net.minecraft.core.BlockPos;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.game.ClientGamePacketListener;
import net.minecraft.network.protocol.game.ClientboundBlockEntityDataPacket;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.block.entity.ChestBlockEntity;
import net.minecraft.world.level.block.state.BlockState;
/**
* BlockEntity for trapped chest blocks.
*
* Phase 16: Blocks
*
* Extends ChestBlockEntity for proper chest behavior,
* but also stores bondage items for the trap.
*/
public class TrappedChestBlockEntity
extends ChestBlockEntity
implements IBondageItemHolder
{
// Bondage item storage (separate from chest inventory)
private ItemStack bind = ItemStack.EMPTY;
private ItemStack gag = ItemStack.EMPTY;
private ItemStack blindfold = ItemStack.EMPTY;
private ItemStack earplugs = ItemStack.EMPTY;
private ItemStack collar = ItemStack.EMPTY;
private ItemStack clothes = ItemStack.EMPTY;
public TrappedChestBlockEntity(BlockPos pos, BlockState state) {
super(ModBlockEntities.TRAPPED_CHEST.get(), pos, state);
}
// ========================================
// BONDAGE ITEM HOLDER IMPLEMENTATION
// ========================================
@Override
public ItemStack getBind() {
return bind;
}
@Override
public void setBind(ItemStack stack) {
if (stack.isEmpty() || stack.getItem() instanceof ItemBind) {
this.bind = stack;
setChangedAndSync();
}
}
@Override
public ItemStack getGag() {
return gag;
}
@Override
public void setGag(ItemStack stack) {
if (stack.isEmpty() || stack.getItem() instanceof ItemGag) {
this.gag = stack;
setChangedAndSync();
}
}
@Override
public ItemStack getBlindfold() {
return blindfold;
}
@Override
public void setBlindfold(ItemStack stack) {
if (stack.isEmpty() || stack.getItem() instanceof ItemBlindfold) {
this.blindfold = stack;
setChangedAndSync();
}
}
@Override
public ItemStack getEarplugs() {
return earplugs;
}
@Override
public void setEarplugs(ItemStack stack) {
if (stack.isEmpty() || stack.getItem() instanceof ItemEarplugs) {
this.earplugs = stack;
setChangedAndSync();
}
}
@Override
public ItemStack getCollar() {
return collar;
}
@Override
public void setCollar(ItemStack stack) {
if (stack.isEmpty() || stack.getItem() instanceof ItemCollar) {
this.collar = stack;
setChangedAndSync();
}
}
@Override
public ItemStack getClothes() {
return clothes;
}
@Override
public void setClothes(ItemStack stack) {
if (stack.isEmpty() || stack.getItem() instanceof GenericClothes) {
this.clothes = stack;
setChangedAndSync();
}
}
@Override
public boolean isArmed() {
return (
!bind.isEmpty() ||
!gag.isEmpty() ||
!blindfold.isEmpty() ||
!earplugs.isEmpty() ||
!collar.isEmpty() ||
!clothes.isEmpty()
);
}
@Override
public void readBondageData(CompoundTag tag) {
if (tag.contains("bind")) bind = ItemStack.of(tag.getCompound("bind"));
if (tag.contains("gag")) gag = ItemStack.of(tag.getCompound("gag"));
if (tag.contains("blindfold")) blindfold = ItemStack.of(
tag.getCompound("blindfold")
);
if (tag.contains("earplugs")) earplugs = ItemStack.of(
tag.getCompound("earplugs")
);
if (tag.contains("collar")) collar = ItemStack.of(
tag.getCompound("collar")
);
if (tag.contains("clothes")) clothes = ItemStack.of(
tag.getCompound("clothes")
);
}
@Override
public CompoundTag writeBondageData(CompoundTag tag) {
if (!bind.isEmpty()) tag.put("bind", bind.save(new CompoundTag()));
if (!gag.isEmpty()) tag.put("gag", gag.save(new CompoundTag()));
if (!blindfold.isEmpty()) tag.put(
"blindfold",
blindfold.save(new CompoundTag())
);
if (!earplugs.isEmpty()) tag.put(
"earplugs",
earplugs.save(new CompoundTag())
);
if (!collar.isEmpty()) tag.put(
"collar",
collar.save(new CompoundTag())
);
if (!clothes.isEmpty()) tag.put(
"clothes",
clothes.save(new CompoundTag())
);
return tag;
}
// ========================================
// NBT SERIALIZATION
// ========================================
@Override
public void load(CompoundTag tag) {
super.load(tag);
readBondageData(tag);
}
@Override
protected void saveAdditional(CompoundTag tag) {
super.saveAdditional(tag);
writeBondageData(tag);
}
// ========================================
// NETWORK SYNC
// ========================================
/**
* Mark dirty and sync to clients.
* Ensures bondage trap state is visible to all players.
*/
protected void setChangedAndSync() {
if (this.level != null) {
this.setChanged();
// Notify clients of block update
this.level.sendBlockUpdated(
this.worldPosition,
this.getBlockState(),
this.getBlockState(),
3
);
}
}
@Override
public CompoundTag getUpdateTag() {
CompoundTag tag = super.getUpdateTag();
writeBondageData(tag);
return tag;
}
@Nullable
@Override
public Packet<ClientGamePacketListener> getUpdatePacket() {
return ClientboundBlockEntityDataPacket.create(this);
}
@Override
public void handleUpdateTag(CompoundTag tag) {
super.handleUpdateTag(tag);
readBondageData(tag);
}
}

View File

@@ -0,0 +1,210 @@
package com.tiedup.remake.bounty;
import java.util.UUID;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.world.item.ItemStack;
/**
* Represents a single bounty placed on a player.
*
* Phase 17: Bounty System
*
* A bounty is created when a player (client) offers a reward for capturing
* another player (target). Anyone who delivers the target to the client
* receives the reward.
*/
public class Bounty {
private final String id;
private final UUID clientId;
private final UUID targetId;
private String clientName;
private String targetName;
private final ItemStack reward;
private final long creationTime; // System.currentTimeMillis()
private final int durationSeconds; // How long the bounty lasts
/**
* Create a new bounty.
*/
public Bounty(
UUID clientId,
String clientName,
UUID targetId,
String targetName,
ItemStack reward,
int durationSeconds
) {
this.id = UUID.randomUUID().toString();
this.clientId = clientId;
this.clientName = clientName;
this.targetId = targetId;
this.targetName = targetName;
this.reward = reward.copy();
this.creationTime = System.currentTimeMillis();
this.durationSeconds = durationSeconds;
}
/**
* Create a bounty from NBT data.
*/
private Bounty(
String id,
UUID clientId,
String clientName,
UUID targetId,
String targetName,
ItemStack reward,
long creationTime,
int durationSeconds
) {
this.id = id;
this.clientId = clientId;
this.clientName = clientName;
this.targetId = targetId;
this.targetName = targetName;
this.reward = reward;
this.creationTime = creationTime;
this.durationSeconds = durationSeconds;
}
// ==================== GETTERS ====================
public String getId() {
return id;
}
public UUID getClientId() {
return clientId;
}
public UUID getTargetId() {
return targetId;
}
public String getClientName() {
return clientName;
}
public String getTargetName() {
return targetName;
}
public ItemStack getReward() {
return reward.copy();
}
public int getDurationSeconds() {
return durationSeconds;
}
public long getCreationTime() {
return creationTime;
}
// ==================== TIME CALCULATIONS ====================
/**
* Get remaining time in seconds.
*/
public int getSecondsRemaining() {
long elapsed = (System.currentTimeMillis() - creationTime) / 1000;
return Math.max(0, durationSeconds - (int) elapsed);
}
/**
* Get remaining time as [hours, minutes].
*/
public int[] getRemainingTime() {
int seconds = getSecondsRemaining();
int hours = seconds / 3600;
int minutes = (seconds % 3600) / 60;
return new int[] { hours, minutes };
}
/**
* Check if bounty has expired.
*/
public boolean isExpired() {
return getSecondsRemaining() <= 0;
}
// ==================== PLAYER CHECKS ====================
/**
* Check if player is the client (creator) of this bounty.
*/
public boolean isClient(UUID playerId) {
return clientId.equals(playerId);
}
/**
* Check if player is the target of this bounty.
*/
public boolean isTarget(UUID playerId) {
return targetId.equals(playerId);
}
/**
* Check if bounty matches (client receives target).
*/
public boolean matches(UUID clientId, UUID targetId) {
return this.clientId.equals(clientId) && this.targetId.equals(targetId);
}
// ==================== REWARD DESCRIPTION ====================
/**
* Get a description of the reward (e.g., "Diamond x 5").
*/
public String getRewardDescription() {
if (reward.isEmpty()) {
return "Nothing";
}
return reward.getHoverName().getString() + " x " + reward.getCount();
}
// ==================== NAME UPDATES ====================
/**
* Update client name (for when player was offline during creation).
*/
public void setClientName(String name) {
this.clientName = name;
}
/**
* Update target name (for when player was offline during creation).
*/
public void setTargetName(String name) {
this.targetName = name;
}
// ==================== NBT SERIALIZATION ====================
public CompoundTag save() {
CompoundTag tag = new CompoundTag();
tag.putString("id", id);
tag.putUUID("clientId", clientId);
tag.putUUID("targetId", targetId);
tag.putString("clientName", clientName);
tag.putString("targetName", targetName);
tag.put("reward", reward.save(new CompoundTag()));
tag.putLong("creationTime", creationTime);
tag.putInt("durationSeconds", durationSeconds);
return tag;
}
public static Bounty load(CompoundTag tag) {
return new Bounty(
tag.getString("id"),
tag.getUUID("clientId"),
tag.getString("clientName"),
tag.getUUID("targetId"),
tag.getString("targetName"),
ItemStack.of(tag.getCompound("reward")),
tag.getLong("creationTime"),
tag.getInt("durationSeconds")
);
}
}

View File

@@ -0,0 +1,407 @@
package com.tiedup.remake.bounty;
import com.tiedup.remake.core.SystemMessageManager;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.core.SettingsAccessor;
import java.util.*;
import javax.annotation.Nullable;
import net.minecraft.ChatFormatting;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.ListTag;
import net.minecraft.nbt.Tag;
import net.minecraft.network.chat.Component;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.saveddata.SavedData;
/**
* World-saved data manager for bounties.
*
* Phase 17: Bounty System
*
* Manages all active bounties, handles expiration, delivery rewards,
* and stores bounties for offline players.
*/
public class BountyManager extends SavedData {
private static final String DATA_NAME = "tiedup_bounties";
/** Pending rewards expire after 30 real days (in milliseconds). */
private static final long PENDING_REWARD_EXPIRATION_MS =
30L * 24 * 60 * 60 * 1000;
// Active bounties
private final List<Bounty> bounties = new ArrayList<>();
// Bounties for offline players (to return reward when they log in)
// Stored with timestamp via Bounty.creationTime + durationSeconds
private final List<Bounty> pendingRewards = new ArrayList<>();
// ==================== CONSTRUCTION ====================
public BountyManager() {}
public static BountyManager create() {
return new BountyManager();
}
public static BountyManager load(CompoundTag tag) {
BountyManager manager = new BountyManager();
// Load active bounties
ListTag bountiesTag = tag.getList("bounties", Tag.TAG_COMPOUND);
for (int i = 0; i < bountiesTag.size(); i++) {
manager.bounties.add(Bounty.load(bountiesTag.getCompound(i)));
}
// Load pending rewards (with expiration cleanup)
ListTag pendingTag = tag.getList("pendingRewards", Tag.TAG_COMPOUND);
int expiredCount = 0;
for (int i = 0; i < pendingTag.size(); i++) {
Bounty bounty = Bounty.load(pendingTag.getCompound(i));
if (!isPendingRewardExpired(bounty)) {
manager.pendingRewards.add(bounty);
} else {
expiredCount++;
}
}
if (expiredCount > 0) {
TiedUpMod.LOGGER.info(
"[BOUNTY] Cleaned up {} expired pending rewards (>30 days)",
expiredCount
);
}
TiedUpMod.LOGGER.info(
"[BOUNTY] Loaded {} active bounties, {} pending rewards",
manager.bounties.size(),
manager.pendingRewards.size()
);
return manager;
}
@Override
public CompoundTag save(CompoundTag tag) {
// Save active bounties
ListTag bountiesTag = new ListTag();
for (Bounty bounty : bounties) {
bountiesTag.add(bounty.save());
}
tag.put("bounties", bountiesTag);
// Save pending rewards
ListTag pendingTag = new ListTag();
for (Bounty bounty : pendingRewards) {
pendingTag.add(bounty.save());
}
tag.put("pendingRewards", pendingTag);
return tag;
}
// ==================== ACCESS ====================
/**
* Get the BountyManager for a world.
*/
public static BountyManager get(ServerLevel level) {
return level
.getDataStorage()
.computeIfAbsent(
BountyManager::load,
BountyManager::create,
DATA_NAME
);
}
/**
* Get the BountyManager from a server.
*/
public static BountyManager get(MinecraftServer server) {
ServerLevel overworld = server.overworld();
return get(overworld);
}
// ==================== BOUNTY MANAGEMENT ====================
/**
* Get all active bounties (removes expired ones).
*/
public List<Bounty> getBounties(ServerLevel level) {
// Clean up expired bounties
Iterator<Bounty> it = bounties.iterator();
while (it.hasNext()) {
Bounty bounty = it.next();
if (bounty.isExpired()) {
it.remove();
onBountyExpired(level, bounty);
}
}
setDirty();
return new ArrayList<>(bounties);
}
/**
* Add a new bounty.
*/
public void addBounty(Bounty bounty) {
bounties.add(bounty);
setDirty();
TiedUpMod.LOGGER.info(
"[BOUNTY] New bounty: {} on {} by {}",
bounty.getId(),
bounty.getTargetName(),
bounty.getClientName()
);
}
/**
* Get a bounty by ID.
*/
@Nullable
public Bounty getBountyById(String id) {
for (Bounty bounty : bounties) {
if (bounty.getId().equals(id)) {
return bounty;
}
}
return null;
}
/**
* Cancel a bounty.
* Only the client or an admin can cancel.
* If client cancels, they get their reward back.
*/
public boolean cancelBounty(ServerPlayer player, String bountyId) {
Bounty bounty = getBountyById(bountyId);
if (bounty == null) {
return false;
}
boolean isAdmin = player.hasPermissions(2);
boolean isClient = bounty.isClient(player.getUUID());
if (!isClient && !isAdmin) {
return false;
}
bounties.remove(bounty);
setDirty();
// Return reward to client (or drop if admin cancelled)
if (isClient) {
giveReward(player, bounty);
broadcastMessage(
player.server,
player.getName().getString() +
" cancelled their bounty on " +
bounty.getTargetName()
);
} else {
onBountyExpired(player.serverLevel(), bounty);
broadcastMessage(
player.server,
player.getName().getString() +
" (admin) cancelled bounty on " +
bounty.getTargetName()
);
}
return true;
}
// ==================== DELIVERY ====================
/**
* Try to deliver a captive to a client.
* Called when a hunter brings a captive near the bounty client.
*
* @param hunter The player delivering the captive
* @param client The bounty client receiving the captive
* @param target The captive being delivered
* @return true if bounty was fulfilled
*/
public boolean tryDeliverCaptive(
ServerPlayer hunter,
ServerPlayer client,
ServerPlayer target
) {
boolean delivered = false;
Iterator<Bounty> it = bounties.iterator();
while (it.hasNext()) {
Bounty bounty = it.next();
// Skip expired
if (bounty.isExpired()) {
continue;
}
// Check if this bounty matches
if (bounty.matches(client.getUUID(), target.getUUID())) {
it.remove();
setDirty();
// Give reward to hunter
giveReward(hunter, bounty);
delivered = true;
broadcastMessage(
hunter.server,
hunter.getName().getString() +
" delivered " +
target.getName().getString() +
" to " +
client.getName().getString() +
" for " +
bounty.getRewardDescription() +
"!"
);
TiedUpMod.LOGGER.info(
"[BOUNTY] Delivered: {} brought {} to {}",
hunter.getName().getString(),
target.getName().getString(),
client.getName().getString()
);
}
}
return delivered;
}
// ==================== EXPIRATION ====================
/**
* Handle bounty expiration.
* Returns reward to client if online, otherwise stores for later.
*/
private void onBountyExpired(ServerLevel level, Bounty bounty) {
ServerPlayer client = level
.getServer()
.getPlayerList()
.getPlayer(bounty.getClientId());
if (client != null) {
// Client is online - return reward
giveReward(client, bounty);
SystemMessageManager.sendChatToPlayer(
client,
"Your bounty on " +
bounty.getTargetName() +
" has expired. Reward returned.",
ChatFormatting.YELLOW
);
} else {
// Client is offline - store for later
pendingRewards.add(bounty);
setDirty();
}
TiedUpMod.LOGGER.info(
"[BOUNTY] Expired: {} on {}",
bounty.getClientName(),
bounty.getTargetName()
);
}
/**
* Check for pending rewards when a player joins.
*/
public void onPlayerJoin(ServerPlayer player) {
Iterator<Bounty> it = pendingRewards.iterator();
while (it.hasNext()) {
Bounty bounty = it.next();
if (bounty.isClient(player.getUUID())) {
giveReward(player, bounty);
it.remove();
setDirty();
SystemMessageManager.sendChatToPlayer(
player,
"Your expired bounty reward has been returned: " +
bounty.getRewardDescription(),
ChatFormatting.YELLOW
);
}
}
}
// ==================== VALIDATION ====================
/**
* Check if a player can create a new bounty.
*/
public boolean canCreateBounty(ServerPlayer player, ServerLevel level) {
if (player.hasPermissions(2)) {
return true; // Admins bypass limit
}
int count = 0;
for (Bounty bounty : bounties) {
if (bounty.isClient(player.getUUID())) {
count++;
}
}
int max = SettingsAccessor.getMaxBounties(level.getGameRules());
return count < max;
}
/**
* Get the number of active bounties for a player.
*/
public int getBountyCount(UUID playerId) {
int count = 0;
for (Bounty bounty : bounties) {
if (bounty.isClient(playerId)) {
count++;
}
}
return count;
}
// ==================== HELPERS ====================
private void giveReward(ServerPlayer player, Bounty bounty) {
ItemStack reward = bounty.getReward();
if (!reward.isEmpty()) {
if (!player.getInventory().add(reward)) {
// Inventory full - drop at feet
player.drop(reward, false);
}
}
}
private void broadcastMessage(MinecraftServer server, String message) {
server
.getPlayerList()
.broadcastSystemMessage(
Component.literal("[Bounty] " + message).withStyle(
ChatFormatting.GOLD
),
false
);
}
/**
* Check if a pending reward has been waiting too long (>30 days).
* Uses the bounty's original expiration time as baseline.
*/
private static boolean isPendingRewardExpired(Bounty bounty) {
// Calculate when the bounty originally expired
// creationTime is in milliseconds, durationSeconds needs conversion
long expirationTime =
bounty.getCreationTime() + (bounty.getDurationSeconds() * 1000L);
long now = System.currentTimeMillis();
// Check if it's been more than 30 days since expiration
return (now - expirationTime) > PENDING_REWARD_EXPIRATION_MS;
}
}

View File

@@ -0,0 +1,332 @@
package com.tiedup.remake.cells;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.items.base.ILockable;
import com.tiedup.remake.items.base.ItemCollar;
import com.tiedup.remake.prison.PrisonerManager;
import com.tiedup.remake.state.IRestrainable;
import com.tiedup.remake.util.KidnappedHelper;
import com.tiedup.remake.v2.BodyRegionV2;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import net.minecraft.ChatFormatting;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
/**
* Handles camp lifecycle events: camp death, prisoner freeing, and camp defense alerts.
*
* <p>This is a stateless utility class. All state lives in {@link CampOwnership}
* (the SavedData singleton). Methods here orchestrate multi-system side effects
* that don't belong in the data layer.
*/
public final class CampLifecycleManager {
private CampLifecycleManager() {} // utility class
/**
* Mark a camp as dead (trader killed) and perform full cleanup.
* This:
* - Cancels all ransoms for the camp's prisoners
* - Frees all prisoners (untie, unlock collars)
* - Clears all labor states
* - Removes all cells belonging to the camp
* - Makes the camp inactive
*
* @param campId The camp UUID
* @param level The server level for entity lookups
*/
public static void markCampDead(UUID campId, ServerLevel level) {
CampOwnership ownership = CampOwnership.get(level);
CampOwnership.CampData data = ownership.getCamp(campId);
if (data == null) {
return;
}
UUID traderUUID = data.getTraderUUID();
TiedUpMod.LOGGER.info(
"[CampLifecycleManager] Camp {} dying - freeing all prisoners",
campId.toString().substring(0, 8)
);
// PERFORMANCE FIX: Use PrisonerManager's index instead of CellRegistry
// This is O(1) lookup instead of iterating all cells
PrisonerManager manager = PrisonerManager.get(level);
Set<UUID> prisonerIds = manager.getPrisonersInCamp(campId);
TiedUpMod.LOGGER.debug(
"[CampLifecycleManager] Found {} prisoners in camp {} via index",
prisonerIds.size(),
campId.toString().substring(0, 8)
);
// Cancel ransoms and free each prisoner
for (UUID prisonerId : prisonerIds) {
// Cancel ransom by clearing it
if (manager.getRansomRecord(prisonerId) != null) {
manager.setRansomRecord(prisonerId, null);
TiedUpMod.LOGGER.debug(
"[CampLifecycleManager] Cancelled ransom for prisoner {}",
prisonerId.toString().substring(0, 8)
);
}
// Free the prisoner
ServerPlayer prisoner = level
.getServer()
.getPlayerList()
.getPlayer(prisonerId);
if (prisoner != null) {
// Online: untie, unlock collar, release with 5-min grace period
removeLaborTools(prisoner);
freePrisonerOnCampDeath(prisoner, traderUUID, level);
// Cell cleanup only -- freePrisonerOnCampDeath already called release()
// which transitions to PROTECTED with grace period.
// Calling escape() here would override PROTECTED->FREE, losing the grace.
CellRegistryV2.get(level).releasePrisonerFromAllCells(
prisonerId
);
} else {
// Offline: full escape via PrisonerService (no grace period needed)
com.tiedup.remake.prison.service.PrisonerService.get().escape(
level,
prisonerId,
"camp death"
);
}
ownership.unmarkPrisonerProcessed(prisonerId);
}
// HIGH FIX: Remove all cells belonging to this camp from CellRegistryV2
// Prevents memory leak and stale data in indices
CellRegistryV2 cellRegistry = CellRegistryV2.get(level);
List<CellDataV2> campCells = cellRegistry.getCellsByCamp(campId);
for (CellDataV2 cell : campCells) {
cellRegistry.removeCell(cell.getId());
TiedUpMod.LOGGER.debug(
"[CampLifecycleManager] Removed cell {} from registry",
cell.getId().toString().substring(0, 8)
);
}
TiedUpMod.LOGGER.info(
"[CampLifecycleManager] Removed {} cells for dead camp {}",
campCells.size(),
campId.toString().substring(0, 8)
);
// Mark camp as dead
data.setAlive(false);
ownership.setDirty();
TiedUpMod.LOGGER.info(
"[CampLifecycleManager] Camp {} is now dead, {} prisoners freed",
campId.toString().substring(0, 8),
prisonerIds.size()
);
}
/**
* Alert all NPCs in a camp to defend against an attacker.
* Called when someone tries to restrain the trader or maid.
*
* @param campId The camp UUID
* @param attacker The player attacking
* @param level The server level
*/
public static void alertCampToDefend(
UUID campId,
Player attacker,
ServerLevel level
) {
CampOwnership ownership = CampOwnership.get(level);
CampOwnership.CampData camp = ownership.getCamp(campId);
if (camp == null) return;
int alertedCount = 0;
// 1. Alert the trader
UUID traderUUID = camp.getTraderUUID();
if (traderUUID != null) {
net.minecraft.world.entity.Entity traderEntity = level.getEntity(
traderUUID
);
if (
traderEntity instanceof
com.tiedup.remake.entities.EntitySlaveTrader trader
) {
if (trader.isAlive() && !trader.isTiedUp()) {
trader.setTarget(attacker);
trader.setLastAttacker(attacker);
alertedCount++;
}
}
}
// 2. Alert the maid
UUID maidUUID = camp.getMaidUUID();
if (maidUUID != null) {
net.minecraft.world.entity.Entity maidEntity = level.getEntity(
maidUUID
);
if (
maidEntity instanceof com.tiedup.remake.entities.EntityMaid maid
) {
if (maid.isAlive() && !maid.isTiedUp()) {
maid.setTarget(attacker);
maid.setMaidState(
com.tiedup.remake.entities.ai.maid.MaidState.DEFENDING
);
alertedCount++;
}
}
}
// 3. Alert all kidnappers
Set<UUID> kidnapperUUIDs = camp.getLinkedKidnappers();
for (UUID kidnapperUUID : kidnapperUUIDs) {
net.minecraft.world.entity.Entity entity = level.getEntity(
kidnapperUUID
);
if (
entity instanceof
com.tiedup.remake.entities.EntityKidnapper kidnapper
) {
if (kidnapper.isAlive() && !kidnapper.isTiedUp()) {
kidnapper.setTarget(attacker);
kidnapper.setLastAttacker(attacker);
alertedCount++;
}
}
}
TiedUpMod.LOGGER.info(
"[CampLifecycleManager] Camp {} alerted {} NPCs to defend against {}",
campId.toString().substring(0, 8),
alertedCount,
attacker.getName().getString()
);
}
// ==================== PRIVATE HELPERS ====================
/**
* Free a prisoner when their camp dies.
* Untie, unlock collar, cancel sale, notify.
* Uses legitimate removal flag to prevent alerting kidnappers.
*/
private static void freePrisonerOnCampDeath(
ServerPlayer prisoner,
UUID traderUUID,
ServerLevel level
) {
IRestrainable state = KidnappedHelper.getKidnappedState(prisoner);
if (state == null) {
return;
}
// Suppress collar removal alerts - this is a legitimate release (camp death)
ItemCollar.runWithSuppressedAlert(() -> {
// Unlock collar if owned by the dead camp/trader
unlockCollarIfOwnedBy(prisoner, state, traderUUID);
// Remove all restraints (including collar if any)
state.untie(true);
// Cancel sale
state.cancelSale();
});
// Clear client HUD
com.tiedup.remake.network.ModNetwork.sendToPlayer(
new com.tiedup.remake.network.labor.PacketSyncLaborProgress(),
prisoner
);
// Notify prisoner
prisoner.sendSystemMessage(
Component.literal("Your captor has died. You are FREE!").withStyle(
ChatFormatting.GREEN,
ChatFormatting.BOLD
)
);
// Grant grace period (5 minutes = 6000 ticks)
PrisonerManager manager = PrisonerManager.get(level);
manager.release(prisoner.getUUID(), level.getGameTime(), 6000);
prisoner.sendSystemMessage(
Component.literal(
"You have 5 minutes of protection from kidnappers."
).withStyle(ChatFormatting.AQUA)
);
TiedUpMod.LOGGER.info(
"[CampLifecycleManager] Freed prisoner {} on camp death (no alert)",
prisoner.getName().getString()
);
}
/**
* Unlock a prisoner's collar if it's owned by the specified owner (trader/kidnapper).
*/
private static void unlockCollarIfOwnedBy(
ServerPlayer prisoner,
IRestrainable state,
UUID ownerUUID
) {
ItemStack collar = state.getEquipment(BodyRegionV2.NECK);
if (collar.isEmpty()) {
return;
}
if (collar.getItem() instanceof ItemCollar collarItem) {
List<UUID> owners = collarItem.getOwners(collar);
// If the dead trader/camp is an owner, unlock the collar
if (owners.contains(ownerUUID)) {
if (collar.getItem() instanceof ILockable lockable) {
lockable.setLockedByKeyUUID(collar, null); // Unlock and clear
}
TiedUpMod.LOGGER.debug(
"[CampLifecycleManager] Unlocked collar for {} (owner {} died)",
prisoner.getName().getString(),
ownerUUID.toString().substring(0, 8)
);
}
}
}
/**
* SECURITY: Remove all labor tools from player inventory.
* Prevents prisoners from keeping unbreakable tools when freed/released.
*/
private static void removeLaborTools(ServerPlayer player) {
var inventory = player.getInventory();
int removedCount = 0;
for (int i = 0; i < inventory.getContainerSize(); i++) {
ItemStack stack = inventory.getItem(i);
if (!stack.isEmpty() && stack.hasTag()) {
CompoundTag tag = stack.getTag();
if (tag != null && tag.getBoolean("LaborTool")) {
inventory.setItem(i, ItemStack.EMPTY);
removedCount++;
}
}
}
if (removedCount > 0) {
TiedUpMod.LOGGER.debug(
"[CampLifecycleManager] Removed {} labor tools from {} on camp death",
removedCount,
player.getName().getString()
);
}
}
}

View File

@@ -0,0 +1,158 @@
package com.tiedup.remake.cells;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.prison.PrisonerManager;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import net.minecraft.server.level.ServerLevel;
import org.jetbrains.annotations.Nullable;
/**
* Manages maid lifecycle within camps: death, respawn timers, prisoner reassignment.
*
* <p>This is a stateless utility class. All state lives in {@link CampOwnership}
* (the SavedData singleton). Methods here orchestrate maid-specific side effects.
*/
public final class CampMaidManager {
private CampMaidManager() {} // utility class
/**
* Mark the maid as dead for a camp.
* The camp remains alive but prisoners are paused until new maid spawns.
*
* @param campId The camp UUID
* @param currentTime The current game time
* @param level The server level
*/
public static void markMaidDead(UUID campId, long currentTime, ServerLevel level) {
CampOwnership ownership = CampOwnership.get(level);
CampOwnership.CampData data = ownership.getCamp(campId);
if (data == null || !data.isAlive()) {
return;
}
// Save maid UUID before clearing (fix NPE)
UUID deadMaidId = data.getMaidUUID();
data.setMaidDeathTime(currentTime);
data.setMaidUUID(null);
// Reset prisoners who were being escorted by the dead maid
if (deadMaidId != null) {
reassignPrisonersFromMaid(deadMaidId, null, level);
}
ownership.setDirty();
TiedUpMod.LOGGER.info(
"[CampMaidManager] Maid died for camp {} - respawn available in 5 minutes",
campId.toString().substring(0, 8)
);
}
/**
* Assign a new maid to a camp (after respawn or initial setup).
*
* @param campId The camp UUID
* @param newMaidUUID The new maid's UUID
* @param level The server level
*/
public static void assignNewMaid(
UUID campId,
UUID newMaidUUID,
ServerLevel level
) {
CampOwnership ownership = CampOwnership.get(level);
CampOwnership.CampData data = ownership.getCamp(campId);
if (data == null) {
return;
}
UUID oldMaidId = data.getMaidUUID();
data.setMaidUUID(newMaidUUID);
data.setMaidDeathTime(-1); // Reset death time
// Transfer prisoners to new maid
reassignPrisonersFromMaid(oldMaidId, newMaidUUID, level);
ownership.setDirty();
TiedUpMod.LOGGER.info(
"[CampMaidManager] New maid {} assigned to camp {}",
newMaidUUID.toString().substring(0, 8),
campId.toString().substring(0, 8)
);
}
/**
* Get camps that need a new maid spawned.
*
* @param currentTime The current game time
* @param level The server level
* @return List of camp IDs ready for maid respawn
*/
public static List<UUID> getCampsNeedingMaidRespawn(long currentTime, ServerLevel level) {
CampOwnership ownership = CampOwnership.get(level);
List<UUID> result = new ArrayList<>();
for (CampOwnership.CampData data : ownership.getAllCamps()) {
if (data.isAlive() && data.canRespawnMaid(currentTime)) {
result.add(data.getCampId());
}
}
return result;
}
/**
* Reassign all prisoners from one maid to another (for maid death/replacement).
* The new PrisonerManager tracks labor state separately via LaborRecord.
*
* @param oldMaidId The old maid's UUID (or null to assign to all unassigned prisoners)
* @param newMaidId The new maid's UUID (or null if maid died with no replacement)
* @param level The server level
*/
public static void reassignPrisonersFromMaid(
@Nullable UUID oldMaidId,
@Nullable UUID newMaidId,
ServerLevel level
) {
PrisonerManager manager = PrisonerManager.get(level);
for (UUID playerId : manager.getAllPrisonerIds()) {
com.tiedup.remake.prison.LaborRecord labor = manager.getLaborRecord(
playerId
);
if (labor == null) continue;
// Check if this prisoner was managed by the old maid
UUID assignedMaid = labor.getMaidId();
boolean shouldReassign =
(oldMaidId == null && assignedMaid == null) ||
(oldMaidId != null && oldMaidId.equals(assignedMaid));
if (shouldReassign) {
// Update maid ID in labor record
labor.setMaidId(newMaidId);
// If maid died (no replacement) during active work, prisoner can rest
if (
newMaidId == null &&
labor.getPhase() !=
com.tiedup.remake.prison.LaborRecord.WorkPhase.IDLE
) {
labor.setPhase(
com.tiedup.remake.prison.LaborRecord.WorkPhase.IDLE,
level.getGameTime()
);
TiedUpMod.LOGGER.info(
"[CampMaidManager] Prisoner {} labor reset to IDLE - maid died during escort",
playerId.toString().substring(0, 8)
);
}
// If there's a replacement maid, keep current state so they get picked up
}
}
CampOwnership.get(level).setDirty();
}
}

View File

@@ -0,0 +1,576 @@
package com.tiedup.remake.cells;
import com.tiedup.remake.core.TiedUpMod;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import net.minecraft.core.BlockPos;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.ListTag;
import net.minecraft.nbt.Tag;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.saveddata.SavedData;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
/**
* Global registry for camp ownership linking camps to their SlaveTrader.
*
* This registry tracks:
* - Camp UUID -> CampData (trader, maid, alive status)
* - When a trader dies, the camp becomes inactive
*
* Persists across server restarts using Minecraft's SavedData system.
*/
public class CampOwnership extends SavedData {
private static final String DATA_NAME = "tiedup_camp_ownership";
// Camp UUID -> CampData
private final Map<UUID, CampData> camps = new ConcurrentHashMap<>();
// Prisoners that have been processed (to avoid re-processing)
private final Set<UUID> processedPrisoners = ConcurrentHashMap.newKeySet();
// ==================== CAMP DATA CLASS ====================
/**
* Data structure representing a camp and its owner.
* Uses thread-safe collections for concurrent access.
*/
/** Maid respawn delay in ticks (5 minutes = 6000 ticks) */
public static final long MAID_RESPAWN_DELAY = 6000;
public static class CampData {
private final UUID campId;
private volatile UUID traderUUID;
private volatile UUID maidUUID;
private volatile boolean isAlive = true;
private volatile BlockPos center;
private final Set<UUID> linkedKidnapperUUIDs =
ConcurrentHashMap.newKeySet();
/** Time when maid died (for respawn timer), -1 if alive */
private volatile long maidDeathTime = -1;
/** Cached positions of LOOT chests (below LOOT markers) */
private final List<BlockPos> lootChestPositions = new ArrayList<>();
public CampData(UUID campId) {
this.campId = campId;
}
public CampData(
UUID campId,
UUID traderUUID,
@Nullable UUID maidUUID,
BlockPos center
) {
this.campId = campId;
this.traderUUID = traderUUID;
this.maidUUID = maidUUID;
this.center = center;
this.isAlive = true;
}
// Getters
public UUID getCampId() {
return campId;
}
public UUID getTraderUUID() {
return traderUUID;
}
public UUID getMaidUUID() {
return maidUUID;
}
public boolean isAlive() {
return isAlive;
}
public BlockPos getCenter() {
return center;
}
// Setters
public void setTraderUUID(UUID traderUUID) {
this.traderUUID = traderUUID;
}
public void setMaidUUID(UUID maidUUID) {
this.maidUUID = maidUUID;
}
public void setAlive(boolean alive) {
this.isAlive = alive;
}
public void setCenter(BlockPos center) {
this.center = center;
}
// Maid death/respawn
public long getMaidDeathTime() {
return maidDeathTime;
}
public void setMaidDeathTime(long time) {
this.maidDeathTime = time;
}
public boolean isMaidDead() {
return maidDeathTime >= 0;
}
public boolean canRespawnMaid(long currentTime) {
return (
isMaidDead() &&
(currentTime - maidDeathTime) >= MAID_RESPAWN_DELAY
);
}
// Loot chest management
public List<BlockPos> getLootChestPositions() {
return lootChestPositions;
}
public void addLootChestPosition(BlockPos pos) {
if (!lootChestPositions.contains(pos)) {
lootChestPositions.add(pos);
}
}
// Kidnapper management
public void addKidnapper(UUID kidnapperUUID) {
linkedKidnapperUUIDs.add(kidnapperUUID);
}
public void removeKidnapper(UUID kidnapperUUID) {
linkedKidnapperUUIDs.remove(kidnapperUUID);
}
public Set<UUID> getLinkedKidnappers() {
return Collections.unmodifiableSet(linkedKidnapperUUIDs);
}
public boolean hasKidnapper(UUID kidnapperUUID) {
return linkedKidnapperUUIDs.contains(kidnapperUUID);
}
public int getKidnapperCount() {
return linkedKidnapperUUIDs.size();
}
// NBT Serialization
public CompoundTag save() {
CompoundTag tag = new CompoundTag();
tag.putUUID("campId", campId);
if (traderUUID != null) tag.putUUID("traderUUID", traderUUID);
if (maidUUID != null) tag.putUUID("maidUUID", maidUUID);
tag.putBoolean("isAlive", isAlive);
if (center != null) {
tag.putInt("centerX", center.getX());
tag.putInt("centerY", center.getY());
tag.putInt("centerZ", center.getZ());
}
// Save linked kidnappers
if (!linkedKidnapperUUIDs.isEmpty()) {
ListTag kidnapperList = new ListTag();
for (UUID uuid : linkedKidnapperUUIDs) {
CompoundTag uuidTag = new CompoundTag();
uuidTag.putUUID("uuid", uuid);
kidnapperList.add(uuidTag);
}
tag.put("linkedKidnappers", kidnapperList);
}
// Save maid death time
tag.putLong("maidDeathTime", maidDeathTime);
// Save loot chest positions
if (!lootChestPositions.isEmpty()) {
ListTag lootList = new ListTag();
for (BlockPos pos : lootChestPositions) {
CompoundTag posTag = new CompoundTag();
posTag.putInt("x", pos.getX());
posTag.putInt("y", pos.getY());
posTag.putInt("z", pos.getZ());
lootList.add(posTag);
}
tag.put("lootChestPositions", lootList);
}
return tag;
}
public static CampData load(CompoundTag tag) {
UUID campId = tag.getUUID("campId");
CampData data = new CampData(campId);
if (tag.contains("traderUUID")) data.traderUUID = tag.getUUID(
"traderUUID"
);
if (tag.contains("maidUUID")) data.maidUUID = tag.getUUID(
"maidUUID"
);
data.isAlive = tag.getBoolean("isAlive");
if (tag.contains("centerX")) {
data.center = new BlockPos(
tag.getInt("centerX"),
tag.getInt("centerY"),
tag.getInt("centerZ")
);
}
// Load linked kidnappers
if (tag.contains("linkedKidnappers")) {
ListTag kidnapperList = tag.getList(
"linkedKidnappers",
Tag.TAG_COMPOUND
);
for (int i = 0; i < kidnapperList.size(); i++) {
CompoundTag uuidTag = kidnapperList.getCompound(i);
if (uuidTag.contains("uuid")) {
data.linkedKidnapperUUIDs.add(uuidTag.getUUID("uuid"));
}
}
}
// Load maid death time
if (tag.contains("maidDeathTime")) {
data.maidDeathTime = tag.getLong("maidDeathTime");
}
// Load loot chest positions
if (tag.contains("lootChestPositions")) {
ListTag lootList = tag.getList(
"lootChestPositions",
Tag.TAG_COMPOUND
);
for (int i = 0; i < lootList.size(); i++) {
CompoundTag posTag = lootList.getCompound(i);
data.lootChestPositions.add(
new BlockPos(
posTag.getInt("x"),
posTag.getInt("y"),
posTag.getInt("z")
)
);
}
}
return data;
}
}
// ==================== STATIC ACCESS ====================
/**
* Get the CampOwnership registry for a server level.
*/
public static CampOwnership get(ServerLevel level) {
return level
.getDataStorage()
.computeIfAbsent(
CampOwnership::load,
CampOwnership::new,
DATA_NAME
);
}
/**
* Get the CampOwnership from a MinecraftServer.
*/
public static CampOwnership get(MinecraftServer server) {
ServerLevel overworld = server.overworld();
return get(overworld);
}
// ==================== CAMP MANAGEMENT ====================
/**
* Register a new camp with its trader and maid.
*
* @param campId The camp/structure UUID
* @param traderUUID The SlaveTrader entity UUID
* @param maidUUID The Maid entity UUID (can be null)
* @param center The center position of the camp
*/
public void registerCamp(
UUID campId,
UUID traderUUID,
@Nullable UUID maidUUID,
BlockPos center
) {
CampData data = new CampData(campId, traderUUID, maidUUID, center);
camps.put(campId, data);
setDirty();
}
/**
* Check if a camp is alive (has living trader).
*
* @param campId The camp UUID
* @return true if camp exists and is alive
*/
public boolean isCampAlive(UUID campId) {
CampData data = camps.get(campId);
return data != null && data.isAlive();
}
/**
* Get camp data by camp UUID.
*/
@Nullable
public CampData getCamp(UUID campId) {
return camps.get(campId);
}
/**
* Get camp data by trader UUID.
*/
@Nullable
public CampData getCampByTrader(UUID traderUUID) {
for (CampData camp : camps.values()) {
if (traderUUID.equals(camp.getTraderUUID())) {
return camp;
}
}
return null;
}
/**
* Get camp data by maid UUID.
*/
@Nullable
public CampData getCampByMaid(UUID maidUUID) {
for (CampData camp : camps.values()) {
if (maidUUID.equals(camp.getMaidUUID())) {
return camp;
}
}
return null;
}
/**
* Find camps near a position.
*
* @param center The center position
* @param radius The search radius
* @return List of camps within radius
*/
public List<CampData> findCampsNear(BlockPos center, double radius) {
List<CampData> nearby = new ArrayList<>();
double radiusSq = radius * radius;
for (CampData camp : camps.values()) {
if (
camp.getCenter() != null &&
camp.getCenter().distSqr(center) <= radiusSq
) {
nearby.add(camp);
}
}
return nearby;
}
/**
* Find the nearest alive camp to a position.
*
* @param pos The position to search from
* @param radius Maximum search radius
* @return The nearest alive camp, or null
*/
@Nullable
public CampData findNearestAliveCamp(BlockPos pos, double radius) {
CampData nearest = null;
double nearestDistSq = radius * radius;
for (CampData camp : camps.values()) {
if (!camp.isAlive() || camp.getCenter() == null) continue;
double distSq = camp.getCenter().distSqr(pos);
if (distSq < nearestDistSq) {
nearestDistSq = distSq;
nearest = camp;
}
}
return nearest;
}
/**
* Remove a camp from the registry.
*/
@Nullable
public CampData removeCamp(UUID campId) {
CampData removed = camps.remove(campId);
if (removed != null) {
setDirty();
}
return removed;
}
/**
* Get all registered camps.
*/
public Collection<CampData> getAllCamps() {
return Collections.unmodifiableCollection(camps.values());
}
/**
* Get all alive camps.
*/
public List<CampData> getAliveCamps() {
List<CampData> alive = new ArrayList<>();
for (CampData camp : camps.values()) {
if (camp.isAlive()) {
alive.add(camp);
}
}
return alive;
}
/**
* Link a kidnapper to a camp.
*
* @param campId The camp UUID
* @param kidnapperUUID The kidnapper UUID
*/
public void linkKidnapperToCamp(UUID campId, UUID kidnapperUUID) {
CampData data = camps.get(campId);
if (data != null) {
data.addKidnapper(kidnapperUUID);
setDirty();
}
}
/**
* Unlink a kidnapper from a camp.
*
* @param campId The camp UUID
* @param kidnapperUUID The kidnapper UUID
*/
public void unlinkKidnapperFromCamp(UUID campId, UUID kidnapperUUID) {
CampData data = camps.get(campId);
if (data != null) {
data.removeKidnapper(kidnapperUUID);
setDirty();
}
}
/**
* Check if a kidnapper is linked to a camp.
*
* @param campId The camp UUID
* @param kidnapperUUID The kidnapper UUID
* @return true if the kidnapper is linked to this camp
*/
public boolean isKidnapperLinked(UUID campId, UUID kidnapperUUID) {
CampData data = camps.get(campId);
return data != null && data.hasKidnapper(kidnapperUUID);
}
/**
* Find the camp a kidnapper is linked to.
*
* @param kidnapperUUID The kidnapper UUID
* @return The camp data, or null if not linked
*/
@Nullable
public CampData findCampByKidnapper(UUID kidnapperUUID) {
for (CampData camp : camps.values()) {
if (camp.hasKidnapper(kidnapperUUID)) {
return camp;
}
}
return null;
}
/**
* Mark a prisoner as processed (to avoid re-processing).
*
* @param prisonerId The prisoner's UUID
*/
public void markPrisonerProcessed(UUID prisonerId) {
processedPrisoners.add(prisonerId);
setDirty();
}
/**
* Check if a prisoner has been processed.
*
* @param prisonerId The prisoner's UUID
* @return true if already processed
*/
public boolean isPrisonerProcessed(UUID prisonerId) {
return processedPrisoners.contains(prisonerId);
}
/**
* Remove a prisoner from processed set.
*
* @param prisonerId The prisoner's UUID
*/
public void unmarkPrisonerProcessed(UUID prisonerId) {
if (processedPrisoners.remove(prisonerId)) {
setDirty();
}
}
/**
* Get the set of processed prisoners for a camp (unmodifiable).
*
* @return Unmodifiable set of processed prisoner UUIDs
*/
public Set<UUID> getProcessedPrisoners() {
return Collections.unmodifiableSet(processedPrisoners);
}
// ==================== PERSISTENCE ====================
@Override
public @NotNull CompoundTag save(@NotNull CompoundTag tag) {
// Save camps
ListTag campList = new ListTag();
for (CampData camp : camps.values()) {
campList.add(camp.save());
}
tag.put("camps", campList);
// Save processed prisoners
ListTag processedList = new ListTag();
for (UUID uuid : processedPrisoners) {
CompoundTag uuidTag = new CompoundTag();
uuidTag.putUUID("uuid", uuid);
processedList.add(uuidTag);
}
tag.put("processedPrisoners", processedList);
return tag;
}
public static CampOwnership load(CompoundTag tag) {
CampOwnership registry = new CampOwnership();
// Load camps
if (tag.contains("camps")) {
ListTag campList = tag.getList("camps", Tag.TAG_COMPOUND);
for (int i = 0; i < campList.size(); i++) {
CampData camp = CampData.load(campList.getCompound(i));
registry.camps.put(camp.getCampId(), camp);
}
}
// Load processed prisoners
if (tag.contains("processedPrisoners")) {
ListTag processedList = tag.getList(
"processedPrisoners",
Tag.TAG_COMPOUND
);
for (int i = 0; i < processedList.size(); i++) {
CompoundTag uuidTag = processedList.getCompound(i);
if (uuidTag.contains("uuid")) {
registry.processedPrisoners.add(uuidTag.getUUID("uuid"));
}
}
}
return registry;
}
}

View File

@@ -0,0 +1,607 @@
package com.tiedup.remake.cells;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.ListTag;
import net.minecraft.nbt.NbtUtils;
import net.minecraft.nbt.Tag;
import org.jetbrains.annotations.Nullable;
/**
* Cell data for Cell System V2.
*
* Named CellDataV2 to coexist with v1 CellData during the migration period.
* Contains geometry (interior + walls from flood-fill), breach tracking,
* prisoner management, and auto-detected features.
*/
public class CellDataV2 {
private static final int MAX_PRISONERS = 4;
// Identity
private final UUID id;
private CellState state;
private final BlockPos corePos;
// Cached from Core BE (for use when chunk is unloaded)
@Nullable
private BlockPos spawnPoint;
@Nullable
private BlockPos deliveryPoint;
// Ownership
@Nullable
private UUID ownerId;
private CellOwnerType ownerType = CellOwnerType.PLAYER;
@Nullable
private String name;
// Geometry (from flood-fill)
private final Set<BlockPos> interiorBlocks;
private final Set<BlockPos> wallBlocks;
private final Set<BlockPos> breachedPositions;
private int totalWallCount;
// Interior face direction (which face of Core points inside)
@Nullable
private Direction interiorFace;
// Auto-detected features
private final List<BlockPos> beds;
private final List<BlockPos> petBeds;
private final List<BlockPos> anchors;
private final List<BlockPos> doors;
private final List<BlockPos> linkedRedstone;
// Prisoners
private final List<UUID> prisonerIds = new CopyOnWriteArrayList<>();
private final Map<UUID, Long> prisonerTimestamps =
new ConcurrentHashMap<>();
// Camp navigation
private final List<BlockPos> pathWaypoints = new CopyOnWriteArrayList<>();
// ==================== CONSTRUCTORS ====================
/**
* Create from a successful flood-fill result.
*/
public CellDataV2(BlockPos corePos, FloodFillResult result) {
this.id = UUID.randomUUID();
this.state = CellState.INTACT;
this.corePos = corePos.immutable();
this.interiorFace = result.getInteriorFace();
this.interiorBlocks = ConcurrentHashMap.newKeySet();
this.interiorBlocks.addAll(result.getInterior());
this.wallBlocks = ConcurrentHashMap.newKeySet();
this.wallBlocks.addAll(result.getWalls());
this.breachedPositions = ConcurrentHashMap.newKeySet();
this.totalWallCount = result.getWalls().size();
this.beds = new CopyOnWriteArrayList<>(result.getBeds());
this.petBeds = new CopyOnWriteArrayList<>(result.getPetBeds());
this.anchors = new CopyOnWriteArrayList<>(result.getAnchors());
this.doors = new CopyOnWriteArrayList<>(result.getDoors());
this.linkedRedstone = new CopyOnWriteArrayList<>(
result.getLinkedRedstone()
);
}
/**
* Create for NBT loading (minimal constructor).
*/
public CellDataV2(UUID id, BlockPos corePos) {
this.id = id;
this.state = CellState.INTACT;
this.corePos = corePos.immutable();
this.interiorBlocks = ConcurrentHashMap.newKeySet();
this.wallBlocks = ConcurrentHashMap.newKeySet();
this.breachedPositions = ConcurrentHashMap.newKeySet();
this.totalWallCount = 0;
this.beds = new CopyOnWriteArrayList<>();
this.petBeds = new CopyOnWriteArrayList<>();
this.anchors = new CopyOnWriteArrayList<>();
this.doors = new CopyOnWriteArrayList<>();
this.linkedRedstone = new CopyOnWriteArrayList<>();
}
// ==================== IDENTITY ====================
public UUID getId() {
return id;
}
public CellState getState() {
return state;
}
public void setState(CellState state) {
this.state = state;
}
public BlockPos getCorePos() {
return corePos;
}
@Nullable
public BlockPos getSpawnPoint() {
return spawnPoint;
}
public void setSpawnPoint(@Nullable BlockPos spawnPoint) {
this.spawnPoint = spawnPoint != null ? spawnPoint.immutable() : null;
}
@Nullable
public BlockPos getDeliveryPoint() {
return deliveryPoint;
}
public void setDeliveryPoint(@Nullable BlockPos deliveryPoint) {
this.deliveryPoint =
deliveryPoint != null ? deliveryPoint.immutable() : null;
}
@Nullable
public Direction getInteriorFace() {
return interiorFace;
}
// ==================== OWNERSHIP ====================
@Nullable
public UUID getOwnerId() {
return ownerId;
}
public void setOwnerId(@Nullable UUID ownerId) {
this.ownerId = ownerId;
}
public CellOwnerType getOwnerType() {
return ownerType;
}
public void setOwnerType(CellOwnerType ownerType) {
this.ownerType = ownerType;
}
@Nullable
public String getName() {
return name;
}
public void setName(@Nullable String name) {
this.name = name;
}
public boolean isOwnedBy(UUID playerId) {
return playerId != null && playerId.equals(ownerId);
}
/**
* Check if a player can manage this cell (open menu, rename, modify settings).
* - OPs (level 2+) can always manage any cell (including camp-owned).
* - Player-owned cells: only the owning player.
* - Camp-owned cells: OPs only.
*
* @param playerId UUID of the player
* @param hasOpPerms true if the player has OP level 2+
* @return true if the player is allowed to manage this cell
*/
public boolean canPlayerManage(UUID playerId, boolean hasOpPerms) {
if (hasOpPerms) return true;
if (isCampOwned()) return false;
return isOwnedBy(playerId);
}
public boolean hasOwner() {
return ownerId != null;
}
public boolean isCampOwned() {
return ownerType == CellOwnerType.CAMP;
}
public boolean isPlayerOwned() {
return ownerType == CellOwnerType.PLAYER;
}
@Nullable
public UUID getCampId() {
return isCampOwned() ? ownerId : null;
}
// ==================== GEOMETRY ====================
public Set<BlockPos> getInteriorBlocks() {
return Collections.unmodifiableSet(interiorBlocks);
}
public Set<BlockPos> getWallBlocks() {
return Collections.unmodifiableSet(wallBlocks);
}
public Set<BlockPos> getBreachedPositions() {
return Collections.unmodifiableSet(breachedPositions);
}
public int getTotalWallCount() {
return totalWallCount;
}
public boolean isContainedInCell(BlockPos pos) {
return interiorBlocks.contains(pos);
}
public boolean isWallBlock(BlockPos pos) {
return wallBlocks.contains(pos);
}
// ==================== BREACH MANAGEMENT ====================
public void addBreach(BlockPos wallPos) {
if (wallBlocks.remove(wallPos)) {
breachedPositions.add(wallPos.immutable());
}
}
public void repairBreach(BlockPos wallPos) {
if (breachedPositions.remove(wallPos)) {
wallBlocks.add(wallPos.immutable());
}
}
public float getBreachPercentage() {
if (totalWallCount == 0) return 0.0f;
return (float) breachedPositions.size() / totalWallCount;
}
// ==================== FEATURES ====================
public List<BlockPos> getBeds() {
return Collections.unmodifiableList(beds);
}
public List<BlockPos> getPetBeds() {
return Collections.unmodifiableList(petBeds);
}
public List<BlockPos> getAnchors() {
return Collections.unmodifiableList(anchors);
}
public List<BlockPos> getDoors() {
return Collections.unmodifiableList(doors);
}
public List<BlockPos> getLinkedRedstone() {
return Collections.unmodifiableList(linkedRedstone);
}
// ==================== PRISONER MANAGEMENT ====================
public List<UUID> getPrisonerIds() {
return Collections.unmodifiableList(prisonerIds);
}
public boolean hasPrisoner(UUID prisonerId) {
return prisonerIds.contains(prisonerId);
}
public boolean isFull() {
return prisonerIds.size() >= MAX_PRISONERS;
}
public boolean isOccupied() {
return !prisonerIds.isEmpty();
}
public int getPrisonerCount() {
return prisonerIds.size();
}
public boolean addPrisoner(UUID prisonerId) {
if (isFull() || prisonerIds.contains(prisonerId)) {
return false;
}
prisonerIds.add(prisonerId);
prisonerTimestamps.put(prisonerId, System.currentTimeMillis());
return true;
}
public boolean removePrisoner(UUID prisonerId) {
boolean removed = prisonerIds.remove(prisonerId);
if (removed) {
prisonerTimestamps.remove(prisonerId);
}
return removed;
}
@Nullable
public Long getPrisonerTimestamp(UUID prisonerId) {
return prisonerTimestamps.get(prisonerId);
}
// ==================== CAMP NAVIGATION ====================
public List<BlockPos> getPathWaypoints() {
return Collections.unmodifiableList(pathWaypoints);
}
public void setPathWaypoints(List<BlockPos> waypoints) {
pathWaypoints.clear();
pathWaypoints.addAll(waypoints);
}
// ==================== WIRE RECONSTRUCTION (client-side) ====================
/** Add a wall block position (used for client-side packet reconstruction). */
public void addWallBlock(BlockPos pos) {
wallBlocks.add(pos.immutable());
}
/** Add a bed position (used for client-side packet reconstruction). */
public void addBed(BlockPos pos) {
beds.add(pos.immutable());
}
/** Add an anchor position (used for client-side packet reconstruction). */
public void addAnchor(BlockPos pos) {
anchors.add(pos.immutable());
}
/** Add a door position (used for client-side packet reconstruction). */
public void addDoor(BlockPos pos) {
doors.add(pos.immutable());
}
// ==================== GEOMETRY UPDATE (rescan) ====================
/**
* Replace geometry with a new flood-fill result (used during rescan).
*/
public void updateGeometry(FloodFillResult result) {
interiorBlocks.clear();
interiorBlocks.addAll(result.getInterior());
wallBlocks.clear();
wallBlocks.addAll(result.getWalls());
breachedPositions.clear();
totalWallCount = result.getWalls().size();
if (result.getInteriorFace() != null) {
this.interiorFace = result.getInteriorFace();
}
beds.clear();
beds.addAll(result.getBeds());
petBeds.clear();
petBeds.addAll(result.getPetBeds());
anchors.clear();
anchors.addAll(result.getAnchors());
doors.clear();
doors.addAll(result.getDoors());
linkedRedstone.clear();
linkedRedstone.addAll(result.getLinkedRedstone());
state = CellState.INTACT;
}
// ==================== NBT PERSISTENCE ====================
public CompoundTag save() {
CompoundTag tag = new CompoundTag();
tag.putUUID("id", id);
tag.putString("state", state.getSerializedName());
tag.put("corePos", NbtUtils.writeBlockPos(corePos));
if (spawnPoint != null) {
tag.put("spawnPoint", NbtUtils.writeBlockPos(spawnPoint));
}
if (deliveryPoint != null) {
tag.put("deliveryPoint", NbtUtils.writeBlockPos(deliveryPoint));
}
if (interiorFace != null) {
tag.putString("interiorFace", interiorFace.getSerializedName());
}
// Ownership
if (ownerId != null) {
tag.putUUID("ownerId", ownerId);
}
tag.putString("ownerType", ownerType.getSerializedName());
if (name != null) {
tag.putString("name", name);
}
// Geometry
tag.put("interior", saveBlockPosSet(interiorBlocks));
tag.put("walls", saveBlockPosSet(wallBlocks));
tag.put("breached", saveBlockPosSet(breachedPositions));
tag.putInt("totalWallCount", totalWallCount);
// Features
tag.put("beds", saveBlockPosList(beds));
tag.put("petBeds", saveBlockPosList(petBeds));
tag.put("anchors", saveBlockPosList(anchors));
tag.put("doors", saveBlockPosList(doors));
tag.put("linkedRedstone", saveBlockPosList(linkedRedstone));
// Prisoners
if (!prisonerIds.isEmpty()) {
ListTag prisonerList = new ListTag();
for (UUID uuid : prisonerIds) {
CompoundTag prisonerTag = new CompoundTag();
prisonerTag.putUUID("id", uuid);
prisonerTag.putLong(
"timestamp",
prisonerTimestamps.getOrDefault(
uuid,
System.currentTimeMillis()
)
);
prisonerList.add(prisonerTag);
}
tag.put("prisoners", prisonerList);
}
// Path waypoints
if (!pathWaypoints.isEmpty()) {
tag.put("pathWaypoints", saveBlockPosList(pathWaypoints));
}
return tag;
}
@Nullable
public static CellDataV2 load(CompoundTag tag) {
if (!tag.contains("id") || !tag.contains("corePos")) {
return null;
}
UUID id = tag.getUUID("id");
BlockPos corePos = NbtUtils.readBlockPos(tag.getCompound("corePos"));
CellDataV2 cell = new CellDataV2(id, corePos);
cell.state = CellState.fromString(tag.getString("state"));
if (tag.contains("spawnPoint")) {
cell.spawnPoint = NbtUtils.readBlockPos(
tag.getCompound("spawnPoint")
);
}
if (tag.contains("deliveryPoint")) {
cell.deliveryPoint = NbtUtils.readBlockPos(
tag.getCompound("deliveryPoint")
);
}
if (tag.contains("interiorFace")) {
cell.interiorFace = Direction.byName(tag.getString("interiorFace"));
}
// Ownership
if (tag.contains("ownerId")) {
cell.ownerId = tag.getUUID("ownerId");
}
if (tag.contains("ownerType")) {
cell.ownerType = CellOwnerType.fromString(
tag.getString("ownerType")
);
}
if (tag.contains("name")) {
cell.name = tag.getString("name");
}
// Geometry
loadBlockPosSet(tag, "interior", cell.interiorBlocks);
loadBlockPosSet(tag, "walls", cell.wallBlocks);
loadBlockPosSet(tag, "breached", cell.breachedPositions);
cell.totalWallCount = tag.getInt("totalWallCount");
// Features
loadBlockPosList(tag, "beds", cell.beds);
loadBlockPosList(tag, "petBeds", cell.petBeds);
loadBlockPosList(tag, "anchors", cell.anchors);
loadBlockPosList(tag, "doors", cell.doors);
loadBlockPosList(tag, "linkedRedstone", cell.linkedRedstone);
// Prisoners
if (tag.contains("prisoners")) {
ListTag prisonerList = tag.getList("prisoners", Tag.TAG_COMPOUND);
for (int i = 0; i < prisonerList.size(); i++) {
CompoundTag prisonerTag = prisonerList.getCompound(i);
UUID prisonerId = prisonerTag.getUUID("id");
cell.prisonerIds.add(prisonerId);
long timestamp = prisonerTag.contains("timestamp")
? prisonerTag.getLong("timestamp")
: System.currentTimeMillis();
cell.prisonerTimestamps.put(prisonerId, timestamp);
}
}
// Path waypoints
loadBlockPosList(tag, "pathWaypoints", cell.pathWaypoints);
return cell;
}
// ==================== NBT HELPERS ====================
private static ListTag saveBlockPosSet(Set<BlockPos> positions) {
ListTag list = new ListTag();
for (BlockPos pos : positions) {
list.add(NbtUtils.writeBlockPos(pos));
}
return list;
}
private static ListTag saveBlockPosList(List<BlockPos> positions) {
ListTag list = new ListTag();
for (BlockPos pos : positions) {
list.add(NbtUtils.writeBlockPos(pos));
}
return list;
}
private static void loadBlockPosSet(
CompoundTag parent,
String key,
Set<BlockPos> target
) {
if (parent.contains(key)) {
ListTag list = parent.getList(key, Tag.TAG_COMPOUND);
for (int i = 0; i < list.size(); i++) {
target.add(NbtUtils.readBlockPos(list.getCompound(i)));
}
}
}
private static void loadBlockPosList(
CompoundTag parent,
String key,
List<BlockPos> target
) {
if (parent.contains(key)) {
ListTag list = parent.getList(key, Tag.TAG_COMPOUND);
for (int i = 0; i < list.size(); i++) {
target.add(NbtUtils.readBlockPos(list.getCompound(i)));
}
}
}
// ==================== DEBUG ====================
@Override
public String toString() {
return (
"CellDataV2{id=" +
id.toString().substring(0, 8) +
"..., state=" +
state +
", core=" +
corePos.toShortString() +
", interior=" +
interiorBlocks.size() +
", walls=" +
wallBlocks.size() +
", prisoners=" +
prisonerIds.size() +
"}"
);
}
}

View File

@@ -0,0 +1,33 @@
package com.tiedup.remake.cells;
/**
* Enum indicating who owns a cell.
* Used to distinguish player-created cells from camp-generated cells.
*
* Extracted from CellData.OwnerType to decouple V2 code from V1 CellData.
*/
public enum CellOwnerType {
/** Cell created by a player using Cell Wand */
PLAYER("player"),
/** Cell generated with a kidnapper camp structure */
CAMP("camp");
private final String serializedName;
CellOwnerType(String serializedName) {
this.serializedName = serializedName;
}
public String getSerializedName() {
return serializedName;
}
public static CellOwnerType fromString(String name) {
for (CellOwnerType type : values()) {
if (type.serializedName.equalsIgnoreCase(name)) {
return type;
}
}
return PLAYER; // Default to PLAYER for backwards compatibility
}
}

View File

@@ -0,0 +1,903 @@
package com.tiedup.remake.cells;
import com.tiedup.remake.core.SystemMessageManager;
import com.tiedup.remake.core.TiedUpMod;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
import net.minecraft.core.BlockPos;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.ListTag;
import net.minecraft.nbt.Tag;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.saveddata.SavedData;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
/**
* Global registry for Cell System V2 data.
*
* Named CellRegistryV2 to coexist with v1 CellRegistry during migration.
* Uses "tiedup_cell_registry_v2" as the SavedData name.
*
* Provides spatial indices for fast lookups by wall position, interior position,
* core position, chunk, and camp.
*/
public class CellRegistryV2 extends SavedData {
private static final String DATA_NAME = "tiedup_cell_registry_v2";
/** Reservation timeout in ticks (30 seconds = 600 ticks) */
private static final long RESERVATION_TIMEOUT_TICKS = 600L;
// ==================== RESERVATION ====================
private static class CellReservation {
private final UUID kidnapperUUID;
private final long expiryTime;
public CellReservation(UUID kidnapperUUID, long expiryTime) {
this.kidnapperUUID = kidnapperUUID;
this.expiryTime = expiryTime;
}
public UUID getKidnapperUUID() {
return kidnapperUUID;
}
public boolean isExpired(long currentTime) {
return currentTime >= expiryTime;
}
}
// ==================== STORAGE ====================
// Primary storage
private final Map<UUID, CellDataV2> cells = new ConcurrentHashMap<>();
// Indices (rebuilt on load)
private final Map<BlockPos, UUID> wallToCell = new ConcurrentHashMap<>();
private final Map<BlockPos, UUID> interiorToCell =
new ConcurrentHashMap<>();
private final Map<BlockPos, UUID> coreToCell = new ConcurrentHashMap<>();
// Spatial + camp indices
private final Map<ChunkPos, Set<UUID>> cellsByChunk =
new ConcurrentHashMap<>();
private final Map<UUID, Set<UUID>> cellsByCamp = new ConcurrentHashMap<>();
// Breach tracking index (breached wall position → cell ID)
private final Map<BlockPos, UUID> breachedToCell =
new ConcurrentHashMap<>();
// Reservations (not persisted)
private final Map<UUID, CellReservation> reservations =
new ConcurrentHashMap<>();
// ==================== STATIC ACCESS ====================
public static CellRegistryV2 get(ServerLevel level) {
return level
.getDataStorage()
.computeIfAbsent(
CellRegistryV2::load,
CellRegistryV2::new,
DATA_NAME
);
}
public static CellRegistryV2 get(MinecraftServer server) {
return get(server.overworld());
}
// ==================== CELL LIFECYCLE ====================
/**
* Create a new cell from a flood-fill result.
*/
public CellDataV2 createCell(
BlockPos corePos,
FloodFillResult result,
@Nullable UUID ownerId
) {
CellDataV2 cell = new CellDataV2(corePos, result);
if (ownerId != null) {
cell.setOwnerId(ownerId);
}
cells.put(cell.getId(), cell);
// Register in indices
coreToCell.put(corePos.immutable(), cell.getId());
for (BlockPos pos : cell.getWallBlocks()) {
wallToCell.put(pos.immutable(), cell.getId());
}
for (BlockPos pos : cell.getInteriorBlocks()) {
interiorToCell.put(pos.immutable(), cell.getId());
}
addToSpatialIndex(cell);
setDirty();
return cell;
}
/**
* Register a pre-constructed CellDataV2 (used by migration and structure loading).
* The cell must already have its ID and corePos set.
*/
public void registerExistingCell(CellDataV2 cell) {
cells.put(cell.getId(), cell);
coreToCell.put(cell.getCorePos().immutable(), cell.getId());
for (BlockPos pos : cell.getWallBlocks()) {
wallToCell.put(pos.immutable(), cell.getId());
}
for (BlockPos pos : cell.getInteriorBlocks()) {
interiorToCell.put(pos.immutable(), cell.getId());
}
addToSpatialIndex(cell);
setDirty();
}
/**
* Remove a cell from the registry and all indices.
*/
public void removeCell(UUID cellId) {
CellDataV2 cell = cells.remove(cellId);
if (cell == null) return;
coreToCell.remove(cell.getCorePos());
for (BlockPos pos : cell.getWallBlocks()) {
wallToCell.remove(pos);
}
for (BlockPos pos : cell.getBreachedPositions()) {
breachedToCell.remove(pos);
}
for (BlockPos pos : cell.getInteriorBlocks()) {
interiorToCell.remove(pos);
}
removeFromSpatialIndex(cell);
reservations.remove(cellId);
setDirty();
}
/**
* Rescan a cell with a new flood-fill result.
* Clears old indices and repopulates with new geometry.
*/
public void rescanCell(UUID cellId, FloodFillResult newResult) {
CellDataV2 cell = cells.get(cellId);
if (cell == null) return;
// Clear old indices for this cell
for (BlockPos pos : cell.getWallBlocks()) {
wallToCell.remove(pos);
}
for (BlockPos pos : cell.getBreachedPositions()) {
wallToCell.remove(pos);
breachedToCell.remove(pos);
}
for (BlockPos pos : cell.getInteriorBlocks()) {
interiorToCell.remove(pos);
}
removeFromSpatialIndex(cell);
// Update geometry
cell.updateGeometry(newResult);
// Rebuild indices
for (BlockPos pos : cell.getWallBlocks()) {
wallToCell.put(pos.immutable(), cellId);
}
for (BlockPos pos : cell.getInteriorBlocks()) {
interiorToCell.put(pos.immutable(), cellId);
}
addToSpatialIndex(cell);
setDirty();
}
// ==================== QUERIES ====================
@Nullable
public CellDataV2 getCell(UUID cellId) {
return cells.get(cellId);
}
@Nullable
public CellDataV2 getCellAtCore(BlockPos corePos) {
UUID cellId = coreToCell.get(corePos);
return cellId != null ? cells.get(cellId) : null;
}
@Nullable
public CellDataV2 getCellContaining(BlockPos pos) {
UUID cellId = interiorToCell.get(pos);
return cellId != null ? cells.get(cellId) : null;
}
@Nullable
public CellDataV2 getCellByWall(BlockPos pos) {
UUID cellId = wallToCell.get(pos);
return cellId != null ? cells.get(cellId) : null;
}
@Nullable
public UUID getCellIdAtWall(BlockPos pos) {
return wallToCell.get(pos);
}
public boolean isInsideAnyCell(BlockPos pos) {
return interiorToCell.containsKey(pos);
}
public Collection<CellDataV2> getAllCells() {
return Collections.unmodifiableCollection(cells.values());
}
public int getCellCount() {
return cells.size();
}
public List<CellDataV2> getCellsByCamp(UUID campId) {
Set<UUID> cellIds = cellsByCamp.get(campId);
if (cellIds == null) return Collections.emptyList();
List<CellDataV2> result = new ArrayList<>();
for (UUID cellId : cellIds) {
CellDataV2 cell = cells.get(cellId);
if (cell != null) {
result.add(cell);
}
}
return result;
}
public List<CellDataV2> findCellsNear(BlockPos center, double radius) {
List<CellDataV2> nearby = new ArrayList<>();
double radiusSq = radius * radius;
int chunkRadius = (int) Math.ceil(radius / 16.0) + 1;
ChunkPos centerChunk = new ChunkPos(center);
for (int dx = -chunkRadius; dx <= chunkRadius; dx++) {
for (int dz = -chunkRadius; dz <= chunkRadius; dz++) {
ChunkPos checkChunk = new ChunkPos(
centerChunk.x + dx,
centerChunk.z + dz
);
Set<UUID> cellsInChunk = cellsByChunk.get(checkChunk);
if (cellsInChunk != null) {
for (UUID cellId : cellsInChunk) {
CellDataV2 cell = cells.get(cellId);
if (
cell != null &&
cell.getCorePos().distSqr(center) <= radiusSq
) {
nearby.add(cell);
}
}
}
}
}
return nearby;
}
@Nullable
public CellDataV2 findCellByPrisoner(UUID prisonerId) {
for (CellDataV2 cell : cells.values()) {
if (cell.hasPrisoner(prisonerId)) {
return cell;
}
}
return null;
}
@Nullable
public CellDataV2 getCellByName(String name) {
if (name == null || name.isEmpty()) return null;
for (CellDataV2 cell : cells.values()) {
if (name.equals(cell.getName())) {
return cell;
}
}
return null;
}
public List<CellDataV2> getCellsByOwner(UUID ownerId) {
if (ownerId == null) return Collections.emptyList();
return cells
.values()
.stream()
.filter(c -> ownerId.equals(c.getOwnerId()))
.collect(Collectors.toList());
}
public int getCellCountOwnedBy(UUID ownerId) {
if (ownerId == null) return 0;
return (int) cells
.values()
.stream()
.filter(c -> ownerId.equals(c.getOwnerId()))
.count();
}
@Nullable
public UUID getNextCellId(@Nullable UUID currentId) {
if (cells.isEmpty()) return null;
List<UUID> ids = new ArrayList<>(cells.keySet());
if (currentId == null) return ids.get(0);
int index = ids.indexOf(currentId);
if (index < 0 || index >= ids.size() - 1) return ids.get(0);
return ids.get(index + 1);
}
// ==================== CAMP QUERIES ====================
public List<UUID> getPrisonersInCamp(UUID campId) {
if (campId == null) return Collections.emptyList();
return getCellsByCamp(campId)
.stream()
.flatMap(cell -> cell.getPrisonerIds().stream())
.collect(Collectors.toList());
}
public int getPrisonerCountInCamp(UUID campId) {
if (campId == null) return 0;
return getCellsByCamp(campId)
.stream()
.mapToInt(CellDataV2::getPrisonerCount)
.sum();
}
@Nullable
public CellDataV2 findAvailableCellInCamp(UUID campId) {
if (campId == null) return null;
for (CellDataV2 cell : getCellsByCamp(campId)) {
if (!cell.isFull()) {
return cell;
}
}
return null;
}
public boolean hasCampCells(UUID campId) {
if (campId == null) return false;
Set<UUID> cellIds = cellsByCamp.get(campId);
return cellIds != null && !cellIds.isEmpty();
}
/**
* Update the camp index for a cell after ownership change.
* Removes from old camp index, adds to new if camp-owned.
*/
public void updateCampIndex(CellDataV2 cell, @Nullable UUID oldOwnerId) {
if (oldOwnerId != null) {
Set<UUID> oldCampCells = cellsByCamp.get(oldOwnerId);
if (oldCampCells != null) {
oldCampCells.remove(cell.getId());
if (oldCampCells.isEmpty()) {
cellsByCamp.remove(oldOwnerId);
}
}
}
if (cell.isCampOwned() && cell.getOwnerId() != null) {
cellsByCamp
.computeIfAbsent(cell.getOwnerId(), k ->
ConcurrentHashMap.newKeySet()
)
.add(cell.getId());
}
setDirty();
}
// ==================== PRISONER MANAGEMENT ====================
public synchronized boolean assignPrisoner(UUID cellId, UUID prisonerId) {
CellDataV2 cell = cells.get(cellId);
if (cell == null || cell.isFull()) return false;
// Ensure prisoner uniqueness across cells
CellDataV2 existingCell = findCellByPrisoner(prisonerId);
if (existingCell != null) {
if (existingCell.getId().equals(cellId)) {
return true; // Already in this cell
}
return false; // Already in another cell
}
if (cell.addPrisoner(prisonerId)) {
setDirty();
return true;
}
return false;
}
public boolean releasePrisoner(
UUID cellId,
UUID prisonerId,
MinecraftServer server
) {
CellDataV2 cell = cells.get(cellId);
if (cell == null) return false;
if (cell.removePrisoner(prisonerId)) {
// Synchronize with PrisonerManager
if (cell.isCampOwned() && cell.getCampId() != null) {
com.tiedup.remake.prison.PrisonerManager manager =
com.tiedup.remake.prison.PrisonerManager.get(
server.overworld()
);
com.tiedup.remake.prison.PrisonerState currentState =
manager.getState(prisonerId);
boolean isBeingExtracted =
currentState ==
com.tiedup.remake.prison.PrisonerState.WORKING;
if (
!isBeingExtracted &&
currentState ==
com.tiedup.remake.prison.PrisonerState.IMPRISONED
) {
manager.release(
prisonerId,
server.overworld().getGameTime()
);
}
}
setDirty();
return true;
}
return false;
}
public boolean assignPrisonerWithNotification(
UUID cellId,
UUID prisonerId,
MinecraftServer server,
String prisonerName
) {
CellDataV2 cell = cells.get(cellId);
if (cell == null || cell.isFull()) return false;
if (cell.addPrisoner(prisonerId)) {
setDirty();
if (cell.hasOwner()) {
notifyOwner(
server,
cell.getOwnerId(),
SystemMessageManager.MessageCategory.PRISONER_ARRIVED,
prisonerName
);
}
return true;
}
return false;
}
public boolean releasePrisonerWithNotification(
UUID cellId,
UUID prisonerId,
MinecraftServer server,
String prisonerName,
boolean escaped
) {
CellDataV2 cell = cells.get(cellId);
if (cell == null) return false;
if (cell.removePrisoner(prisonerId)) {
// Synchronize with PrisonerManager
if (cell.isCampOwned() && cell.getCampId() != null) {
com.tiedup.remake.prison.PrisonerManager manager =
com.tiedup.remake.prison.PrisonerManager.get(
server.overworld()
);
com.tiedup.remake.prison.PrisonerState currentState =
manager.getState(prisonerId);
if (
currentState ==
com.tiedup.remake.prison.PrisonerState.IMPRISONED
) {
manager.release(
prisonerId,
server.overworld().getGameTime()
);
}
}
setDirty();
if (cell.hasOwner()) {
SystemMessageManager.MessageCategory category = escaped
? SystemMessageManager.MessageCategory.PRISONER_ESCAPED
: SystemMessageManager.MessageCategory.PRISONER_RELEASED;
notifyOwner(server, cell.getOwnerId(), category, prisonerName);
}
return true;
}
return false;
}
public int releasePrisonerFromAllCells(UUID prisonerId) {
int count = 0;
for (CellDataV2 cell : cells.values()) {
if (cell.removePrisoner(prisonerId)) {
count++;
}
}
if (count > 0) {
setDirty();
}
return count;
}
/** Offline timeout for cleanup: 30 minutes */
private static final long OFFLINE_TIMEOUT_MS = 30 * 60 * 1000L;
public int cleanupEscapedPrisoners(
ServerLevel level,
com.tiedup.remake.state.CollarRegistry collarRegistry,
double maxDistance
) {
int removed = 0;
for (CellDataV2 cell : cells.values()) {
List<UUID> toRemove = new ArrayList<>();
for (UUID prisonerId : cell.getPrisonerIds()) {
boolean shouldRemove = false;
String reason = null;
ServerPlayer prisoner = level
.getServer()
.getPlayerList()
.getPlayer(prisonerId);
if (prisoner == null) {
Long timestamp = cell.getPrisonerTimestamp(prisonerId);
long ts =
timestamp != null
? timestamp
: System.currentTimeMillis();
long offlineDuration = System.currentTimeMillis() - ts;
if (offlineDuration > OFFLINE_TIMEOUT_MS) {
shouldRemove = true;
reason =
"offline for too long (" +
(offlineDuration / 60000) +
" minutes)";
} else {
continue;
}
} else {
// Use corePos for distance check (V2 uses core position, not spawnPoint)
double distSq = prisoner
.blockPosition()
.distSqr(cell.getCorePos());
if (distSq > maxDistance * maxDistance) {
shouldRemove = true;
reason =
"too far from cell (" +
(int) Math.sqrt(distSq) +
" blocks)";
}
if (
!shouldRemove && !collarRegistry.hasOwners(prisonerId)
) {
shouldRemove = true;
reason = "no collar registered";
}
if (!shouldRemove) {
com.tiedup.remake.state.IBondageState state =
com.tiedup.remake.util.KidnappedHelper.getKidnappedState(
prisoner
);
if (state == null || !state.isCaptive()) {
shouldRemove = true;
reason = "no longer captive";
}
}
}
if (shouldRemove) {
toRemove.add(prisonerId);
TiedUpMod.LOGGER.info(
"[CellRegistryV2] Removing escaped prisoner {} from cell {} - reason: {}",
prisonerId.toString().substring(0, 8),
cell.getId().toString().substring(0, 8),
reason
);
}
}
for (UUID id : toRemove) {
cell.removePrisoner(id);
if (cell.isCampOwned() && cell.getCampId() != null) {
com.tiedup.remake.prison.PrisonerManager manager =
com.tiedup.remake.prison.PrisonerManager.get(
level.getServer().overworld()
);
com.tiedup.remake.prison.PrisonerState currentState =
manager.getState(id);
if (
currentState ==
com.tiedup.remake.prison.PrisonerState.IMPRISONED
) {
com.tiedup.remake.prison.service.PrisonerService.get().escape(
level,
id,
"offline_cleanup"
);
}
}
removed++;
}
}
if (removed > 0) {
setDirty();
}
return removed;
}
// ==================== NOTIFICATIONS ====================
private void notifyOwner(
MinecraftServer server,
UUID ownerId,
SystemMessageManager.MessageCategory category,
String prisonerName
) {
if (server == null || ownerId == null) return;
ServerPlayer owner = server.getPlayerList().getPlayer(ownerId);
if (owner != null) {
String template = SystemMessageManager.getTemplate(category);
String formattedMessage = String.format(template, prisonerName);
SystemMessageManager.sendToPlayer(
owner,
category,
formattedMessage
);
}
}
// ==================== BREACH MANAGEMENT ====================
/**
* Record a wall breach: updates CellDataV2 and indices atomically.
*/
public void addBreach(UUID cellId, BlockPos pos) {
CellDataV2 cell = cells.get(cellId);
if (cell == null) return;
cell.addBreach(pos);
wallToCell.remove(pos);
breachedToCell.put(pos.immutable(), cellId);
setDirty();
}
/**
* Repair a wall breach: updates CellDataV2 and indices atomically.
*/
public void repairBreach(UUID cellId, BlockPos pos) {
CellDataV2 cell = cells.get(cellId);
if (cell == null) return;
cell.repairBreach(pos);
breachedToCell.remove(pos);
wallToCell.put(pos.immutable(), cellId);
setDirty();
}
/**
* Get the cell ID for a breached wall position.
*/
@Nullable
public UUID getCellIdAtBreach(BlockPos pos) {
return breachedToCell.get(pos);
}
// ==================== RESERVATIONS ====================
public boolean reserveCell(UUID cellId, UUID kidnapperUUID, long gameTime) {
cleanupExpiredReservations(gameTime);
long expiryTime = gameTime + RESERVATION_TIMEOUT_TICKS;
CellReservation existing = reservations.get(cellId);
if (existing != null) {
if (existing.getKidnapperUUID().equals(kidnapperUUID)) {
reservations.put(
cellId,
new CellReservation(kidnapperUUID, expiryTime)
);
return true;
}
if (!existing.isExpired(gameTime)) {
return false;
}
}
reservations.put(
cellId,
new CellReservation(kidnapperUUID, expiryTime)
);
return true;
}
public boolean consumeReservation(UUID cellId, UUID kidnapperUUID) {
CellReservation reservation = reservations.remove(cellId);
if (reservation == null) return false;
return reservation.getKidnapperUUID().equals(kidnapperUUID);
}
public boolean isReservedByOther(
UUID cellId,
@Nullable UUID kidnapperUUID,
long gameTime
) {
CellReservation reservation = reservations.get(cellId);
if (reservation == null) return false;
if (reservation.isExpired(gameTime)) {
reservations.remove(cellId);
return false;
}
return (
kidnapperUUID == null ||
!reservation.getKidnapperUUID().equals(kidnapperUUID)
);
}
public void cancelReservation(UUID cellId, UUID kidnapperUUID) {
CellReservation reservation = reservations.get(cellId);
if (
reservation != null &&
reservation.getKidnapperUUID().equals(kidnapperUUID)
) {
reservations.remove(cellId);
}
}
private void cleanupExpiredReservations(long gameTime) {
reservations
.entrySet()
.removeIf(entry -> entry.getValue().isExpired(gameTime));
}
// ==================== SPATIAL INDEX ====================
private void addToSpatialIndex(CellDataV2 cell) {
ChunkPos chunkPos = new ChunkPos(cell.getCorePos());
cellsByChunk
.computeIfAbsent(chunkPos, k -> ConcurrentHashMap.newKeySet())
.add(cell.getId());
// Add to camp index if camp-owned
if (
cell.getOwnerType() == CellOwnerType.CAMP &&
cell.getOwnerId() != null
) {
cellsByCamp
.computeIfAbsent(cell.getOwnerId(), k ->
ConcurrentHashMap.newKeySet()
)
.add(cell.getId());
}
}
private void removeFromSpatialIndex(CellDataV2 cell) {
ChunkPos chunkPos = new ChunkPos(cell.getCorePos());
Set<UUID> cellsInChunk = cellsByChunk.get(chunkPos);
if (cellsInChunk != null) {
cellsInChunk.remove(cell.getId());
if (cellsInChunk.isEmpty()) {
cellsByChunk.remove(chunkPos);
}
}
if (
cell.getOwnerType() == CellOwnerType.CAMP &&
cell.getOwnerId() != null
) {
Set<UUID> cellsInCamp = cellsByCamp.get(cell.getOwnerId());
if (cellsInCamp != null) {
cellsInCamp.remove(cell.getId());
if (cellsInCamp.isEmpty()) {
cellsByCamp.remove(cell.getOwnerId());
}
}
}
}
// ==================== INDEX REBUILD ====================
private void rebuildIndices() {
wallToCell.clear();
interiorToCell.clear();
coreToCell.clear();
breachedToCell.clear();
cellsByChunk.clear();
cellsByCamp.clear();
for (CellDataV2 cell : cells.values()) {
coreToCell.put(cell.getCorePos(), cell.getId());
for (BlockPos pos : cell.getWallBlocks()) {
wallToCell.put(pos, cell.getId());
}
for (BlockPos pos : cell.getBreachedPositions()) {
breachedToCell.put(pos, cell.getId());
}
for (BlockPos pos : cell.getInteriorBlocks()) {
interiorToCell.put(pos, cell.getId());
}
addToSpatialIndex(cell);
}
}
// ==================== PERSISTENCE ====================
@Override
public @NotNull CompoundTag save(@NotNull CompoundTag tag) {
ListTag cellList = new ListTag();
for (CellDataV2 cell : cells.values()) {
cellList.add(cell.save());
}
tag.put("cells", cellList);
return tag;
}
public static CellRegistryV2 load(CompoundTag tag) {
CellRegistryV2 registry = new CellRegistryV2();
if (tag.contains("cells")) {
ListTag cellList = tag.getList("cells", Tag.TAG_COMPOUND);
for (int i = 0; i < cellList.size(); i++) {
CellDataV2 cell = CellDataV2.load(cellList.getCompound(i));
if (cell != null) {
registry.cells.put(cell.getId(), cell);
}
}
}
registry.rebuildIndices();
return registry;
}
// ==================== DEBUG ====================
public String toDebugString() {
StringBuilder sb = new StringBuilder();
sb.append("CellRegistryV2:\n");
sb.append(" Total cells: ").append(cells.size()).append("\n");
sb.append(" Wall index: ").append(wallToCell.size()).append("\n");
sb
.append(" Interior index: ")
.append(interiorToCell.size())
.append("\n");
sb.append(" Core index: ").append(coreToCell.size()).append("\n");
for (CellDataV2 cell : cells.values()) {
sb.append(" ").append(cell.toString()).append("\n");
}
return sb.toString();
}
}

View File

@@ -0,0 +1,96 @@
package com.tiedup.remake.cells;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import net.minecraft.core.BlockPos;
import org.jetbrains.annotations.Nullable;
/**
* Server-side manager tracking which players are in selection mode
* (Set Spawn, Set Delivery, Set Disguise) after clicking a Cell Core menu button.
*
* Static map pattern matching ForcedSeatingHandler.
*/
public class CellSelectionManager {
private static final long TIMEOUT_MS = 30 * 1000L;
private static final double MAX_DISTANCE_SQ = 10.0 * 10.0;
private static final ConcurrentHashMap<UUID, SelectionContext> selections =
new ConcurrentHashMap<>();
public static class SelectionContext {
public final SelectionMode mode;
public final BlockPos corePos;
public final UUID cellId;
public final long startTimeMs;
public final BlockPos playerStartPos;
public SelectionContext(
SelectionMode mode,
BlockPos corePos,
UUID cellId,
BlockPos playerStartPos
) {
this.mode = mode;
this.corePos = corePos;
this.cellId = cellId;
this.startTimeMs = System.currentTimeMillis();
this.playerStartPos = playerStartPos;
}
}
public static void startSelection(
UUID playerId,
SelectionMode mode,
BlockPos corePos,
UUID cellId,
BlockPos playerPos
) {
selections.put(
playerId,
new SelectionContext(mode, corePos, cellId, playerPos)
);
}
@Nullable
public static SelectionContext getSelection(UUID playerId) {
return selections.get(playerId);
}
public static void clearSelection(UUID playerId) {
selections.remove(playerId);
}
public static boolean isInSelectionMode(UUID playerId) {
return selections.containsKey(playerId);
}
/**
* Check if selection should be cancelled due to timeout or distance.
*/
public static boolean shouldCancel(UUID playerId, BlockPos currentPos) {
SelectionContext ctx = selections.get(playerId);
if (ctx == null) return false;
// Timeout check
if (System.currentTimeMillis() - ctx.startTimeMs > TIMEOUT_MS) {
return true;
}
// Distance check (from core, not player start)
if (ctx.corePos.distSqr(currentPos) > MAX_DISTANCE_SQ) {
return true;
}
return false;
}
/**
* Called on player disconnect to prevent memory leaks.
*/
public static void cleanup(UUID playerId) {
selections.remove(playerId);
}
}

View File

@@ -0,0 +1,33 @@
package com.tiedup.remake.cells;
/**
* State of a Cell System V2 cell.
*
* INTACT — all walls present, fully operational.
* BREACHED — some walls broken, prisoners may escape.
* COMPROMISED — Core destroyed or too many walls broken; cell is non-functional.
*/
public enum CellState {
INTACT("intact"),
BREACHED("breached"),
COMPROMISED("compromised");
private final String serializedName;
CellState(String serializedName) {
this.serializedName = serializedName;
}
public String getSerializedName() {
return serializedName;
}
public static CellState fromString(String name) {
for (CellState state : values()) {
if (state.serializedName.equalsIgnoreCase(name)) {
return state;
}
}
return INTACT;
}
}

View File

@@ -0,0 +1,641 @@
package com.tiedup.remake.cells;
import com.tiedup.remake.core.TiedUpMod;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import net.minecraft.core.BlockPos;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.ListTag;
import net.minecraft.nbt.NbtUtils;
import net.minecraft.nbt.Tag;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.Container;
import net.minecraft.world.entity.player.Inventory;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.entity.ChestBlockEntity;
import net.minecraft.world.level.saveddata.SavedData;
import org.jetbrains.annotations.Nullable;
/**
* Phase 2: SavedData registry for confiscated player inventories.
*
* When a player is imprisoned:
* 1. Their inventory is saved to NBT
* 2. Items are transferred to a LOOT chest in the cell
* 3. Player's inventory is cleared
* 4. Data is persisted for recovery after server restart
*
* Recovery options:
* - Player finds and opens the chest manually
* - Player escapes and returns to chest
* - Admin command /tiedup returnstuff <player>
*/
public class ConfiscatedInventoryRegistry extends SavedData {
private static final String DATA_NAME =
TiedUpMod.MOD_ID + "_confiscated_inventories";
/**
* Map of prisoner UUID to their confiscated inventory data
*/
private final Map<UUID, ConfiscatedData> confiscatedInventories =
new HashMap<>();
/**
* Data class for a single confiscated inventory
*/
public static class ConfiscatedData {
public final UUID prisonerId;
public final CompoundTag inventoryNbt;
public final BlockPos chestPos;
public final UUID cellId;
public final long confiscatedTime;
public ConfiscatedData(
UUID prisonerId,
CompoundTag inventoryNbt,
BlockPos chestPos,
@Nullable UUID cellId,
long confiscatedTime
) {
this.prisonerId = prisonerId;
this.inventoryNbt = inventoryNbt;
this.chestPos = chestPos;
this.cellId = cellId;
this.confiscatedTime = confiscatedTime;
}
public CompoundTag save() {
CompoundTag tag = new CompoundTag();
tag.putUUID("prisonerId", prisonerId);
tag.put("inventory", inventoryNbt);
tag.put("chestPos", NbtUtils.writeBlockPos(chestPos));
if (cellId != null) {
tag.putUUID("cellId", cellId);
}
tag.putLong("time", confiscatedTime);
return tag;
}
public static ConfiscatedData load(CompoundTag tag) {
UUID prisonerId = tag.getUUID("prisonerId");
CompoundTag inventoryNbt = tag.getCompound("inventory");
BlockPos chestPos = NbtUtils.readBlockPos(
tag.getCompound("chestPos")
);
UUID cellId = tag.contains("cellId") ? tag.getUUID("cellId") : null;
long time = tag.getLong("time");
return new ConfiscatedData(
prisonerId,
inventoryNbt,
chestPos,
cellId,
time
);
}
}
public ConfiscatedInventoryRegistry() {}
// ==================== STATIC ACCESSORS ====================
/**
* Get or create the registry for a server level.
*/
public static ConfiscatedInventoryRegistry get(ServerLevel level) {
return level
.getDataStorage()
.computeIfAbsent(
ConfiscatedInventoryRegistry::load,
ConfiscatedInventoryRegistry::new,
DATA_NAME
);
}
// ==================== CONFISCATION METHODS ====================
/**
* Confiscate a player's inventory.
*
* @param player The player whose inventory to confiscate
* @param chestPos The position of the LOOT chest
* @param cellId The cell ID (optional)
* @return true if confiscation was successful
*/
public boolean confiscate(
ServerPlayer player,
BlockPos chestPos,
@Nullable UUID cellId
) {
Inventory inventory = player.getInventory();
// CRITICAL FIX: Transaction safety - save record BEFORE any state changes
// This ensures that if the server crashes, we have a backup to restore from
// Save inventory to NBT (backup in case of issues)
CompoundTag inventoryNbt = savePlayerInventory(player);
// Create and persist record FIRST (before modifying game state)
ConfiscatedData data = new ConfiscatedData(
player.getUUID(),
inventoryNbt,
chestPos,
cellId,
System.currentTimeMillis()
);
confiscatedInventories.put(player.getUUID(), data);
setDirty(); // Persist immediately before state changes
// Now attempt transfer to chest
boolean transferred = transferToChest(
player.serverLevel(),
chestPos,
inventory
);
if (!transferred) {
// Transfer failed - rollback: remove record and DO NOT clear inventory
confiscatedInventories.remove(player.getUUID());
setDirty(); // Persist the rollback
TiedUpMod.LOGGER.error(
"[ConfiscatedInventoryRegistry] Failed to transfer items for {} - items NOT confiscated, record rolled back",
player.getName().getString()
);
return false;
}
// Transfer succeeded - now safe to clear inventory
inventory.clearContent();
TiedUpMod.LOGGER.info(
"[ConfiscatedInventoryRegistry] Confiscated inventory from {} (chest: {}, cell: {})",
player.getName().getString(),
chestPos.toShortString(),
cellId != null ? cellId.toString().substring(0, 8) : "none"
);
// Final persist to save cleared inventory state
setDirty();
return true;
}
/**
* Dump a player's inventory to a chest WITHOUT creating a backup record.
* Used for daily labor returns - items gathered during work are transferred to camp storage.
* Does NOT clear the player's inventory - caller should clear labor tools separately.
*
* @param player The player whose inventory to dump
* @param chestPos The position of the chest
* @return true if transfer was successful
*/
public boolean dumpInventoryToChest(
ServerPlayer player,
BlockPos chestPos
) {
Inventory inventory = player.getInventory();
// Transfer items to chest
boolean transferred = transferToChest(
player.serverLevel(),
chestPos,
inventory
);
if (!transferred) {
TiedUpMod.LOGGER.warn(
"[ConfiscatedInventoryRegistry] Failed to dump labor inventory for {} - chest issue",
player.getName().getString()
);
return false;
}
// Clear player inventory (items are now in chest)
inventory.clearContent();
TiedUpMod.LOGGER.debug(
"[ConfiscatedInventoryRegistry] Dumped labor inventory from {} to chest at {}",
player.getName().getString(),
chestPos.toShortString()
);
return true;
}
/**
* Deposits items across multiple LOOT chests with smart rotation.
* Distributes items evenly to prevent single chest from filling too quickly.
*
* @param items List of items to deposit
* @param chestPositions List of chest positions to use (in priority order)
* @param level Server level
* @return Number of items successfully deposited (remainder dropped)
*/
public int depositItemsInChests(
List<ItemStack> items,
List<BlockPos> chestPositions,
ServerLevel level
) {
if (items.isEmpty() || chestPositions.isEmpty()) {
return 0;
}
int deposited = 0;
List<ItemStack> overflow = new ArrayList<>();
// Try to deposit each item
for (ItemStack stack : items) {
if (stack.isEmpty()) continue;
boolean placed = false;
// Try all chests in order
for (BlockPos chestPos : chestPositions) {
BlockEntity be = level.getBlockEntity(chestPos);
if (!(be instanceof ChestBlockEntity chest)) continue;
// Try to stack with existing items first
for (int i = 0; i < chest.getContainerSize(); i++) {
ItemStack slot = chest.getItem(i);
// Stack with existing
if (
!slot.isEmpty() &&
ItemStack.isSameItemSameTags(slot, stack) &&
slot.getCount() < slot.getMaxStackSize()
) {
int spaceInSlot =
slot.getMaxStackSize() - slot.getCount();
int toAdd = Math.min(spaceInSlot, stack.getCount());
slot.grow(toAdd);
chest.setChanged();
stack.shrink(toAdd);
deposited += toAdd;
if (stack.isEmpty()) {
placed = true;
break;
}
}
}
if (placed) break;
// Try to place in empty slot
if (!stack.isEmpty()) {
for (int i = 0; i < chest.getContainerSize(); i++) {
if (chest.getItem(i).isEmpty()) {
chest.setItem(i, stack.copy());
chest.setChanged();
deposited += stack.getCount();
placed = true;
break;
}
}
}
if (placed) break;
}
// If not placed, add to overflow
if (!placed && !stack.isEmpty()) {
overflow.add(stack);
}
}
// Drop overflow items at first chest location
if (!overflow.isEmpty() && !chestPositions.isEmpty()) {
BlockPos dropPos = chestPositions.get(0);
for (ItemStack stack : overflow) {
net.minecraft.world.entity.item.ItemEntity itemEntity =
new net.minecraft.world.entity.item.ItemEntity(
level,
dropPos.getX() + 0.5,
dropPos.getY() + 1.0,
dropPos.getZ() + 0.5,
stack
);
level.addFreshEntity(itemEntity);
}
TiedUpMod.LOGGER.warn(
"[ConfiscatedInventoryRegistry] {} items overflowed - dropped at {}",
overflow.stream().mapToInt(ItemStack::getCount).sum(),
dropPos.toShortString()
);
}
return deposited;
}
/**
* Save player inventory to NBT.
*/
private CompoundTag savePlayerInventory(ServerPlayer player) {
CompoundTag tag = new CompoundTag();
tag.put("Items", player.getInventory().save(new ListTag()));
return tag;
}
/**
* Transfer inventory contents to a chest.
* Handles ALL inventory slots:
* - Slots 0-35: Main inventory (hotbar 0-8, backpack 9-35)
* - Slots 36-39: Armor (boots, leggings, chestplate, helmet)
* - Slot 40: Offhand
*/
private boolean transferToChest(
ServerLevel level,
BlockPos chestPos,
Inventory inventory
) {
// Find an existing chest near the LOOT marker position
BlockPos actualChestPos = findExistingChestNear(level, chestPos);
if (actualChestPos == null) {
TiedUpMod.LOGGER.warn(
"[ConfiscatedInventoryRegistry] No existing chest found near {} - structure may be damaged",
chestPos.toShortString()
);
return false;
}
BlockEntity be = level.getBlockEntity(actualChestPos);
if (!(be instanceof Container chest)) {
TiedUpMod.LOGGER.warn(
"[ConfiscatedInventoryRegistry] Block at {} is not a container",
actualChestPos.toShortString()
);
return false;
}
// Transfer ALL items including armor and offhand
int mainItems = 0;
int armorItems = 0;
int offhandItems = 0;
int droppedItems = 0;
// getContainerSize() returns 41: 36 main + 4 armor + 1 offhand
for (int i = 0; i < inventory.getContainerSize(); i++) {
ItemStack stack = inventory.getItem(i);
if (!stack.isEmpty()) {
ItemStack remaining = addToContainer(chest, stack.copy());
if (remaining.isEmpty()) {
// Track which slot type was transferred
if (i < 36) {
mainItems++;
} else if (i < 40) {
armorItems++;
} else {
offhandItems++;
}
} else {
// Couldn't fit all items - drop them on the ground near the chest
// This prevents permanent item loss
net.minecraft.world.entity.item.ItemEntity itemEntity =
new net.minecraft.world.entity.item.ItemEntity(
level,
actualChestPos.getX() + 0.5,
actualChestPos.getY() + 1.0,
actualChestPos.getZ() + 0.5,
remaining
);
// Add slight random velocity to spread items
itemEntity.setDeltaMovement(
(level.random.nextDouble() - 0.5) * 0.2,
0.2,
(level.random.nextDouble() - 0.5) * 0.2
);
level.addFreshEntity(itemEntity);
droppedItems += remaining.getCount();
}
}
}
if (droppedItems > 0) {
TiedUpMod.LOGGER.info(
"[ConfiscatedInventoryRegistry] Dropped {} overflow items at {}",
droppedItems,
actualChestPos.toShortString()
);
}
int totalTransferred = mainItems + armorItems + offhandItems;
TiedUpMod.LOGGER.info(
"[ConfiscatedInventoryRegistry] Confiscated {} items (inventory: {}, armor: {}, offhand: {})",
totalTransferred,
mainItems,
armorItems,
offhandItems
);
return true;
}
/**
* Add an item stack to a container, returning any remainder.
*/
private ItemStack addToContainer(Container container, ItemStack stack) {
ItemStack remaining = stack.copy();
for (
int i = 0;
i < container.getContainerSize() && !remaining.isEmpty();
i++
) {
ItemStack slotStack = container.getItem(i);
if (slotStack.isEmpty()) {
container.setItem(i, remaining.copy());
remaining = ItemStack.EMPTY;
} else if (ItemStack.isSameItemSameTags(slotStack, remaining)) {
int space = slotStack.getMaxStackSize() - slotStack.getCount();
int toTransfer = Math.min(space, remaining.getCount());
if (toTransfer > 0) {
slotStack.grow(toTransfer);
remaining.shrink(toTransfer);
}
}
}
container.setChanged();
return remaining;
}
/**
* Find an existing chest near the given position (typically a LOOT marker).
* The LOOT marker is placed ABOVE the physical chest in the structure,
* so we check below first, then at the position, then in a small radius.
*
* Does NOT spawn new chests - structures must have chests placed by their templates.
*
* @return The position of an existing chest, or null if none found
*/
@Nullable
private static BlockPos findExistingChestNear(
ServerLevel level,
BlockPos pos
) {
// Check below the marker (chest is usually under the LOOT marker)
BlockPos below = pos.below();
if (level.getBlockEntity(below) instanceof ChestBlockEntity) {
return below;
}
// Check at the marker position itself
if (level.getBlockEntity(pos) instanceof ChestBlockEntity) {
return pos;
}
// Search in a small radius (3 blocks)
for (int radius = 1; radius <= 3; radius++) {
for (int dx = -radius; dx <= radius; dx++) {
for (int dy = -2; dy <= 1; dy++) {
for (int dz = -radius; dz <= radius; dz++) {
if (dx == 0 && dy == 0 && dz == 0) continue;
BlockPos testPos = pos.offset(dx, dy, dz);
if (
level.getBlockEntity(testPos) instanceof
ChestBlockEntity
) {
TiedUpMod.LOGGER.debug(
"[ConfiscatedInventoryRegistry] Found existing chest at {} (offset from marker {})",
testPos.toShortString(),
pos.toShortString()
);
return testPos;
}
}
}
}
}
TiedUpMod.LOGGER.warn(
"[ConfiscatedInventoryRegistry] No existing chest found near marker at {}",
pos.toShortString()
);
return null;
}
// ==================== RECOVERY METHODS ====================
/**
* Check if a player has confiscated inventory.
*/
public boolean hasConfiscatedInventory(UUID playerId) {
return confiscatedInventories.containsKey(playerId);
}
/**
* Get confiscated data for a player.
*/
@Nullable
public ConfiscatedData getConfiscatedData(UUID playerId) {
return confiscatedInventories.get(playerId);
}
/**
* Get the chest position for a player's confiscated inventory.
*/
@Nullable
public BlockPos getChestPosition(UUID playerId) {
ConfiscatedData data = confiscatedInventories.get(playerId);
return data != null ? data.chestPos : null;
}
/**
* Restore a player's confiscated inventory from the NBT backup.
* This gives items directly to the player, bypassing the chest.
*
* @param player The player to restore inventory to
* @return true if restoration was successful, false if no confiscated data found
*/
public boolean restoreInventory(ServerPlayer player) {
ConfiscatedData data = confiscatedInventories.get(player.getUUID());
if (data == null) {
TiedUpMod.LOGGER.debug(
"[ConfiscatedInventoryRegistry] No confiscated inventory for {}",
player.getName().getString()
);
return false;
}
// Load inventory from NBT backup
if (data.inventoryNbt.contains("Items")) {
ListTag items = data.inventoryNbt.getList(
"Items",
Tag.TAG_COMPOUND
);
player.getInventory().load(items);
TiedUpMod.LOGGER.info(
"[ConfiscatedInventoryRegistry] Restored {} inventory slots to {}",
items.size(),
player.getName().getString()
);
}
// Remove the confiscation record
confiscatedInventories.remove(player.getUUID());
setDirty();
return true;
}
/**
* Remove the confiscation record for a player without restoring items.
* Used when the player has already retrieved items from the chest manually.
*
* @param playerId The player's UUID
*/
public void clearConfiscationRecord(UUID playerId) {
if (confiscatedInventories.remove(playerId) != null) {
TiedUpMod.LOGGER.debug(
"[ConfiscatedInventoryRegistry] Cleared confiscation record for {}",
playerId.toString().substring(0, 8)
);
setDirty();
}
}
// ==================== SERIALIZATION ====================
@Override
public CompoundTag save(CompoundTag tag) {
ListTag list = new ListTag();
for (ConfiscatedData data : confiscatedInventories.values()) {
list.add(data.save());
}
tag.put("confiscated", list);
return tag;
}
public static ConfiscatedInventoryRegistry load(CompoundTag tag) {
ConfiscatedInventoryRegistry registry =
new ConfiscatedInventoryRegistry();
if (tag.contains("confiscated")) {
ListTag list = tag.getList("confiscated", Tag.TAG_COMPOUND);
for (int i = 0; i < list.size(); i++) {
ConfiscatedData data = ConfiscatedData.load(
list.getCompound(i)
);
registry.confiscatedInventories.put(data.prisonerId, data);
}
}
TiedUpMod.LOGGER.info(
"[ConfiscatedInventoryRegistry] Loaded {} confiscated inventory records",
registry.confiscatedInventories.size()
);
return registry;
}
}

View File

@@ -0,0 +1,407 @@
package com.tiedup.remake.cells;
import java.util.*;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.state.BlockState;
/**
* BFS flood-fill algorithm for detecting enclosed rooms around a Cell Core.
*
* Scans outward from air neighbors of the Core block, treating solid blocks
* (including the Core itself) as walls. Picks the smallest successful fill
* as the cell interior (most likely the room, not the hallway).
*/
public final class FloodFillAlgorithm {
static final int MAX_VOLUME = 1200;
static final int MIN_VOLUME = 2;
static final int MAX_X = 12;
static final int MAX_Y = 8;
static final int MAX_Z = 12;
private FloodFillAlgorithm() {}
/**
* Try flood-fill from each air neighbor of the Core position.
* Pick the smallest successful fill (= most likely the cell, not the hallway).
* If none succeed, return a failure result.
*/
public static FloodFillResult tryFill(Level level, BlockPos corePos) {
Set<BlockPos> bestInterior = null;
Direction bestDirection = null;
for (Direction dir : Direction.values()) {
BlockPos neighbor = corePos.relative(dir);
BlockState neighborState = level.getBlockState(neighbor);
if (!isPassable(neighborState)) {
continue;
}
Set<BlockPos> interior = bfs(level, neighbor, corePos);
if (interior == null) {
// Overflow or out of bounds — this direction opens to the outside
continue;
}
if (interior.size() < MIN_VOLUME) {
continue;
}
if (bestInterior == null || interior.size() < bestInterior.size()) {
bestInterior = interior;
bestDirection = dir;
}
}
if (bestInterior == null) {
// No direction produced a valid fill — check why
// Try again to determine the most helpful error message
boolean anyAir = false;
boolean tooLarge = false;
boolean tooSmall = false;
boolean outOfBounds = false;
for (Direction dir : Direction.values()) {
BlockPos neighbor = corePos.relative(dir);
BlockState neighborState = level.getBlockState(neighbor);
if (!isPassable(neighborState)) continue;
anyAir = true;
Set<BlockPos> result = bfsDiagnostic(level, neighbor, corePos);
if (result == null) {
// Overflowed — could be not enclosed or too large
tooLarge = true;
} else if (result.size() < MIN_VOLUME) {
tooSmall = true;
} else {
outOfBounds = true;
}
}
if (!anyAir) {
return FloodFillResult.failure(
"msg.tiedup.cell_core.not_enclosed"
);
} else if (tooLarge) {
// Could be open to outside or genuinely too large
return FloodFillResult.failure(
"msg.tiedup.cell_core.not_enclosed"
);
} else if (outOfBounds) {
return FloodFillResult.failure(
"msg.tiedup.cell_core.out_of_bounds"
);
} else if (tooSmall) {
return FloodFillResult.failure(
"msg.tiedup.cell_core.too_small"
);
} else {
return FloodFillResult.failure(
"msg.tiedup.cell_core.not_enclosed"
);
}
}
// Build walls set
Set<BlockPos> walls = findWalls(level, bestInterior, corePos);
// Detect features
List<BlockPos> beds = new ArrayList<>();
List<BlockPos> petBeds = new ArrayList<>();
List<BlockPos> anchors = new ArrayList<>();
List<BlockPos> doors = new ArrayList<>();
List<BlockPos> linkedRedstone = new ArrayList<>();
detectFeatures(
level,
bestInterior,
walls,
beds,
petBeds,
anchors,
doors,
linkedRedstone
);
return FloodFillResult.success(
bestInterior,
walls,
bestDirection,
beds,
petBeds,
anchors,
doors,
linkedRedstone
);
}
/**
* BFS from start position, treating corePos and solid blocks as walls.
*
* @return The set of interior (passable) positions, or null if the fill
* overflowed MAX_VOLUME or exceeded MAX bounds.
*/
private static Set<BlockPos> bfs(
Level level,
BlockPos start,
BlockPos corePos
) {
Set<BlockPos> visited = new HashSet<>();
Queue<BlockPos> queue = new ArrayDeque<>();
visited.add(start);
queue.add(start);
int minX = start.getX(),
maxX = start.getX();
int minY = start.getY(),
maxY = start.getY();
int minZ = start.getZ(),
maxZ = start.getZ();
while (!queue.isEmpty()) {
BlockPos current = queue.poll();
for (Direction dir : Direction.values()) {
BlockPos next = current.relative(dir);
if (next.equals(corePos)) {
// Core is always treated as wall
continue;
}
if (visited.contains(next)) {
continue;
}
// Treat unloaded chunks as walls to avoid synchronous chunk loading
if (!level.isLoaded(next)) {
continue;
}
BlockState state = level.getBlockState(next);
if (!isPassable(state)) {
// Solid block = wall, don't expand
continue;
}
visited.add(next);
// Check volume
if (visited.size() > MAX_VOLUME) {
return null; // Too large or not enclosed
}
// Update bounds
minX = Math.min(minX, next.getX());
maxX = Math.max(maxX, next.getX());
minY = Math.min(minY, next.getY());
maxY = Math.max(maxY, next.getY());
minZ = Math.min(minZ, next.getZ());
maxZ = Math.max(maxZ, next.getZ());
// Check dimensional bounds
if (
(maxX - minX + 1) > MAX_X ||
(maxY - minY + 1) > MAX_Y ||
(maxZ - minZ + 1) > MAX_Z
) {
return null; // Exceeds max dimensions
}
queue.add(next);
}
}
return visited;
}
/**
* Diagnostic BFS: same as bfs() but returns the set even on bounds overflow
* (returns null only on volume overflow). Used to determine error messages.
*/
private static Set<BlockPos> bfsDiagnostic(
Level level,
BlockPos start,
BlockPos corePos
) {
Set<BlockPos> visited = new HashSet<>();
Queue<BlockPos> queue = new ArrayDeque<>();
visited.add(start);
queue.add(start);
while (!queue.isEmpty()) {
BlockPos current = queue.poll();
for (Direction dir : Direction.values()) {
BlockPos next = current.relative(dir);
if (next.equals(corePos) || visited.contains(next)) {
continue;
}
// Treat unloaded chunks as walls to avoid synchronous chunk loading
if (!level.isLoaded(next)) {
continue;
}
BlockState state = level.getBlockState(next);
if (!isPassable(state)) {
continue;
}
visited.add(next);
if (visited.size() > MAX_VOLUME) {
return null;
}
queue.add(next);
}
}
return visited;
}
/**
* Find all solid blocks adjacent to the interior set (the walls of the cell).
* The Core block itself is always included as a wall.
*/
private static Set<BlockPos> findWalls(
Level level,
Set<BlockPos> interior,
BlockPos corePos
) {
Set<BlockPos> walls = new HashSet<>();
walls.add(corePos);
for (BlockPos pos : interior) {
for (Direction dir : Direction.values()) {
BlockPos neighbor = pos.relative(dir);
if (!interior.contains(neighbor) && !neighbor.equals(corePos)) {
// This is a solid boundary block
walls.add(neighbor);
}
}
}
return walls;
}
/**
* Scan interior and wall blocks to detect notable features.
*/
private static void detectFeatures(
Level level,
Set<BlockPos> interior,
Set<BlockPos> walls,
List<BlockPos> beds,
List<BlockPos> petBeds,
List<BlockPos> anchors,
List<BlockPos> doors,
List<BlockPos> linkedRedstone
) {
// Scan interior for beds and pet beds
for (BlockPos pos : interior) {
BlockState state = level.getBlockState(pos);
Block block = state.getBlock();
if (block instanceof BedBlock) {
// Only count the HEAD part to avoid double-counting (beds are 2 blocks)
if (
state.getValue(BedBlock.PART) ==
net.minecraft.world.level.block.state.properties.BedPart.HEAD
) {
beds.add(pos.immutable());
}
}
// Check for mod's pet bed block
if (block instanceof com.tiedup.remake.v2.blocks.PetBedBlock) {
petBeds.add(pos.immutable());
}
}
// Scan walls for doors, redstone components, and anchors
for (BlockPos pos : walls) {
BlockState state = level.getBlockState(pos);
Block block = state.getBlock();
// Doors, trapdoors, fence gates
if (block instanceof DoorBlock) {
// Only count the lower half to avoid double-counting
if (
state.getValue(DoorBlock.HALF) ==
net.minecraft.world.level.block.state.properties.DoubleBlockHalf.LOWER
) {
doors.add(pos.immutable());
}
} else if (
block instanceof TrapDoorBlock ||
block instanceof FenceGateBlock
) {
doors.add(pos.immutable());
}
// Chain blocks as anchors
if (block instanceof ChainBlock) {
anchors.add(pos.immutable());
}
// Buttons and levers as linked redstone
if (block instanceof ButtonBlock || block instanceof LeverBlock) {
linkedRedstone.add(pos.immutable());
}
}
// Also check for buttons/levers on the interior side adjacent to walls
for (BlockPos pos : interior) {
BlockState state = level.getBlockState(pos);
Block block = state.getBlock();
if (block instanceof ButtonBlock || block instanceof LeverBlock) {
linkedRedstone.add(pos.immutable());
}
}
}
/**
* Determine if a block state is passable for flood-fill purposes.
*
* Air and non-solid blocks (torches, carpets, flowers, signs, etc.) are passable.
* Closed doors block the fill (treated as walls). Open doors let fill through.
* Glass, bars, fences are solid → treated as wall.
*/
private static boolean isPassable(BlockState state) {
if (state.isAir()) {
return true;
}
Block block = state.getBlock();
// Doors are always treated as walls for flood-fill (detected as features separately).
// This prevents the fill from leaking through open doors.
if (
block instanceof DoorBlock ||
block instanceof TrapDoorBlock ||
block instanceof FenceGateBlock
) {
return false;
}
// Beds are interior furniture, not walls.
// BedBlock.isSolid() returns true in 1.20.1 which would misclassify them as walls,
// preventing detectFeatures() from finding them (it only scans interior for beds).
if (block instanceof BedBlock) {
return true;
}
// Non-solid decorative blocks are passable
// This covers torches, carpets, flowers, signs, pressure plates, etc.
return !state.isSolid();
}
}

View File

@@ -0,0 +1,140 @@
package com.tiedup.remake.cells;
import java.util.*;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import org.jetbrains.annotations.Nullable;
/**
* Immutable result container returned by the flood-fill algorithm.
*
* Either a success (with geometry and detected features) or a failure (with an error translation key).
*/
public class FloodFillResult {
private final boolean success;
@Nullable
private final String errorKey;
// Geometry
private final Set<BlockPos> interior;
private final Set<BlockPos> walls;
@Nullable
private final Direction interiorFace;
// Auto-detected features
private final List<BlockPos> beds;
private final List<BlockPos> petBeds;
private final List<BlockPos> anchors;
private final List<BlockPos> doors;
private final List<BlockPos> linkedRedstone;
private FloodFillResult(
boolean success,
@Nullable String errorKey,
Set<BlockPos> interior,
Set<BlockPos> walls,
@Nullable Direction interiorFace,
List<BlockPos> beds,
List<BlockPos> petBeds,
List<BlockPos> anchors,
List<BlockPos> doors,
List<BlockPos> linkedRedstone
) {
this.success = success;
this.errorKey = errorKey;
this.interior = Collections.unmodifiableSet(interior);
this.walls = Collections.unmodifiableSet(walls);
this.interiorFace = interiorFace;
this.beds = Collections.unmodifiableList(beds);
this.petBeds = Collections.unmodifiableList(petBeds);
this.anchors = Collections.unmodifiableList(anchors);
this.doors = Collections.unmodifiableList(doors);
this.linkedRedstone = Collections.unmodifiableList(linkedRedstone);
}
public static FloodFillResult success(
Set<BlockPos> interior,
Set<BlockPos> walls,
Direction interiorFace,
List<BlockPos> beds,
List<BlockPos> petBeds,
List<BlockPos> anchors,
List<BlockPos> doors,
List<BlockPos> linkedRedstone
) {
return new FloodFillResult(
true,
null,
interior,
walls,
interiorFace,
beds,
petBeds,
anchors,
doors,
linkedRedstone
);
}
public static FloodFillResult failure(String errorKey) {
return new FloodFillResult(
false,
errorKey,
Collections.emptySet(),
Collections.emptySet(),
null,
Collections.emptyList(),
Collections.emptyList(),
Collections.emptyList(),
Collections.emptyList(),
Collections.emptyList()
);
}
// --- Getters ---
public boolean isSuccess() {
return success;
}
@Nullable
public String getErrorKey() {
return errorKey;
}
public Set<BlockPos> getInterior() {
return interior;
}
public Set<BlockPos> getWalls() {
return walls;
}
@Nullable
public Direction getInteriorFace() {
return interiorFace;
}
public List<BlockPos> getBeds() {
return beds;
}
public List<BlockPos> getPetBeds() {
return petBeds;
}
public List<BlockPos> getAnchors() {
return anchors;
}
public List<BlockPos> getDoors() {
return doors;
}
public List<BlockPos> getLinkedRedstone() {
return linkedRedstone;
}
}

View File

@@ -0,0 +1,161 @@
package com.tiedup.remake.cells;
/**
* Enum defining the types of markers used in the cell system.
*
* Phase: Kidnapper Revamp - Cell System
*
* Markers are invisible points placed by structure builders to define
* functional areas within kidnapper hideouts.
*/
public enum MarkerType {
// ==================== CELL MARKERS (V1 legacy — kept for retrocompat) ====================
/** @deprecated V1 cell marker. Kept for save retrocompat and V1→V2 migration. Use Cell Core flood-fill instead. */
@Deprecated
WALL("wall", true),
/** @deprecated V1 cell marker. Kept for save retrocompat and V1→V2 migration. Use Cell Core flood-fill instead. */
@Deprecated
ANCHOR("anchor", true),
/** @deprecated V1 cell marker. Kept for save retrocompat and V1→V2 migration. Use Cell Core flood-fill instead. */
@Deprecated
BED("bed", true),
/** @deprecated V1 cell marker. Kept for save retrocompat and V1→V2 migration. Use Cell Core flood-fill instead. */
@Deprecated
DOOR("door", true),
/** @deprecated V1 cell marker. Kept for save retrocompat and V1→V2 migration. Use Cell Core flood-fill instead. */
@Deprecated
DELIVERY("delivery", true),
// ==================== STRUCTURE MARKERS (Admin Wand) ====================
/**
* Entrance marker - Main entry point to the structure.
* Used for AI pathfinding and player release point.
*/
ENTRANCE("entrance", false),
/**
* Patrol marker - Waypoint for kidnapper AI patrol routes.
* Guards will walk between these points.
*/
PATROL("patrol", false),
/**
* Loot marker - Position for loot chests in structures.
* Used for confiscated inventory storage.
*/
LOOT("loot", false),
/**
* Spawner marker - Position for kidnapper spawns.
* Kidnappers respawn at these points.
*/
SPAWNER("spawner", false),
/**
* Trader spawn marker - Position for SlaveTrader spawn.
* Only spawns once when structure generates.
*/
TRADER_SPAWN("trader_spawn", false),
/**
* Maid spawn marker - Position for Maid spawn.
* Spawns linked to the nearest trader.
*/
MAID_SPAWN("maid_spawn", false),
/**
* Merchant spawn marker - Position for Merchant spawn.
* Spawns a merchant NPC that can trade/buy items.
*/
MERCHANT_SPAWN("merchant_spawn", false);
private final String serializedName;
private final boolean cellMarker;
MarkerType(String serializedName, boolean cellMarker) {
this.serializedName = serializedName;
this.cellMarker = cellMarker;
}
/**
* Get the serialized name for NBT/network storage.
*/
public String getSerializedName() {
return serializedName;
}
/**
* Check if this is a cell-level marker (vs structure-level).
*/
public boolean isCellMarker() {
return cellMarker;
}
/**
* Check if this is a structure-level marker.
*/
public boolean isStructureMarker() {
return !cellMarker;
}
/**
* Check if this marker type should be linked to a cell's positions.
* This includes WALL, ANCHOR, BED, DOOR - positions that define cell structure.
*/
public boolean isLinkedPosition() {
return cellMarker;
}
/**
* Parse a MarkerType from its serialized name.
*
* @param name The serialized name
* @return The MarkerType, or WALL as default
*/
public static MarkerType fromString(String name) {
for (MarkerType type : values()) {
if (type.serializedName.equalsIgnoreCase(name)) {
return type;
}
}
return ENTRANCE;
}
/**
* Get the next STRUCTURE marker type (for Admin Wand).
* Cycles: ENTRANCE -> PATROL -> LOOT -> SPAWNER -> TRADER_SPAWN -> MAID_SPAWN -> MERCHANT_SPAWN -> ENTRANCE
*/
public MarkerType nextStructureType() {
return switch (this) {
case ENTRANCE -> PATROL;
case PATROL -> LOOT;
case LOOT -> SPAWNER;
case SPAWNER -> TRADER_SPAWN;
case TRADER_SPAWN -> MAID_SPAWN;
case MAID_SPAWN -> MERCHANT_SPAWN;
case MERCHANT_SPAWN -> ENTRANCE;
default -> ENTRANCE;
};
}
/**
* Get all structure marker types.
*/
public static MarkerType[] structureTypes() {
return new MarkerType[] {
ENTRANCE,
PATROL,
LOOT,
SPAWNER,
TRADER_SPAWN,
MAID_SPAWN,
MERCHANT_SPAWN,
};
}
}

View File

@@ -0,0 +1,12 @@
package com.tiedup.remake.cells;
/**
* Selection modes for the Cell Core right-click menu.
* When a player clicks "Set Spawn", "Set Delivery", or "Set Disguise",
* they enter a selection mode where their next block click is captured.
*/
public enum SelectionMode {
SET_SPAWN,
SET_DELIVERY,
SET_DISGUISE,
}

View File

@@ -0,0 +1,155 @@
package com.tiedup.remake.client;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.blaze3d.vertex.VertexConsumer;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.items.GenericBind;
import com.tiedup.remake.items.base.BindVariant;
import com.tiedup.remake.state.PlayerBindState;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
import net.minecraft.client.Minecraft;
import net.minecraft.client.model.PlayerModel;
import net.minecraft.client.model.geom.ModelPart;
import net.minecraft.client.player.AbstractClientPlayer;
import net.minecraft.client.renderer.MultiBufferSource;
import net.minecraft.client.renderer.RenderType;
import net.minecraft.client.renderer.entity.player.PlayerRenderer;
import net.minecraft.client.renderer.texture.OverlayTexture;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.entity.HumanoidArm;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import net.minecraftforge.client.event.RenderArmEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
/**
* Renders mittens on the player's arms in first-person view.
*
* Uses RenderArmEvent which fires specifically when a player's arm
* is being rendered in first person. This is more targeted than RenderHandEvent.
*
* @see <a href="https://nekoyue.github.io/ForgeJavaDocs-NG/javadoc/1.18.2/net/minecraftforge/client/event/RenderArmEvent.html">RenderArmEvent Documentation</a>
*/
@OnlyIn(Dist.CLIENT)
@Mod.EventBusSubscriber(
modid = TiedUpMod.MOD_ID,
bus = Mod.EventBusSubscriber.Bus.FORGE,
value = Dist.CLIENT
)
public class FirstPersonMittensRenderer {
private static final ResourceLocation MITTENS_TEXTURE =
ResourceLocation.fromNamespaceAndPath(
TiedUpMod.MOD_ID,
"textures/models/bondage/mittens/mittens.png"
);
/**
* Render mittens overlay on the player's arm in first-person view.
*
* This event fires after the arm is set up for rendering but we can add
* our own rendering on top of it.
*/
@SubscribeEvent
public static void onRenderArm(RenderArmEvent event) {
AbstractClientPlayer player = event.getPlayer();
// Get player's bind state
PlayerBindState state = PlayerBindState.getInstance(player);
if (state == null) return;
// If tied up, arms are hidden by FirstPersonHandHideHandler - don't render mittens
if (state.isTiedUp()) return;
// Check if player has mittens
if (!state.hasMittens()) return;
// Hide mittens when player is in a wrap or latex sack (hands are covered)
if (isBindHidingMittens(player)) return;
// Render mittens on this arm
renderMittensOnArm(event);
}
/**
* Render the mittens overlay on the arm.
*/
private static void renderMittensOnArm(RenderArmEvent event) {
PoseStack poseStack = event.getPoseStack();
MultiBufferSource buffer = event.getMultiBufferSource();
int packedLight = event.getPackedLight();
AbstractClientPlayer player = event.getPlayer();
HumanoidArm arm = event.getArm();
// Get the player's model to access the arm ModelPart
Minecraft mc = Minecraft.getInstance();
var renderer = mc.getEntityRenderDispatcher().getRenderer(player);
if (!(renderer instanceof PlayerRenderer playerRenderer)) return;
PlayerModel<AbstractClientPlayer> playerModel =
playerRenderer.getModel();
poseStack.pushPose();
// Get the appropriate arm from the player model
ModelPart armPart = (arm == HumanoidArm.RIGHT)
? playerModel.rightArm
: playerModel.leftArm;
ModelPart sleevePart = (arm == HumanoidArm.RIGHT)
? playerModel.rightSleeve
: playerModel.leftSleeve;
// The arm is already positioned by the game's first-person renderer
// We just need to render our mittens texture on top
// Use a slightly scaled version to appear on top (avoid z-fighting)
poseStack.scale(1.001F, 1.001F, 1.001F);
// Render the arm with mittens texture
VertexConsumer vertexConsumer = buffer.getBuffer(
RenderType.entitySolid(MITTENS_TEXTURE)
);
// Render the arm part with mittens texture
armPart.render(
poseStack,
vertexConsumer,
packedLight,
OverlayTexture.NO_OVERLAY
);
// Also render the sleeve part if visible
if (sleevePart.visible) {
sleevePart.render(
poseStack,
vertexConsumer,
packedLight,
OverlayTexture.NO_OVERLAY
);
}
poseStack.popPose();
}
/**
* Check if the player's current bind variant hides mittens.
* WRAP and LATEX_SACK cover the entire body including hands.
*/
private static boolean isBindHidingMittens(AbstractClientPlayer player) {
net.minecraft.world.item.ItemStack bindStack =
V2EquipmentHelper.getInRegion(
player,
BodyRegionV2.ARMS
);
if (bindStack.isEmpty()) return false;
if (bindStack.getItem() instanceof GenericBind bind) {
BindVariant variant = bind.getVariant();
return (
variant == BindVariant.WRAP || variant == BindVariant.LATEX_SACK
);
}
return false;
}
}

View File

@@ -0,0 +1,446 @@
package com.tiedup.remake.client;
import com.mojang.blaze3d.platform.InputConstants;
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
import com.tiedup.remake.client.gui.screens.AdjustmentScreen;
import com.tiedup.remake.client.gui.screens.UnifiedBondageScreen;
import com.tiedup.remake.items.base.ItemCollar;
import org.jetbrains.annotations.Nullable;
import com.tiedup.remake.core.ModConfig;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.items.base.ILockable;
import com.tiedup.remake.network.ModNetwork;
import com.tiedup.remake.network.action.PacketForceSeatModifier;
import com.tiedup.remake.network.action.PacketStruggle;
import com.tiedup.remake.network.action.PacketTighten;
import com.tiedup.remake.network.bounty.PacketRequestBounties;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.network.PacketV2StruggleStart;
import com.tiedup.remake.state.PlayerBindState;
import net.minecraft.ChatFormatting;
import net.minecraft.client.KeyMapping;
import net.minecraft.client.Minecraft;
import net.minecraft.network.chat.Component;
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.client.event.RegisterKeyMappingsEvent;
import net.minecraftforge.event.TickEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
/**
* Phase 7: Client-side keybindings for TiedUp mod.
*
* Manages key mappings and sends packets to server when keys are pressed.
*
* Based on original KeyBindings from 1.12.2
*/
@Mod.EventBusSubscriber(
modid = TiedUpMod.MOD_ID,
bus = Mod.EventBusSubscriber.Bus.FORGE,
value = Dist.CLIENT
)
public class ModKeybindings {
/**
* Key category for TiedUp keybindings
*/
private static final String CATEGORY = "key.categories.tiedup";
/**
* Struggle keybinding - Press to struggle against binds
* Default: R key
*/
public static final KeyMapping STRUGGLE_KEY = new KeyMapping(
"key.tiedup.struggle", // Translation key
InputConstants.Type.KEYSYM,
InputConstants.KEY_R, // Default key: R
CATEGORY
);
/**
* Adjustment screen keybinding - Open item adjustment screen
* Default: K key
*/
public static final KeyMapping ADJUSTMENT_KEY = new KeyMapping(
"key.tiedup.adjustment_screen",
InputConstants.Type.KEYSYM,
InputConstants.KEY_K, // Default key: K
CATEGORY
);
/**
* Bondage inventory keybinding - Open bondage inventory screen
* Default: J key
*/
public static final KeyMapping INVENTORY_KEY = new KeyMapping(
"key.tiedup.bondage_inventory",
InputConstants.Type.KEYSYM,
InputConstants.KEY_J, // Default key: J
CATEGORY
);
/**
* Slave management keybinding - Open slave management dashboard
* Default: L key
*/
public static final KeyMapping SLAVE_MANAGEMENT_KEY = new KeyMapping(
"key.tiedup.slave_management",
InputConstants.Type.KEYSYM,
InputConstants.KEY_L, // Default key: L
CATEGORY
);
/**
* Bounty list keybinding - Open bounty list screen
* Default: B key
*/
public static final KeyMapping BOUNTY_KEY = new KeyMapping(
"key.tiedup.bounties",
InputConstants.Type.KEYSYM,
InputConstants.KEY_B, // Default key: B
CATEGORY
);
/**
* Force seat keybinding - Hold to force captive on/off vehicles
* Default: Left ALT key
*/
public static final KeyMapping FORCE_SEAT_KEY = new KeyMapping(
"key.tiedup.force_seat",
InputConstants.Type.KEYSYM,
InputConstants.KEY_LALT, // Default key: Left ALT
CATEGORY
);
/**
* Tighten bind keybinding - Tighten binds on looked-at target
* Default: T key
*/
public static final KeyMapping TIGHTEN_KEY = new KeyMapping(
"key.tiedup.tighten",
InputConstants.Type.KEYSYM,
InputConstants.KEY_T, // Default key: T
CATEGORY
);
/** Track last sent state to avoid spamming packets */
private static boolean lastForceSeatState = false;
/**
* Check if Force Seat key is currently pressed.
*/
public static boolean isForceSeatPressed() {
return FORCE_SEAT_KEY.isDown();
}
/**
* Register keybindings.
* Called during mod initialization (MOD bus).
*
* @param event The registration event
*/
public static void register(RegisterKeyMappingsEvent event) {
event.register(STRUGGLE_KEY);
event.register(ADJUSTMENT_KEY);
event.register(INVENTORY_KEY);
event.register(SLAVE_MANAGEMENT_KEY);
event.register(BOUNTY_KEY);
event.register(FORCE_SEAT_KEY);
event.register(TIGHTEN_KEY);
TiedUpMod.LOGGER.info("Registered {} keybindings", 7);
}
// ==================== STRUGGLE MINI-GAME (uses vanilla movement keys) ====================
/**
* Get the vanilla movement keybind for a given direction index.
* Uses Minecraft's movement keys so AZERTY/QWERTY is already configured.
* @param index 0=FORWARD, 1=LEFT, 2=BACK, 3=RIGHT
* @return The keybind or null if invalid index
*/
public static KeyMapping getStruggleDirectionKey(int index) {
Minecraft mc = Minecraft.getInstance();
if (mc.options == null) return null;
return switch (index) {
case 0 -> mc.options.keyUp; // Forward (W/Z)
case 1 -> mc.options.keyLeft; // Strafe Left (A/Q)
case 2 -> mc.options.keyDown; // Back (S)
case 3 -> mc.options.keyRight; // Strafe Right (D)
default -> null;
};
}
/**
* Check if a keycode matches any vanilla movement keybind.
* @param keyCode The GLFW key code
* @return The direction index (0-3) or -1 if not a movement key
*/
public static int getStruggleDirectionFromKeyCode(int keyCode) {
Minecraft mc = Minecraft.getInstance();
if (mc.options == null) return -1;
if (mc.options.keyUp.matches(keyCode, 0)) return 0;
if (mc.options.keyLeft.matches(keyCode, 0)) return 1;
if (mc.options.keyDown.matches(keyCode, 0)) return 2;
if (mc.options.keyRight.matches(keyCode, 0)) return 3;
return -1;
}
/**
* Get the display name of a vanilla movement key.
* Shows the actual bound key (W for QWERTY, Z for AZERTY, etc.)
* @param index 0=FORWARD, 1=LEFT, 2=BACK, 3=RIGHT
* @return The key's display name
*/
public static String getStruggleDirectionKeyName(int index) {
KeyMapping key = getStruggleDirectionKey(index);
if (key == null) return "?";
return key.getTranslatedKeyMessage().getString().toUpperCase();
}
/**
* Handle key presses on client tick.
* Called every client tick (FORGE bus).
*
* @param event The tick event
*/
@SubscribeEvent
public static void onClientTick(TickEvent.ClientTickEvent event) {
// Only run at end of tick
if (event.phase != TickEvent.Phase.END) {
return;
}
Minecraft mc = Minecraft.getInstance();
if (mc.player == null || mc.level == null) {
return;
}
// Sync Force Seat keybind state to server (only send on change)
boolean currentForceSeatState = isForceSeatPressed();
if (currentForceSeatState != lastForceSeatState) {
lastForceSeatState = currentForceSeatState;
ModNetwork.sendToServer(
new PacketForceSeatModifier(currentForceSeatState)
);
}
// Check struggle key - Phase 21: Flow based on bind/accessories
while (STRUGGLE_KEY.consumeClick()) {
handleStruggleKey();
}
// Check adjustment screen key
while (ADJUSTMENT_KEY.consumeClick()) {
// Only open if not already in a screen and player has adjustable items
if (mc.screen == null && AdjustmentScreen.canOpen()) {
mc.setScreen(new AdjustmentScreen());
TiedUpMod.LOGGER.debug(
"[CLIENT] Adjustment key pressed - opening screen"
);
}
}
// Check bondage inventory key - opens UnifiedBondageScreen in SELF or MASTER mode
while (INVENTORY_KEY.consumeClick()) {
if (mc.screen == null) {
LivingEntity masterTarget = findOwnedCollarTarget(mc.player);
if (masterTarget != null) {
mc.setScreen(new UnifiedBondageScreen(masterTarget));
} else {
mc.setScreen(new UnifiedBondageScreen());
}
}
}
// SLAVE_MANAGEMENT_KEY: now handled by [J] with master mode detection (see above)
while (SLAVE_MANAGEMENT_KEY.consumeClick()) {
// consumed but no-op — kept registered to avoid key conflict during transition
}
// Check bounty list key
while (BOUNTY_KEY.consumeClick()) {
// Request bounty list from server (server will open the screen)
if (mc.screen == null) {
ModNetwork.sendToServer(new PacketRequestBounties());
TiedUpMod.LOGGER.debug(
"[CLIENT] Bounty key pressed - requesting bounty list"
);
}
}
// Check tighten key
while (TIGHTEN_KEY.consumeClick()) {
// Send tighten packet to server (server finds target)
if (mc.screen == null) {
ModNetwork.sendToServer(new PacketTighten());
TiedUpMod.LOGGER.debug(
"[CLIENT] Tighten key pressed - sending tighten request"
);
}
}
}
/**
* Phase 21: Handle struggle key press with new flow.
*
* Flow:
* 1. If bind equipped: Send PacketStruggle to server (struggle against bind)
* 2. If no bind: Check for locked accessories
* - If locked accessories exist: Open StruggleChoiceScreen
* - If no locked accessories: Show "Nothing to struggle" message
*/
private static void handleStruggleKey() {
Minecraft mc = Minecraft.getInstance();
Player player = mc.player;
if (player == null || mc.screen != null) {
return;
}
// V2 path: check if player has V2 equipment to struggle against
if (com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper.hasAnyEquipment(player)) {
handleV2Struggle(player);
return;
}
PlayerBindState state = PlayerBindState.getInstance(player);
if (state == null) {
return;
}
// Check if player has bind equipped
if (state.isTiedUp()) {
// Has bind - struggle against it
// Phase 2.5: Check if mini-game is enabled
if (ModConfig.SERVER.struggleMiniGameEnabled.get()) {
// New: Start struggle mini-game
ModNetwork.sendToServer(new PacketV2StruggleStart(BodyRegionV2.ARMS));
TiedUpMod.LOGGER.debug(
"[CLIENT] Struggle key pressed - starting V2 struggle mini-game"
);
} else {
// Legacy: Probability-based struggle
ModNetwork.sendToServer(new PacketStruggle());
TiedUpMod.LOGGER.debug(
"[CLIENT] Struggle key pressed - legacy struggle against bind"
);
}
return;
}
// No bind - check for locked accessories
boolean hasLockedAccessories = hasAnyLockedAccessory(player);
if (hasLockedAccessories) {
// Open UnifiedBondageScreen in self mode
mc.setScreen(new UnifiedBondageScreen());
TiedUpMod.LOGGER.debug(
"[CLIENT] Struggle key pressed - opening unified bondage screen"
);
} else {
// No locked accessories - show message
player.displayClientMessage(
Component.translatable("tiedup.struggle.nothing").withStyle(
ChatFormatting.GRAY
),
true
);
TiedUpMod.LOGGER.debug(
"[CLIENT] Struggle key pressed - nothing to struggle"
);
}
}
/**
* Handle struggle key for V2 equipment.
* Auto-targets the highest posePriority item.
*/
private static void handleV2Struggle(Player player) {
java.util.Map<com.tiedup.remake.v2.BodyRegionV2, ItemStack> equipped =
com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper.getAllEquipped(player);
if (equipped.isEmpty()) return;
// Auto-target: find highest posePriority item
com.tiedup.remake.v2.BodyRegionV2 bestRegion = null;
int bestPriority = Integer.MIN_VALUE;
for (java.util.Map.Entry<com.tiedup.remake.v2.BodyRegionV2, ItemStack> entry : equipped.entrySet()) {
ItemStack stack = entry.getValue();
if (stack.getItem() instanceof com.tiedup.remake.v2.bondage.IV2BondageItem item) {
if (item.getPosePriority(stack) > bestPriority) {
bestPriority = item.getPosePriority(stack);
bestRegion = entry.getKey();
}
}
}
if (bestRegion != null) {
ModNetwork.sendToServer(
new com.tiedup.remake.v2.bondage.network.PacketV2StruggleStart(bestRegion)
);
TiedUpMod.LOGGER.debug(
"[CLIENT] V2 Struggle key pressed - targeting region {}",
bestRegion.name()
);
}
}
/**
* Check the crosshair entity: if it is a LivingEntity wearing a collar owned by the player,
* return it as the MASTER mode target. Returns null if no valid target.
*/
@Nullable
private static LivingEntity findOwnedCollarTarget(Player player) {
if (player == null) return null;
Minecraft mc = Minecraft.getInstance();
net.minecraft.world.entity.Entity crosshair = mc.crosshairPickEntity;
if (crosshair instanceof LivingEntity living) {
return checkCollarOwnership(living, player) ? living : null;
}
return null;
}
/**
* Returns true if the given entity has a collar in the NECK region that lists the player as an owner.
*/
private static boolean checkCollarOwnership(LivingEntity target, Player player) {
ItemStack collarStack = com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper.getInRegion(
target, BodyRegionV2.NECK
);
if (!collarStack.isEmpty() && collarStack.getItem() instanceof ItemCollar collar) {
return collar.isOwner(collarStack, player);
}
return false;
}
/**
* Check if player has any locked accessories.
*/
private static boolean hasAnyLockedAccessory(Player player) {
BodyRegionV2[] accessoryRegions = {
BodyRegionV2.MOUTH,
BodyRegionV2.EYES,
BodyRegionV2.EARS,
BodyRegionV2.NECK,
BodyRegionV2.TORSO,
BodyRegionV2.HANDS,
};
for (BodyRegionV2 region : accessoryRegions) {
ItemStack stack = V2EquipmentHelper.getInRegion(player, region);
if (
!stack.isEmpty() &&
stack.getItem() instanceof ILockable lockable
) {
if (lockable.isLocked(stack)) {
return true;
}
}
}
return false;
}
}

View File

@@ -0,0 +1,118 @@
package com.tiedup.remake.client;
import net.minecraft.client.resources.sounds.Sound;
import net.minecraft.client.resources.sounds.SoundInstance;
import net.minecraft.client.resources.sounds.TickableSoundInstance;
import net.minecraft.client.sounds.SoundManager;
import net.minecraft.client.sounds.WeighedSoundEvents;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.sounds.SoundSource;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
/**
* Wrapper around a SoundInstance that applies volume and pitch modifiers.
* Used for the earplugs muffling effect.
*
* This delegates all methods to the wrapped sound, but overrides
* getVolume() and getPitch() to apply modifiers.
*/
@OnlyIn(Dist.CLIENT)
public class MuffledSoundInstance implements SoundInstance {
private final SoundInstance wrapped;
private final float volumeMultiplier;
private final float pitchMultiplier;
public MuffledSoundInstance(
SoundInstance wrapped,
float volumeMultiplier,
float pitchMultiplier
) {
this.wrapped = wrapped;
this.volumeMultiplier = volumeMultiplier;
this.pitchMultiplier = pitchMultiplier;
}
@Override
public ResourceLocation getLocation() {
return wrapped.getLocation();
}
@Override
public WeighedSoundEvents resolve(SoundManager soundManager) {
return wrapped.resolve(soundManager);
}
@Override
public Sound getSound() {
return wrapped.getSound();
}
@Override
public SoundSource getSource() {
return wrapped.getSource();
}
@Override
public boolean isLooping() {
return wrapped.isLooping();
}
@Override
public boolean isRelative() {
return wrapped.isRelative();
}
@Override
public int getDelay() {
return wrapped.getDelay();
}
@Override
public float getVolume() {
// Apply muffling to volume
return wrapped.getVolume() * volumeMultiplier;
}
@Override
public float getPitch() {
// Apply muffling to pitch
return wrapped.getPitch() * pitchMultiplier;
}
@Override
public double getX() {
return wrapped.getX();
}
@Override
public double getY() {
return wrapped.getY();
}
@Override
public double getZ() {
return wrapped.getZ();
}
@Override
public Attenuation getAttenuation() {
return wrapped.getAttenuation();
}
/**
* Check if this is wrapping a tickable sound.
* Used to handle special cases.
*/
public boolean isTickable() {
return wrapped instanceof TickableSoundInstance;
}
/**
* Get the wrapped sound instance.
*/
public SoundInstance getWrapped() {
return wrapped;
}
}

View File

@@ -0,0 +1,60 @@
package com.tiedup.remake.client.animation;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
/**
* Central registry for player animation state tracking.
*
* <p>Holds per-player state maps that were previously scattered across
* AnimationTickHandler. Provides a single clearAll() entry point for
* world unload cleanup.
*/
@OnlyIn(Dist.CLIENT)
public final class AnimationStateRegistry {
/** Track last tied state per player */
static final Map<UUID, Boolean> lastTiedState = new ConcurrentHashMap<>();
/** Track last animation ID per player to avoid redundant updates */
static final Map<UUID, String> lastAnimId = new ConcurrentHashMap<>();
private AnimationStateRegistry() {}
public static Map<UUID, Boolean> getLastTiedState() {
return lastTiedState;
}
public static Map<UUID, String> getLastAnimId() {
return lastAnimId;
}
/**
* Clear all animation-related state in one call.
* Called on world unload to prevent memory leaks and stale data.
*/
public static void clearAll() {
// Animation state tracking
lastTiedState.clear();
lastAnimId.clear();
// Animation managers
BondageAnimationManager.clearAll();
PendingAnimationManager.clearAll();
// V2 animation context system (clearAll chains to ContextAnimationFactory.clearCache)
com.tiedup.remake.client.gltf.GltfAnimationApplier.clearAll();
// Render state
com.tiedup.remake.client.animation.render.DogPoseRenderHandler.clearState();
// NPC animation state
com.tiedup.remake.client.animation.tick.NpcAnimationTickHandler.clearAll();
// MCA animation cache
com.tiedup.remake.client.animation.tick.MCAAnimationTickCache.clear();
}
}

View File

@@ -0,0 +1,737 @@
package com.tiedup.remake.client.animation;
import com.mojang.logging.LogUtils;
import com.tiedup.remake.v2.furniture.ISeatProvider;
import dev.kosmx.playerAnim.api.layered.IAnimation;
import dev.kosmx.playerAnim.api.layered.KeyframeAnimationPlayer;
import dev.kosmx.playerAnim.api.layered.ModifierLayer;
import dev.kosmx.playerAnim.core.data.KeyframeAnimation;
import dev.kosmx.playerAnim.impl.IAnimatedPlayer;
import dev.kosmx.playerAnim.minecraftApi.PlayerAnimationAccess;
import dev.kosmx.playerAnim.minecraftApi.PlayerAnimationFactory;
import dev.kosmx.playerAnim.minecraftApi.PlayerAnimationRegistry;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import net.minecraft.client.player.AbstractClientPlayer;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import org.slf4j.Logger;
/**
* Unified animation manager for bondage animations.
*
* <p>Handles both players and NPCs (any entity implementing IAnimatedPlayer).
* Uses PlayerAnimator library for smooth keyframe animations with bendy-lib support.
*
* <p>This replaces the previous split system:
* <ul>
* <li>PlayerAnimatorBridge (for players)</li>
* <li>DamselAnimationManager (for NPCs)</li>
* </ul>
*/
@OnlyIn(Dist.CLIENT)
public class BondageAnimationManager {
private static final Logger LOGGER = LogUtils.getLogger();
/** Cache of ModifierLayers for NPC entities (players use PlayerAnimationAccess) */
private static final Map<UUID, ModifierLayer<IAnimation>> npcLayers =
new ConcurrentHashMap<>();
/** Cache of context ModifierLayers for NPC entities */
private static final Map<UUID, ModifierLayer<IAnimation>> npcContextLayers =
new ConcurrentHashMap<>();
/** Cache of furniture ModifierLayers for NPC entities */
private static final Map<UUID, ModifierLayer<IAnimation>> npcFurnitureLayers =
new ConcurrentHashMap<>();
/** Factory ID for PlayerAnimator item layer (players only) */
private static final ResourceLocation FACTORY_ID =
ResourceLocation.fromNamespaceAndPath("tiedup", "bondage");
/** Factory ID for PlayerAnimator context layer (players only) */
private static final ResourceLocation CONTEXT_FACTORY_ID =
ResourceLocation.fromNamespaceAndPath("tiedup", "bondage_context");
/** Factory ID for PlayerAnimator furniture layer (players only) */
private static final ResourceLocation FURNITURE_FACTORY_ID =
ResourceLocation.fromNamespaceAndPath("tiedup", "bondage_furniture");
/** Priority for context animation layer (lower = overridable by item layer) */
private static final int CONTEXT_LAYER_PRIORITY = 40;
/** Priority for item animation layer (higher = overrides context layer) */
private static final int ITEM_LAYER_PRIORITY = 42;
/**
* Priority for furniture animation layer (highest = overrides item layer on blocked bones).
* Non-blocked bones are disabled so items can still animate them via the item layer.
*/
private static final int FURNITURE_LAYER_PRIORITY = 43;
/** Number of ticks to wait before removing a stale furniture animation. */
private static final int FURNITURE_GRACE_TICKS = 3;
/**
* Tracks ticks since a player with an active furniture animation stopped riding
* an ISeatProvider. After {@link #FURNITURE_GRACE_TICKS}, the animation is removed
* to prevent stuck poses from entity death or network issues.
*
* <p>Uses ConcurrentHashMap for safe access from both client tick and render thread.</p>
*/
private static final Map<UUID, Integer> furnitureGraceTicks = new ConcurrentHashMap<>();
/**
* Initialize the animation system.
* Must be called during client setup to register the player animation factory.
*/
public static void init() {
LOGGER.info("BondageAnimationManager initializing...");
// Context layer: lower priority = evaluated first, overridable by item layer.
// In AnimationStack, layers are sorted ascending by priority and evaluated in order.
// Higher priority layers override lower ones.
PlayerAnimationFactory.ANIMATION_DATA_FACTORY.registerFactory(
CONTEXT_FACTORY_ID,
CONTEXT_LAYER_PRIORITY,
player -> new ModifierLayer<>()
);
// Item layer: higher priority = evaluated last, overrides context layer
PlayerAnimationFactory.ANIMATION_DATA_FACTORY.registerFactory(
FACTORY_ID,
ITEM_LAYER_PRIORITY,
player -> new ModifierLayer<>()
);
// Furniture layer: highest priority = overrides item layer on blocked bones.
// Non-blocked bones are disabled via FurnitureAnimationContext so items
// can still animate free regions (gag, blindfold, etc.).
PlayerAnimationFactory.ANIMATION_DATA_FACTORY.registerFactory(
FURNITURE_FACTORY_ID,
FURNITURE_LAYER_PRIORITY,
player -> new ModifierLayer<>()
);
LOGGER.info(
"BondageAnimationManager: Factories registered — context (pri {}), item (pri {}), furniture (pri {})",
CONTEXT_LAYER_PRIORITY, ITEM_LAYER_PRIORITY, FURNITURE_LAYER_PRIORITY
);
}
// ========================================
// PLAY ANIMATION
// ========================================
/**
* Play an animation on any entity (player or NPC).
*
* @param entity The entity to animate
* @param animId Animation ID string (will be prefixed with "tiedup:" namespace)
* @return true if animation started successfully, false if layer not available
*/
public static boolean playAnimation(LivingEntity entity, String animId) {
ResourceLocation location = ResourceLocation.fromNamespaceAndPath(
"tiedup",
animId
);
return playAnimation(entity, location);
}
/**
* Play an animation on any entity (player or NPC).
*
* <p>If the animation layer is not available (e.g., remote player not fully
* initialized), the animation will be queued for retry via PendingAnimationManager.
*
* @param entity The entity to animate
* @param animId Full ResourceLocation of the animation
* @return true if animation started successfully, false if layer not available
*/
public static boolean playAnimation(
LivingEntity entity,
ResourceLocation animId
) {
if (entity == null || !entity.level().isClientSide()) {
return false;
}
KeyframeAnimation anim = PlayerAnimationRegistry.getAnimation(animId);
if (anim == null) {
// Try fallback: remove _sneak_ suffix if present
ResourceLocation fallbackId = tryFallbackAnimation(animId);
if (fallbackId != null) {
anim = PlayerAnimationRegistry.getAnimation(fallbackId);
if (anim != null) {
LOGGER.debug(
"Using fallback animation '{}' for missing '{}'",
fallbackId,
animId
);
}
}
if (anim == null) {
LOGGER.warn("Animation not found in registry: {}", animId);
return false;
}
}
ModifierLayer<IAnimation> layer = getOrCreateLayer(entity);
if (layer != null) {
// Check if same animation is already playing
// Use reference comparison (==) instead of equals() because:
// 1. PlayerAnimationRegistry caches animations by ID
// 2. Same ID = same cached object reference
// 3. This avoids issues with KeyframeAnimation.equals() implementation
IAnimation current = layer.getAnimation();
if (current instanceof KeyframeAnimationPlayer player) {
if (player.getData() == anim) {
// Same animation already playing, don't reset
return true; // Still counts as success
}
}
layer.setAnimation(new KeyframeAnimationPlayer(anim));
// Remove from pending queue if it was waiting
PendingAnimationManager.remove(entity.getUUID());
LOGGER.debug(
"Playing animation '{}' on entity: {}",
animId,
entity.getUUID()
);
return true;
} else {
// Layer not available - queue for retry if it's a player
if (entity instanceof AbstractClientPlayer) {
PendingAnimationManager.queueForRetry(
entity.getUUID(),
animId.getPath()
);
LOGGER.debug(
"Animation layer not ready for {}, queued for retry",
entity.getName().getString()
);
} else {
LOGGER.warn(
"Animation layer is NULL for NPC: {} (type: {})",
entity.getName().getString(),
entity.getClass().getSimpleName()
);
}
return false;
}
}
/**
* Play a pre-converted KeyframeAnimation directly on an entity, bypassing the registry.
* Used by GltfAnimationApplier for GLB-converted poses.
*
* @param entity The entity to animate
* @param anim The KeyframeAnimation to play
* @return true if animation started successfully
*/
public static boolean playDirect(LivingEntity entity, KeyframeAnimation anim) {
if (entity == null || anim == null || !entity.level().isClientSide()) {
return false;
}
ModifierLayer<IAnimation> layer = getOrCreateLayer(entity);
if (layer != null) {
IAnimation current = layer.getAnimation();
if (current instanceof KeyframeAnimationPlayer player) {
if (player.getData() == anim) {
return true; // Same animation already playing
}
}
layer.setAnimation(new KeyframeAnimationPlayer(anim));
PendingAnimationManager.remove(entity.getUUID());
return true;
}
return false;
}
// ========================================
// STOP ANIMATION
// ========================================
/**
* Stop any currently playing animation on an entity.
*
* @param entity The entity
*/
public static void stopAnimation(LivingEntity entity) {
if (entity == null || !entity.level().isClientSide()) {
return;
}
ModifierLayer<IAnimation> layer = getLayer(entity);
if (layer != null) {
layer.setAnimation(null);
LOGGER.debug("Stopped animation on entity: {}", entity.getUUID());
}
}
// ========================================
// LAYER MANAGEMENT
// ========================================
/**
* Get the ModifierLayer for an entity (without creating).
*/
private static ModifierLayer<IAnimation> getLayer(LivingEntity entity) {
// Players: try PlayerAnimationAccess first, then cache
if (entity instanceof AbstractClientPlayer player) {
ModifierLayer<IAnimation> factoryLayer = getPlayerLayer(player);
if (factoryLayer != null) {
return factoryLayer;
}
// Check cache (for remote players using fallback)
return npcLayers.get(entity.getUUID());
}
// NPCs: use cache
return npcLayers.get(entity.getUUID());
}
/**
* Get or create the ModifierLayer for an entity.
*/
@SuppressWarnings("unchecked")
private static ModifierLayer<IAnimation> getOrCreateLayer(
LivingEntity entity
) {
UUID uuid = entity.getUUID();
// Players: try factory-based access first, fallback to direct stack access
if (entity instanceof AbstractClientPlayer player) {
// Try the registered factory first (works for local player)
ModifierLayer<IAnimation> factoryLayer = getPlayerLayer(player);
if (factoryLayer != null) {
return factoryLayer;
}
// Fallback for remote players: use direct stack access like NPCs
// This handles cases where the factory data isn't available
if (player instanceof IAnimatedPlayer animated) {
return npcLayers.computeIfAbsent(uuid, k -> {
ModifierLayer<IAnimation> newLayer = new ModifierLayer<>();
animated
.getAnimationStack()
.addAnimLayer(ITEM_LAYER_PRIORITY, newLayer);
LOGGER.info(
"Created animation layer for remote player via stack: {}",
player.getName().getString()
);
return newLayer;
});
}
}
// NPCs implementing IAnimatedPlayer: create/cache layer
if (entity instanceof IAnimatedPlayer animated) {
return npcLayers.computeIfAbsent(uuid, k -> {
ModifierLayer<IAnimation> newLayer = new ModifierLayer<>();
animated
.getAnimationStack()
.addAnimLayer(ITEM_LAYER_PRIORITY, newLayer);
LOGGER.debug("Created animation layer for NPC: {}", uuid);
return newLayer;
});
}
LOGGER.warn(
"Entity {} does not support animations (not a player or IAnimatedPlayer)",
uuid
);
return null;
}
/**
* Get the animation layer for a player from PlayerAnimationAccess.
*/
@SuppressWarnings("unchecked")
private static ModifierLayer<IAnimation> getPlayerLayer(
AbstractClientPlayer player
) {
try {
return (ModifierLayer<
IAnimation
>) PlayerAnimationAccess.getPlayerAssociatedData(player).get(
FACTORY_ID
);
} catch (Exception e) {
LOGGER.error(
"Failed to get animation layer for player: {}",
player.getName().getString(),
e
);
return null;
}
}
/**
* Safely get the animation layer for a player.
* Returns null if the layer is not yet initialized.
*
* <p>Public method for PendingAnimationManager to access.
* Checks both the factory-based layer and the NPC cache fallback.
*
* @param player The player
* @return The animation layer, or null if not available
*/
@javax.annotation.Nullable
public static ModifierLayer<IAnimation> getPlayerLayerSafe(
AbstractClientPlayer player
) {
// Try factory first
ModifierLayer<IAnimation> factoryLayer = getPlayerLayer(player);
if (factoryLayer != null) {
return factoryLayer;
}
// Check NPC cache (for remote players using fallback path)
return npcLayers.get(player.getUUID());
}
// ========================================
// CONTEXT LAYER (lower priority, for sit/kneel/sneak)
// ========================================
/**
* Get the context animation layer for a player from PlayerAnimationAccess.
* Returns null if the layer is not yet initialized.
*/
@SuppressWarnings("unchecked")
@javax.annotation.Nullable
private static ModifierLayer<IAnimation> getPlayerContextLayer(
AbstractClientPlayer player
) {
try {
return (ModifierLayer<
IAnimation
>) PlayerAnimationAccess.getPlayerAssociatedData(player).get(
CONTEXT_FACTORY_ID
);
} catch (Exception e) {
return null;
}
}
/**
* Get or create the context animation layer for an NPC entity.
* Uses CONTEXT_LAYER_PRIORITY, below the item layer at ITEM_LAYER_PRIORITY.
*/
@javax.annotation.Nullable
private static ModifierLayer<IAnimation> getOrCreateNpcContextLayer(
LivingEntity entity
) {
if (entity instanceof IAnimatedPlayer animated) {
return npcContextLayers.computeIfAbsent(
entity.getUUID(),
k -> {
ModifierLayer<IAnimation> layer = new ModifierLayer<>();
animated.getAnimationStack().addAnimLayer(CONTEXT_LAYER_PRIORITY, layer);
return layer;
}
);
}
return null;
}
/**
* Play a context animation on the context layer (lower priority).
* Context animations (sit, kneel, sneak) can be overridden by item animations
* on the main layer which has higher priority.
*
* @param entity The entity to animate
* @param anim The KeyframeAnimation to play on the context layer
* @return true if animation started successfully
*/
public static boolean playContext(
LivingEntity entity,
KeyframeAnimation anim
) {
if (entity == null || anim == null || !entity.level().isClientSide()) {
return false;
}
ModifierLayer<IAnimation> layer;
if (entity instanceof AbstractClientPlayer player) {
layer = getPlayerContextLayer(player);
} else {
layer = getOrCreateNpcContextLayer(entity);
}
if (layer != null) {
layer.setAnimation(new KeyframeAnimationPlayer(anim));
return true;
}
return false;
}
/**
* Stop the context layer animation.
*
* @param entity The entity whose context animation should stop
*/
public static void stopContext(LivingEntity entity) {
if (entity == null || !entity.level().isClientSide()) {
return;
}
ModifierLayer<IAnimation> layer;
if (entity instanceof AbstractClientPlayer player) {
layer = getPlayerContextLayer(player);
} else {
layer = npcContextLayers.get(entity.getUUID());
}
if (layer != null) {
layer.setAnimation(null);
}
}
// ========================================
// FURNITURE LAYER (highest priority, for seat poses)
// ========================================
/**
* Play a furniture animation on the furniture layer (highest priority).
*
* <p>The furniture layer sits above the item layer so it controls blocked-region
* bones. Non-blocked bones should already be disabled in the provided animation
* (via {@link com.tiedup.remake.v2.furniture.client.FurnitureAnimationContext#create}).
* This allows bondage items on free regions to still animate via the item layer.</p>
*
* @param player the player to animate
* @param animation the KeyframeAnimation from FurnitureAnimationContext
* @return true if animation started successfully
*/
public static boolean playFurniture(Player player, KeyframeAnimation animation) {
if (player == null || animation == null || !player.level().isClientSide()) {
return false;
}
ModifierLayer<IAnimation> layer = getFurnitureLayer(player);
if (layer != null) {
layer.setAnimation(new KeyframeAnimationPlayer(animation));
// Reset grace ticks since we just started/refreshed the animation
furnitureGraceTicks.remove(player.getUUID());
LOGGER.debug("Playing furniture animation on player: {}", player.getName().getString());
return true;
}
LOGGER.warn("Furniture layer not available for player: {}", player.getName().getString());
return false;
}
/**
* Stop the furniture layer animation for a player.
*
* @param player the player whose furniture animation should stop
*/
public static void stopFurniture(Player player) {
if (player == null || !player.level().isClientSide()) {
return;
}
ModifierLayer<IAnimation> layer = getFurnitureLayer(player);
if (layer != null) {
layer.setAnimation(null);
}
furnitureGraceTicks.remove(player.getUUID());
LOGGER.debug("Stopped furniture animation on player: {}", player.getName().getString());
}
/**
* Check whether a player currently has an active furniture animation.
*
* @param player the player to check
* @return true if the furniture layer has an active animation
*/
public static boolean hasFurnitureAnimation(Player player) {
if (player == null || !player.level().isClientSide()) {
return false;
}
ModifierLayer<IAnimation> layer = getFurnitureLayer(player);
return layer != null && layer.getAnimation() != null;
}
/**
* Get the furniture ModifierLayer for a player.
* Uses PlayerAnimationAccess for local/factory-registered players,
* falls back to NPC cache for remote players.
*/
@SuppressWarnings("unchecked")
@javax.annotation.Nullable
private static ModifierLayer<IAnimation> getFurnitureLayer(Player player) {
if (player instanceof AbstractClientPlayer clientPlayer) {
try {
ModifierLayer<IAnimation> layer = (ModifierLayer<IAnimation>)
PlayerAnimationAccess.getPlayerAssociatedData(clientPlayer)
.get(FURNITURE_FACTORY_ID);
if (layer != null) {
return layer;
}
} catch (Exception e) {
// Fall through to NPC cache
}
// Fallback for remote players: check NPC furniture cache
return npcFurnitureLayers.get(player.getUUID());
}
// Non-player entities: use NPC cache
return npcFurnitureLayers.get(player.getUUID());
}
/**
* Safety tick for furniture animations. Call once per client tick per player.
*
* <p>If a player has an active furniture animation but is NOT riding an
* {@link ISeatProvider}, increment a grace counter. After
* {@link #FURNITURE_GRACE_TICKS} consecutive ticks without a seat, the
* animation is removed to prevent stuck poses from entity death, network
* desync, or teleportation.</p>
*
* <p>If the player IS riding an ISeatProvider, the counter is reset.</p>
*
* @param player the player to check
*/
public static void tickFurnitureSafety(Player player) {
if (player == null || !player.level().isClientSide()) {
return;
}
if (!hasFurnitureAnimation(player)) {
// No furniture animation active, nothing to guard
furnitureGraceTicks.remove(player.getUUID());
return;
}
UUID uuid = player.getUUID();
// Check if the player is riding an ISeatProvider
Entity vehicle = player.getVehicle();
boolean ridingSeat = vehicle instanceof ISeatProvider;
if (ridingSeat) {
// Player is properly seated, reset grace counter
furnitureGraceTicks.remove(uuid);
} else {
// Player has furniture anim but no seat -- increment grace
int ticks = furnitureGraceTicks.merge(uuid, 1, Integer::sum);
if (ticks >= FURNITURE_GRACE_TICKS) {
LOGGER.info("Removing stale furniture animation for player {} "
+ "(not riding ISeatProvider for {} ticks)",
player.getName().getString(), ticks);
stopFurniture(player);
}
}
}
// ========================================
// FALLBACK ANIMATION HANDLING
// ========================================
/**
* Try to find a fallback animation ID when the requested one doesn't exist.
*
* <p>Fallback chain:
* <ol>
* <li>Remove _sneak_ suffix (sneak variants often missing)</li>
* <li>For sit_dog/kneel_dog variants, fall back to basic standing DOG</li>
* <li>For _arms_ variants, try FULL variant</li>
* </ol>
*
* @param originalId The original animation ID that wasn't found
* @return A fallback ResourceLocation to try, or null if no fallback
*/
@javax.annotation.Nullable
private static ResourceLocation tryFallbackAnimation(
ResourceLocation originalId
) {
String path = originalId.getPath();
String namespace = originalId.getNamespace();
// 1. Remove _sneak_ suffix
if (path.contains("_sneak_")) {
String fallback = path.replace("_sneak_", "_");
return ResourceLocation.fromNamespaceAndPath(namespace, fallback);
}
// 2. sit_dog_* / kneel_dog_* -> tied_up_dog_*
if (path.startsWith("sit_dog_") || path.startsWith("kneel_dog_")) {
String suffix = path.substring(path.lastIndexOf("_")); // _idle or _struggle
return ResourceLocation.fromNamespaceAndPath(
namespace,
"tied_up_dog" + suffix
);
}
// 3. _arms_ variants -> try FULL variant (remove _arms)
if (path.contains("_arms_")) {
String fallback = path.replace("_arms_", "_");
return ResourceLocation.fromNamespaceAndPath(namespace, fallback);
}
// 4. Struggle variants for free/legs -> idle variant
if (
(path.startsWith("sit_free_") ||
path.startsWith("kneel_free_") ||
path.startsWith("sit_legs_") ||
path.startsWith("kneel_legs_")) &&
path.endsWith("_struggle")
) {
String fallback = path.replace("_struggle", "_idle");
return ResourceLocation.fromNamespaceAndPath(namespace, fallback);
}
return null;
}
// ========================================
// CLEANUP
// ========================================
/**
* Clean up animation layer for an NPC when it's removed.
*
* @param entityId UUID of the removed entity
*/
/** All NPC layer caches, for bulk cleanup operations. */
private static final Map<UUID, ModifierLayer<IAnimation>>[] ALL_NPC_CACHES = new Map[] {
npcLayers, npcContextLayers, npcFurnitureLayers
};
public static void cleanup(UUID entityId) {
for (Map<UUID, ModifierLayer<IAnimation>> cache : ALL_NPC_CACHES) {
ModifierLayer<IAnimation> layer = cache.remove(entityId);
if (layer != null) {
layer.setAnimation(null);
}
}
furnitureGraceTicks.remove(entityId);
LOGGER.debug("Cleaned up animation layers for entity: {}", entityId);
}
/**
* Clear all NPC animation layers.
* Should be called on world unload.
*/
public static void clearAll() {
for (Map<UUID, ModifierLayer<IAnimation>> cache : ALL_NPC_CACHES) {
cache.values().forEach(layer -> layer.setAnimation(null));
cache.clear();
}
furnitureGraceTicks.clear();
LOGGER.info("Cleared all NPC animation layers");
}
}

View File

@@ -0,0 +1,156 @@
package com.tiedup.remake.client.animation;
import com.mojang.logging.LogUtils;
import dev.kosmx.playerAnim.api.layered.IAnimation;
import dev.kosmx.playerAnim.api.layered.KeyframeAnimationPlayer;
import dev.kosmx.playerAnim.api.layered.ModifierLayer;
import dev.kosmx.playerAnim.core.data.KeyframeAnimation;
import dev.kosmx.playerAnim.minecraftApi.PlayerAnimationRegistry;
import java.util.Iterator;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.client.player.AbstractClientPlayer;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.entity.player.Player;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import org.slf4j.Logger;
/**
* Manages pending animations for remote players whose animation layers
* may not be immediately available due to timing issues.
*
* <p>When a player is tied, the sync packet may arrive before the remote player's
* animation layer is initialized by PlayerAnimator. This class queues failed
* animation attempts and retries them each tick until success or timeout.
*
* <p>This follows the same pattern as SyncManager's pending queue for inventory sync.
*/
@OnlyIn(Dist.CLIENT)
public class PendingAnimationManager {
private static final Logger LOGGER = LogUtils.getLogger();
/** Pending animations waiting for layer initialization */
private static final Map<UUID, PendingEntry> pending =
new ConcurrentHashMap<>();
/** Maximum retry attempts before giving up (~2 seconds at 20 ticks/sec) */
private static final int MAX_RETRIES = 40;
/**
* Queue a player's animation for retry.
* Called when playAnimation fails due to null layer.
*
* @param uuid The player's UUID
* @param animId The animation ID (without namespace)
*/
public static void queueForRetry(UUID uuid, String animId) {
pending.compute(uuid, (k, existing) -> {
if (existing == null) {
LOGGER.debug(
"Queued animation '{}' for retry on player {}",
animId,
uuid
);
return new PendingEntry(animId, 0);
}
// Update animation ID but preserve retry count
return new PendingEntry(animId, existing.retries);
});
}
/**
* Remove a player from the pending queue.
* Called when animation succeeds or player disconnects.
*
* @param uuid The player's UUID
*/
public static void remove(UUID uuid) {
pending.remove(uuid);
}
/**
* Check if a player has a pending animation.
*
* @param uuid The player's UUID
* @return true if pending
*/
public static boolean hasPending(UUID uuid) {
return pending.containsKey(uuid);
}
/**
* Process pending animations. Called every tick from AnimationTickHandler.
* Attempts to play queued animations and removes successful or expired entries.
*
* @param level The client level
*/
public static void processPending(ClientLevel level) {
if (pending.isEmpty()) return;
Iterator<Map.Entry<UUID, PendingEntry>> it = pending
.entrySet()
.iterator();
while (it.hasNext()) {
Map.Entry<UUID, PendingEntry> entry = it.next();
UUID uuid = entry.getKey();
PendingEntry pe = entry.getValue();
// Check expiration
if (pe.retries >= MAX_RETRIES) {
LOGGER.warn("Animation retry exhausted for player {}", uuid);
it.remove();
continue;
}
// Try to find player and play animation
Player player = level.getPlayerByUUID(uuid);
if (player instanceof AbstractClientPlayer clientPlayer) {
ModifierLayer<IAnimation> layer =
BondageAnimationManager.getPlayerLayerSafe(clientPlayer);
if (layer != null) {
ResourceLocation loc =
ResourceLocation.fromNamespaceAndPath(
"tiedup",
pe.animId
);
KeyframeAnimation anim =
PlayerAnimationRegistry.getAnimation(loc);
if (anim != null) {
layer.setAnimation(new KeyframeAnimationPlayer(anim));
LOGGER.info(
"Animation retry succeeded for {} after {} attempts",
clientPlayer.getName().getString(),
pe.retries
);
it.remove();
continue;
}
}
}
// Increment retry count
pending.put(uuid, new PendingEntry(pe.animId, pe.retries + 1));
}
}
/**
* Clear all pending animations.
* Called on world unload.
*/
public static void clearAll() {
pending.clear();
LOGGER.debug("Cleared all pending animations");
}
/**
* Record to store pending animation data.
*/
private record PendingEntry(String animId, int retries) {}
}

View File

@@ -0,0 +1,137 @@
package com.tiedup.remake.client.animation;
import com.tiedup.remake.items.base.PoseType;
import net.minecraft.client.model.HumanoidModel;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
/**
* Applies static bondage poses directly to HumanoidModel.
*
* <p>Used for entities that don't support PlayerAnimator (e.g., MCA villagers).
* Directly modifies arm/leg rotations on the model.
*
* <p>Extracted from BondageAnimationManager to separate concerns:
* BondageAnimationManager handles PlayerAnimator layers,
* StaticPoseApplier handles raw model manipulation.
*/
@OnlyIn(Dist.CLIENT)
public class StaticPoseApplier {
/**
* Apply a static bondage pose directly to a HumanoidModel.
*
* @param model The humanoid model to modify
* @param poseType The pose type (STANDARD, STRAITJACKET, WRAP, LATEX_SACK)
* @param armsBound whether ARMS region is occupied
* @param legsBound whether LEGS region is occupied
*/
public static void applyStaticPose(
HumanoidModel<?> model,
PoseType poseType,
boolean armsBound,
boolean legsBound
) {
if (model == null) {
return;
}
applyBodyPose(model, poseType);
if (armsBound) {
applyArmPose(model, poseType);
}
if (legsBound) {
applyLegPose(model, poseType);
}
}
/**
* Apply arm pose based on pose type.
* Values converted from animation JSON (degrees to radians).
*/
private static void applyArmPose(
HumanoidModel<?> model,
PoseType poseType
) {
switch (poseType) {
case STANDARD -> {
model.rightArm.xRot = 0.899f;
model.rightArm.yRot = 1.0f;
model.rightArm.zRot = 0f;
model.leftArm.xRot = 0.899f;
model.leftArm.yRot = -1.0f;
model.leftArm.zRot = 0f;
}
case STRAITJACKET -> {
model.rightArm.xRot = 0.764f;
model.rightArm.yRot = -0.84f;
model.rightArm.zRot = 0f;
model.leftArm.xRot = 0.764f;
model.leftArm.yRot = 0.84f;
model.leftArm.zRot = 0f;
}
case WRAP, LATEX_SACK -> {
model.rightArm.xRot = 0f;
model.rightArm.yRot = 0f;
model.rightArm.zRot = -0.087f;
model.leftArm.xRot = 0f;
model.leftArm.yRot = 0f;
model.leftArm.zRot = 0.087f;
}
case DOG -> {
model.rightArm.xRot = -2.094f;
model.rightArm.yRot = 0.175f;
model.rightArm.zRot = 0f;
model.leftArm.xRot = -2.094f;
model.leftArm.yRot = -0.175f;
model.leftArm.zRot = 0f;
}
case HUMAN_CHAIR -> {
model.rightArm.xRot = -2.094f;
model.rightArm.yRot = 0.175f;
model.rightArm.zRot = 0f;
model.leftArm.xRot = -2.094f;
model.leftArm.yRot = -0.175f;
model.leftArm.zRot = 0f;
}
}
}
/**
* Apply leg pose based on pose type.
*/
private static void applyLegPose(
HumanoidModel<?> model,
PoseType poseType
) {
if (poseType == PoseType.DOG || poseType == PoseType.HUMAN_CHAIR) {
model.rightLeg.xRot = -1.047f;
model.rightLeg.yRot = 0.349f;
model.rightLeg.zRot = 0f;
model.leftLeg.xRot = -1.047f;
model.leftLeg.yRot = -0.349f;
model.leftLeg.zRot = 0f;
} else {
model.rightLeg.xRot = 0f;
model.rightLeg.yRot = 0f;
model.rightLeg.zRot = -0.1f;
model.leftLeg.xRot = 0f;
model.leftLeg.yRot = 0f;
model.leftLeg.zRot = 0.1f;
}
}
/**
* Apply body pose for DOG/HUMAN_CHAIR pose.
*/
public static void applyBodyPose(
HumanoidModel<?> model,
PoseType poseType
) {
if (poseType == PoseType.DOG || poseType == PoseType.HUMAN_CHAIR) {
model.body.xRot = -1.571f;
}
}
}

View File

@@ -0,0 +1,93 @@
package com.tiedup.remake.client.animation.context;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
/**
* Represents the current player/NPC posture and action state for animation selection.
* Determines which base body posture animation to play.
*
* <p>Each context maps to a GLB animation name via a prefix + variant scheme:
* <ul>
* <li>Prefix: "Sit", "Kneel", "Sneak", "Walk", or "" (standing)</li>
* <li>Variant: "Idle" or "Struggle"</li>
* </ul>
* The {@link GlbAnimationResolver} uses these to build a fallback chain
* (e.g., SitStruggle -> Struggle -> SitIdle -> Idle).</p>
*/
@OnlyIn(Dist.CLIENT)
public enum AnimationContext {
STAND_IDLE("stand_idle", false),
STAND_WALK("stand_walk", false),
STAND_SNEAK("stand_sneak", false),
STAND_STRUGGLE("stand_struggle", true),
SIT_IDLE("sit_idle", false),
SIT_STRUGGLE("sit_struggle", true),
KNEEL_IDLE("kneel_idle", false),
KNEEL_STRUGGLE("kneel_struggle", true),
// Movement style contexts
SHUFFLE_IDLE("shuffle_idle", false),
SHUFFLE_WALK("shuffle_walk", false),
HOP_IDLE("hop_idle", false),
HOP_WALK("hop_walk", false),
WADDLE_IDLE("waddle_idle", false),
WADDLE_WALK("waddle_walk", false),
CRAWL_IDLE("crawl_idle", false),
CRAWL_MOVE("crawl_move", false);
private final String animationSuffix;
private final boolean struggling;
AnimationContext(String animationSuffix, boolean struggling) {
this.animationSuffix = animationSuffix;
this.struggling = struggling;
}
/**
* Suffix used as key for context animation JSON files (e.g., "stand_idle").
*/
public String getAnimationSuffix() {
return animationSuffix;
}
/**
* Whether this context represents an active struggle state.
*/
public boolean isStruggling() {
return struggling;
}
/**
* Get the GLB animation name prefix for this context's posture.
* Used by the fallback chain in {@link GlbAnimationResolver}.
*
* @return "Sit", "Kneel", "Sneak", "Walk", or "" for standing
*/
public String getGlbContextPrefix() {
return switch (this) {
case SIT_IDLE, SIT_STRUGGLE -> "Sit";
case KNEEL_IDLE, KNEEL_STRUGGLE -> "Kneel";
case STAND_SNEAK -> "Sneak";
case STAND_WALK -> "Walk";
case STAND_IDLE, STAND_STRUGGLE -> "";
case SHUFFLE_IDLE, SHUFFLE_WALK -> "Shuffle";
case HOP_IDLE, HOP_WALK -> "Hop";
case WADDLE_IDLE, WADDLE_WALK -> "Waddle";
case CRAWL_IDLE, CRAWL_MOVE -> "Crawl";
};
}
/**
* Get the GLB animation variant name: "Struggle" or "Idle".
*/
public String getGlbVariant() {
return switch (this) {
case STAND_STRUGGLE, SIT_STRUGGLE, KNEEL_STRUGGLE -> "Struggle";
case STAND_WALK, SHUFFLE_WALK, HOP_WALK, WADDLE_WALK -> "Walk";
case CRAWL_MOVE -> "Move";
default -> "Idle";
};
}
}

View File

@@ -0,0 +1,118 @@
package com.tiedup.remake.client.animation.context;
import com.tiedup.remake.client.state.PetBedClientState;
import com.tiedup.remake.entities.AbstractTiedUpNpc;
import com.tiedup.remake.state.PlayerBindState;
import com.tiedup.remake.v2.bondage.movement.MovementStyle;
import net.minecraft.world.entity.player.Player;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import org.jetbrains.annotations.Nullable;
/**
* Resolves the current {@link AnimationContext} for players and NPCs based on their state.
*
* <p>This is a pure function with no side effects -- it reads entity state and returns
* the appropriate animation context. The resolution priority is:
* <ol>
* <li><b>Sitting</b> (pet bed for players, pose for NPCs) -- highest priority posture</li>
* <li><b>Kneeling</b> (NPCs only)</li>
* <li><b>Struggling</b> (standing struggle if not sitting/kneeling)</li>
* <li><b>Sneaking</b> (players only)</li>
* <li><b>Walking</b> (horizontal movement detected)</li>
* <li><b>Standing idle</b> (fallback)</li>
* </ol>
*
* <p>For players, the "sitting" state is determined by the client-side pet bed cache
* ({@link PetBedClientState}) rather than entity data, since pet bed state is not
* synced via entity data accessors.</p>
*/
@OnlyIn(Dist.CLIENT)
public final class AnimationContextResolver {
private AnimationContextResolver() {}
/**
* Resolve the animation context for a player based on their bind state and movement.
*
* <p>Priority chain:
* <ol>
* <li>Sitting (pet bed/furniture) -- highest priority posture</li>
* <li>Struggling -- standing struggle if not sitting</li>
* <li>Movement style -- style-specific idle/walk based on movement</li>
* <li>Sneaking</li>
* <li>Walking</li>
* <li>Standing idle -- fallback</li>
* </ol>
*
* @param player the player entity (must not be null)
* @param state the player's bind state, or null if not bound
* @param activeStyle the active movement style from client state, or null
* @return the resolved animation context, never null
*/
public static AnimationContext resolve(Player player, @Nullable PlayerBindState state,
@Nullable MovementStyle activeStyle) {
boolean sitting = PetBedClientState.get(player.getUUID()) != 0;
boolean struggling = state != null && state.isStruggling();
boolean sneaking = player.isCrouching();
boolean moving = player.getDeltaMovement().horizontalDistanceSqr() > 1.0E-6;
if (sitting) {
return struggling ? AnimationContext.SIT_STRUGGLE : AnimationContext.SIT_IDLE;
}
if (struggling) {
return AnimationContext.STAND_STRUGGLE;
}
if (activeStyle != null) {
return resolveStyleContext(activeStyle, moving);
}
if (sneaking) {
return AnimationContext.STAND_SNEAK;
}
if (moving) {
return AnimationContext.STAND_WALK;
}
return AnimationContext.STAND_IDLE;
}
/**
* Map a movement style + moving flag to the appropriate AnimationContext.
*/
private static AnimationContext resolveStyleContext(MovementStyle style, boolean moving) {
return switch (style) {
case SHUFFLE -> moving ? AnimationContext.SHUFFLE_WALK : AnimationContext.SHUFFLE_IDLE;
case HOP -> moving ? AnimationContext.HOP_WALK : AnimationContext.HOP_IDLE;
case WADDLE -> moving ? AnimationContext.WADDLE_WALK : AnimationContext.WADDLE_IDLE;
case CRAWL -> moving ? AnimationContext.CRAWL_MOVE : AnimationContext.CRAWL_IDLE;
};
}
/**
* Resolve the animation context for a Damsel NPC based on pose and movement.
*
* <p>Unlike players, NPCs support kneeling as a distinct posture and do not sneak.</p>
*
* @param entity the damsel entity (must not be null)
* @return the resolved animation context, never null
*/
public static AnimationContext resolveNpc(AbstractTiedUpNpc entity) {
boolean sitting = entity.isSitting();
boolean kneeling = entity.isKneeling();
boolean struggling = entity.isStruggling();
boolean moving = entity.getDeltaMovement().horizontalDistanceSqr() > 1.0E-6;
if (sitting) {
return struggling ? AnimationContext.SIT_STRUGGLE : AnimationContext.SIT_IDLE;
}
if (kneeling) {
return struggling ? AnimationContext.KNEEL_STRUGGLE : AnimationContext.KNEEL_IDLE;
}
if (struggling) {
return AnimationContext.STAND_STRUGGLE;
}
if (moving) {
return AnimationContext.STAND_WALK;
}
return AnimationContext.STAND_IDLE;
}
}

View File

@@ -0,0 +1,161 @@
package com.tiedup.remake.client.animation.context;
import com.mojang.logging.LogUtils;
import dev.kosmx.playerAnim.core.data.KeyframeAnimation;
import dev.kosmx.playerAnim.minecraftApi.PlayerAnimationRegistry;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import org.jetbrains.annotations.Nullable;
import net.minecraft.resources.ResourceLocation;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import org.slf4j.Logger;
/**
* Builds context {@link KeyframeAnimation}s with item-owned body parts disabled.
*
* <p>Context animations (loaded from {@code context_*.json} files in the PlayerAnimator
* registry) control the base body posture -- standing, sitting, walking, etc.
* When a V2 bondage item "owns" certain body parts (e.g., handcuffs own rightArm + leftArm),
* those parts must NOT be driven by the context animation because the item's own
* GLB animation controls them instead.</p>
*
* <p>This factory loads the base context animation, creates a mutable copy, disables
* the owned parts, and builds an immutable result. Results are cached by
* {@code contextSuffix|ownedPartsHash} to avoid repeated copies.</p>
*
* <p>Thread safety: the cache uses {@link ConcurrentHashMap}. All methods are
* called from the render thread, but the concurrent map avoids issues if
* resource reload triggers on a different thread.</p>
*
* @see AnimationContext
* @see RegionBoneMapper#computeOwnedParts
*/
@OnlyIn(Dist.CLIENT)
public final class ContextAnimationFactory {
private static final Logger LOGGER = LogUtils.getLogger();
private static final String NAMESPACE = "tiedup";
/**
* Cache keyed by "contextSuffix|ownedPartsHashCode".
* Null values are stored as sentinels for missing animations to avoid repeated lookups.
*/
private static final Map<String, KeyframeAnimation> CACHE = new ConcurrentHashMap<>();
/**
* Sentinel set used to track cache keys where the base animation was not found,
* so we don't log the same warning repeatedly.
*/
private static final Set<String> MISSING_WARNED = ConcurrentHashMap.newKeySet();
private ContextAnimationFactory() {}
/**
* Create (or retrieve from cache) a context animation with the given parts disabled.
*
* <p>If no parts need disabling, the base animation is returned as-is (no copy needed).
* If the base animation is not found in the PlayerAnimator registry, returns null.</p>
*
* @param context the current animation context (determines which context_*.json to load)
* @param disabledParts set of PlayerAnimator part names to disable on the context layer
* (e.g., {"rightArm", "leftArm"}), typically from
* {@link RegionBoneMapper.BoneOwnership#disabledOnContext()}
* @return the context animation with disabled parts suppressed, or null if not found
*/
@Nullable
public static KeyframeAnimation create(AnimationContext context, Set<String> disabledParts) {
String cacheKey = context.getAnimationSuffix() + "|" + String.join(",", new java.util.TreeSet<>(disabledParts));
// computeIfAbsent cannot store null values, so we handle the missing case
// by checking the MISSING_WARNED set to avoid redundant work.
KeyframeAnimation cached = CACHE.get(cacheKey);
if (cached != null) {
return cached;
}
if (MISSING_WARNED.contains(cacheKey)) {
return null;
}
KeyframeAnimation result = buildContextAnimation(context, disabledParts);
if (result != null) {
CACHE.put(cacheKey, result);
} else {
MISSING_WARNED.add(cacheKey);
}
return result;
}
/**
* Build a context animation with the specified parts disabled.
*
* <p>Flow:
* <ol>
* <li>Check {@link ContextGlbRegistry} for a GLB-based context animation (takes priority)</li>
* <li>Fall back to {@code tiedup:context_<suffix>} in PlayerAnimationRegistry (JSON-based)</li>
* <li>If no parts need disabling, return the base animation directly (immutable, shared)</li>
* <li>Otherwise, create a mutable copy via {@link KeyframeAnimation#mutableCopy()}</li>
* <li>Disable each part via {@link KeyframeAnimation.StateCollection#setEnabled(boolean)}</li>
* <li>Build and return the new immutable animation</li>
* </ol>
*/
@Nullable
private static KeyframeAnimation buildContextAnimation(AnimationContext context,
Set<String> disabledParts) {
String suffix = context.getAnimationSuffix();
// Priority 1: GLB-based context animation from ContextGlbRegistry
KeyframeAnimation baseAnim = ContextGlbRegistry.get(suffix);
// Priority 2: JSON-based context animation from PlayerAnimationRegistry
if (baseAnim == null) {
ResourceLocation animId = ResourceLocation.fromNamespaceAndPath(
NAMESPACE, "context_" + suffix
);
baseAnim = PlayerAnimationRegistry.getAnimation(animId);
}
if (baseAnim == null) {
LOGGER.warn("[V2Animation] Context animation not found for suffix: {}", suffix);
return null;
}
if (disabledParts.isEmpty()) {
return baseAnim;
}
// Create mutable copy so we can disable parts without affecting the registry/cache original
KeyframeAnimation.AnimationBuilder builder = baseAnim.mutableCopy();
disableParts(builder, disabledParts);
return builder.build();
}
/**
* Disable all animation axes on the specified parts.
*
* <p>Uses {@link KeyframeAnimation.AnimationBuilder#getPart(String)} to look up parts
* by name, then {@link KeyframeAnimation.StateCollection#setEnabled(boolean)} to disable
* all axes (x, y, z, pitch, yaw, roll, and bend/bendDirection if applicable).</p>
*
* <p>Unknown part names are silently ignored -- this can happen if the disabled parts set
* includes future bone names not present in the current context animation.</p>
*/
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);
}
}
}
/**
* Clear all cached animations. Call this on resource reload or when equipped items change
* in a way that might invalidate cached part ownership.
*/
public static void clearCache() {
CACHE.clear();
MISSING_WARNED.clear();
}
}

View File

@@ -0,0 +1,121 @@
package com.tiedup.remake.client.animation.context;
import com.tiedup.remake.client.gltf.GlbParser;
import com.tiedup.remake.client.gltf.GltfData;
import com.tiedup.remake.client.gltf.GltfPoseConverter;
import dev.kosmx.playerAnim.core.data.KeyframeAnimation;
import java.io.InputStream;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import org.jetbrains.annotations.Nullable;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.packs.resources.Resource;
import net.minecraft.server.packs.resources.ResourceManager;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
/**
* Registry for context animations loaded from GLB files.
*
* <p>Scans the {@code tiedup_contexts/} resource directory for {@code .glb} files,
* parses each one via {@link GlbParser}, converts to a {@link KeyframeAnimation}
* via {@link GltfPoseConverter#convert(GltfData)}, and stores the result keyed by
* the file name suffix (e.g., {@code "stand_walk"} from {@code tiedup_contexts/stand_walk.glb}).</p>
*
* <p>GLB context animations take priority over JSON-based PlayerAnimator context
* animations. This allows artists to author posture animations directly in Blender
* instead of hand-editing JSON keyframes.</p>
*
* <p>Reloaded on resource pack reload (F3+T) via the listener registered in
* {@link com.tiedup.remake.client.gltf.GltfClientSetup}.</p>
*
* <p>Thread safety: the registry field is a volatile reference to an unmodifiable map.
* {@link #reload} builds a new map on the reload thread then atomically swaps the
* reference, so the render thread never sees a partially populated registry.</p>
*/
@OnlyIn(Dist.CLIENT)
public final class ContextGlbRegistry {
private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
/** Resource directory containing context GLB files. */
private static final String DIRECTORY = "tiedup_contexts";
/**
* Registry keyed by context suffix (e.g., "stand_walk", "sit_idle").
* Values are fully converted KeyframeAnimations with all parts enabled.
*
* <p>Volatile reference to an unmodifiable map. Reload builds a new map
* and swaps atomically; the render thread always sees a consistent snapshot.</p>
*/
private static volatile Map<String, KeyframeAnimation> REGISTRY = Map.of();
private ContextGlbRegistry() {}
/**
* Reload all context GLB files from the resource manager.
*
* <p>Scans {@code assets/<namespace>/tiedup_contexts/} for {@code .glb} files.
* Each file is parsed and converted to a full-body KeyframeAnimation.
* The context suffix is extracted from the file path:
* {@code tiedup_contexts/stand_walk.glb} becomes key {@code "stand_walk"}.</p>
*
* <p>GLB files without animation data or with parse errors are logged and skipped.</p>
*
* @param resourceManager the current resource manager (from reload listener)
*/
public static void reload(ResourceManager resourceManager) {
Map<String, KeyframeAnimation> newRegistry = new HashMap<>();
Map<ResourceLocation, Resource> resources = resourceManager.listResources(
DIRECTORY, loc -> loc.getPath().endsWith(".glb"));
for (Map.Entry<ResourceLocation, Resource> entry : resources.entrySet()) {
ResourceLocation loc = entry.getKey();
Resource resource = entry.getValue();
// Extract suffix from path: "tiedup_contexts/stand_walk.glb" -> "stand_walk"
String path = loc.getPath();
String fileName = path.substring(path.lastIndexOf('/') + 1);
String suffix = fileName.substring(0, fileName.length() - 4); // strip ".glb"
try (InputStream is = resource.open()) {
GltfData data = GlbParser.parse(is, loc.toString());
// Convert to a full-body KeyframeAnimation (all parts enabled)
KeyframeAnimation anim = GltfPoseConverter.convert(data);
newRegistry.put(suffix, anim);
LOGGER.info("[GltfPipeline] Loaded context GLB: '{}' -> suffix '{}'", loc, suffix);
} catch (Exception e) {
LOGGER.error("[GltfPipeline] Failed to load context GLB: {}", loc, e);
}
}
// Atomic swap: render thread never sees a partially populated registry
REGISTRY = Collections.unmodifiableMap(newRegistry);
LOGGER.info("[ContextGlb] Loaded {} context GLB animations", newRegistry.size());
}
/**
* Get a context animation by suffix.
*
* @param contextSuffix the context suffix (e.g., "stand_walk", "sit_idle")
* @return the KeyframeAnimation, or null if no GLB was found for this suffix
*/
@Nullable
public static KeyframeAnimation get(String contextSuffix) {
return REGISTRY.get(contextSuffix);
}
/**
* Clear all cached context animations.
* Called on resource reload and world unload.
*/
public static void clear() {
REGISTRY = Map.of();
}
}

View File

@@ -0,0 +1,151 @@
package com.tiedup.remake.client.animation.context;
import com.tiedup.remake.client.gltf.GltfCache;
import com.tiedup.remake.client.gltf.GltfData;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ThreadLocalRandom;
import org.jetbrains.annotations.Nullable;
import net.minecraft.resources.ResourceLocation;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
/**
* Resolves which named animation to play from a GLB file based on the current
* {@link AnimationContext}. Implements three features:
*
* <ol>
* <li><b>Context-based resolution with fallback chain</b> — tries progressively
* less specific animation names until one is found:
* <pre>SitStruggle -> Struggle -> SitIdle -> Sit -> Idle -> null</pre></li>
* <li><b>Animation variants</b> — if {@code Struggle.1}, {@code Struggle.2},
* {@code Struggle.3} exist in the GLB, one is picked at random each time</li>
* <li><b>Shared animation templates</b> — animations can come from a separate GLB
* file (passed as {@code animationSource} to {@link #resolveAnimationData})</li>
* </ol>
*
* <p>This class is stateless and thread-safe. All methods are static.</p>
*/
@OnlyIn(Dist.CLIENT)
public final class GlbAnimationResolver {
private GlbAnimationResolver() {}
/**
* Resolve the animation data source.
* If {@code animationSource} is non-null, load that GLB for animations
* (shared template). Otherwise use the item's own model GLB.
*
* @param itemModelLoc the item's GLB model resource location
* @param animationSource optional separate GLB containing shared animations
* @return parsed GLB data, or null if loading failed
*/
@Nullable
public static GltfData resolveAnimationData(ResourceLocation itemModelLoc,
@Nullable ResourceLocation animationSource) {
ResourceLocation source = animationSource != null ? animationSource : itemModelLoc;
return GltfCache.get(source);
}
/**
* Resolve the best animation name from a GLB for the given context.
* Supports variant selection ({@code Struggle.1}, {@code Struggle.2} -> random pick)
* and full-body animations ({@code FullWalk}, {@code FullStruggle}).
*
* <p>Fallback chain (Full variants checked first at each step):</p>
* <pre>
* FullSitStruggle -> SitStruggle -> FullStruggle -> Struggle
* -> FullSitIdle -> SitIdle -> FullSit -> Sit
* -> FullIdle -> Idle -> null
* </pre>
*
* @param data the parsed GLB data containing named animations
* @param context the current animation context (posture + action)
* @return the animation name to use, or null to use the default (first) clip
*/
@Nullable
public static String resolve(GltfData data, AnimationContext context) {
String prefix = context.getGlbContextPrefix(); // "Sit", "Kneel", "Sneak", "Walk", ""
String variant = context.getGlbVariant(); // "Idle" or "Struggle"
// 1. Exact match: "FullSitIdle" then "SitIdle" (with variants)
String exact = prefix + variant;
if (!exact.isEmpty()) {
String picked = pickWithVariants(data, "Full" + exact);
if (picked != null) return picked;
picked = pickWithVariants(data, exact);
if (picked != null) return picked;
}
// 2. For struggles: try "FullStruggle" then "Struggle" (with variants)
if (context.isStruggling()) {
String picked = pickWithVariants(data, "FullStruggle");
if (picked != null) return picked;
picked = pickWithVariants(data, "Struggle");
if (picked != null) return picked;
}
// 3. Context-only: "FullSit" then "Sit" (with variants)
if (!prefix.isEmpty()) {
String picked = pickWithVariants(data, "Full" + prefix);
if (picked != null) return picked;
picked = pickWithVariants(data, prefix);
if (picked != null) return picked;
}
// 4. Variant-only: "FullIdle" then "Idle" (with variants)
{
String picked = pickWithVariants(data, "Full" + variant);
if (picked != null) return picked;
picked = pickWithVariants(data, variant);
if (picked != null) return picked;
}
// 5. Default: return null = use first animation clip in GLB
return null;
}
/**
* Look for an animation by base name, including numbered variants.
* <ul>
* <li>If "Struggle" exists alone, return "Struggle"</li>
* <li>If "Struggle.1" and "Struggle.2" exist, pick one randomly</li>
* <li>If both "Struggle" and "Struggle.1" exist, include all in the random pool</li>
* </ul>
*
* <p>Variant numbering starts at 1 and tolerates a missing {@code .1}
* (continues to check {@code .2}). Gaps after index 1 stop the scan.
* For example, {@code Struggle.1, Struggle.3} would only find
* {@code Struggle.1} because the gap at index 2 stops iteration.
* However, if only {@code Struggle.2} exists (no {@code .1}), it will
* still be found because the scan skips the first gap.</p>
*
* @param data the parsed GLB data
* @param baseName the base animation name (e.g., "Struggle", "SitIdle")
* @return the selected animation name, or null if no match found
*/
@Nullable
private static String pickWithVariants(GltfData data, String baseName) {
Map<String, GltfData.AnimationClip> anims = data.namedAnimations();
List<String> candidates = new ArrayList<>();
if (anims.containsKey(baseName)) {
candidates.add(baseName);
}
// Check numbered variants: baseName.1, baseName.2, ...
for (int i = 1; i <= 99; i++) {
String variantName = baseName + "." + i;
if (anims.containsKey(variantName)) {
candidates.add(variantName);
} else if (i > 1) {
break; // Stop at first gap after .1
}
}
if (candidates.isEmpty()) return null;
if (candidates.size() == 1) return candidates.get(0);
return candidates.get(ThreadLocalRandom.current().nextInt(candidates.size()));
}
}

View File

@@ -0,0 +1,344 @@
package com.tiedup.remake.client.animation.context;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.IV2BondageItem;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemDefinition;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry;
import java.util.*;
import org.jetbrains.annotations.Nullable;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.item.ItemStack;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
/**
* Maps V2 body regions to PlayerAnimator part names.
* Bridge between gameplay regions and animation bones.
*
* <p>PlayerAnimator uses 6 named parts: head, body, rightArm, leftArm, rightLeg, leftLeg.
* This mapper translates the 14 {@link BodyRegionV2} gameplay regions into those bone names,
* enabling the animation system to know which bones are "owned" by equipped bondage items.</p>
*
* <p>Regions without a direct bone mapping (NECK, FINGERS, TAIL, WINGS) return empty sets.
* These regions still affect gameplay (blocking, escape difficulty) but don't directly
* constrain animation bones.</p>
*/
@OnlyIn(Dist.CLIENT)
public final class RegionBoneMapper {
/** All PlayerAnimator part names for the player model. */
public static final Set<String> ALL_PARTS = Set.of(
"head", "body", "rightArm", "leftArm", "rightLeg", "leftLeg"
);
/**
* Describes bone ownership for a specific item in the context of all equipped items.
*
* <ul>
* <li>{@code thisParts} — parts owned exclusively by the winning item</li>
* <li>{@code otherParts} — parts owned by other equipped items</li>
* <li>{@link #freeParts()} — parts not owned by any item (available for animation)</li>
* <li>{@link #enabledParts()} — parts the winning item may animate (owned + free)</li>
* </ul>
*
* <p>When both the winning item and another item claim the same bone,
* the other item takes precedence (the bone goes to {@code otherParts}).</p>
*/
public record BoneOwnership(Set<String> thisParts, Set<String> otherParts) {
/**
* Parts not owned by any item. These are "free" and can be animated
* by the winning item IF the GLB contains keyframes for them.
*/
public Set<String> freeParts() {
Set<String> free = new HashSet<>(ALL_PARTS);
free.removeAll(thisParts);
free.removeAll(otherParts);
return Collections.unmodifiableSet(free);
}
/**
* Parts the winning item is allowed to animate: its own parts + free parts.
* Free parts are only actually enabled if the GLB has keyframes for them.
*/
public Set<String> enabledParts() {
Set<String> enabled = new HashSet<>(thisParts);
enabled.addAll(freeParts());
return Collections.unmodifiableSet(enabled);
}
/**
* Parts that must be disabled on the context layer: parts owned by this item
* (handled by item layer) + parts owned by other items (handled by their layer).
* This equals ALL_PARTS minus freeParts.
*/
public Set<String> disabledOnContext() {
Set<String> disabled = new HashSet<>(thisParts);
disabled.addAll(otherParts);
return Collections.unmodifiableSet(disabled);
}
}
private static final Map<BodyRegionV2, Set<String>> REGION_TO_PARTS;
static {
Map<BodyRegionV2, Set<String>> map = new EnumMap<>(BodyRegionV2.class);
map.put(BodyRegionV2.HEAD, Set.of("head"));
map.put(BodyRegionV2.EYES, Set.of("head"));
map.put(BodyRegionV2.EARS, Set.of("head"));
map.put(BodyRegionV2.MOUTH, Set.of("head"));
map.put(BodyRegionV2.NECK, Set.of());
map.put(BodyRegionV2.TORSO, Set.of("body"));
map.put(BodyRegionV2.ARMS, Set.of("rightArm", "leftArm"));
map.put(BodyRegionV2.HANDS, Set.of("rightArm", "leftArm"));
map.put(BodyRegionV2.FINGERS, Set.of());
map.put(BodyRegionV2.WAIST, Set.of("body"));
map.put(BodyRegionV2.LEGS, Set.of("rightLeg", "leftLeg"));
map.put(BodyRegionV2.FEET, Set.of("rightLeg", "leftLeg"));
map.put(BodyRegionV2.TAIL, Set.of());
map.put(BodyRegionV2.WINGS, Set.of());
REGION_TO_PARTS = Collections.unmodifiableMap(map);
}
private RegionBoneMapper() {}
/**
* Get the PlayerAnimator part names affected by a single body region.
*
* @param region the V2 body region
* @return unmodifiable set of part name strings, never null (may be empty)
*/
public static Set<String> getPartsForRegion(BodyRegionV2 region) {
return REGION_TO_PARTS.getOrDefault(region, Set.of());
}
/**
* Compute the union of all PlayerAnimator parts "owned" by equipped bondage items.
*
* <p>Iterates over the equipped map (as returned by
* {@link com.tiedup.remake.v2.bondage.IV2BondageEquipment#getAllEquipped()})
* and collects every bone affected by each item's occupied regions.</p>
*
* @param equipped map from representative region to equipped ItemStack
* @return unmodifiable set of owned part name strings
*/
public static Set<String> computeOwnedParts(Map<BodyRegionV2, ItemStack> equipped) {
Set<String> owned = new HashSet<>();
for (Map.Entry<BodyRegionV2, ItemStack> entry : equipped.entrySet()) {
ItemStack stack = entry.getValue();
if (stack.getItem() instanceof IV2BondageItem v2Item) {
for (BodyRegionV2 region : v2Item.getOccupiedRegions(stack)) {
owned.addAll(getPartsForRegion(region));
}
}
}
return Collections.unmodifiableSet(owned);
}
/**
* Compute per-item bone ownership for a specific "winning" item.
*
* <p>Iterates over all equipped items. Parts owned by the winning item
* go to {@code thisParts}; parts owned by other items go to {@code otherParts}.
* If both the winning item and another item claim the same bone, the other
* item takes precedence (conflict resolution: other wins).</p>
*
* <p>Uses ItemStack reference equality ({@code ==}) to identify the winning item
* because the same ItemStack instance is used in the equipped map.</p>
*
* @param equipped map from representative region to equipped ItemStack
* @param winningItemStack the ItemStack of the highest-priority V2 item with a GLB model
* @return BoneOwnership describing this item's parts vs other items' parts
*/
public static BoneOwnership computePerItemParts(Map<BodyRegionV2, ItemStack> equipped,
ItemStack winningItemStack) {
Set<String> thisParts = new HashSet<>();
Set<String> otherParts = new HashSet<>();
// Track which ItemStacks we've already processed to avoid duplicate work
// (multiple regions can map to the same ItemStack)
Set<ItemStack> processed = Collections.newSetFromMap(new IdentityHashMap<>());
for (Map.Entry<BodyRegionV2, ItemStack> entry : equipped.entrySet()) {
ItemStack stack = entry.getValue();
if (processed.contains(stack)) continue;
processed.add(stack);
if (stack.getItem() instanceof IV2BondageItem v2Item) {
Set<String> itemParts = new HashSet<>();
for (BodyRegionV2 region : v2Item.getOccupiedRegions(stack)) {
itemParts.addAll(getPartsForRegion(region));
}
if (stack == winningItemStack) {
thisParts.addAll(itemParts);
} else {
otherParts.addAll(itemParts);
}
}
}
// Conflict resolution: if both this item and another claim the same bone,
// the other item takes precedence
thisParts.removeAll(otherParts);
return new BoneOwnership(
Collections.unmodifiableSet(thisParts),
Collections.unmodifiableSet(otherParts)
);
}
/**
* Result of resolving the highest-priority V2 item with a GLB model.
* Combines the model location, optional animation source, and the winning ItemStack
* into a single object so callers don't need two separate iteration passes.
*
* @param modelLoc the GLB model ResourceLocation of the winning item
* @param animSource separate GLB for animations (shared template), or null to use modelLoc
* @param winningItem the actual ItemStack reference (for identity comparison in
* {@link #computePerItemParts})
*/
public record GlbModelResult(ResourceLocation modelLoc, @Nullable ResourceLocation animSource,
ItemStack winningItem) {}
/**
* Animation info for a single equipped V2 item.
* Used by the multi-item animation pipeline to process each item independently.
*
* @param modelLoc GLB model location (for rendering + default animation source)
* @param animSource separate animation GLB, or null to use modelLoc
* @param ownedParts parts this item exclusively owns (after conflict resolution)
* @param posePriority the item's pose priority (for free-bone assignment)
* @param animationBones per-animation bone whitelist from the data-driven definition.
* Empty map for hardcoded items (no filtering applied).
*/
public record V2ItemAnimInfo(ResourceLocation modelLoc, @Nullable ResourceLocation animSource,
Set<String> ownedParts, int posePriority,
Map<String, Set<String>> animationBones) {}
/**
* Find the highest-priority V2 item with a GLB model in the equipped map.
*
* <p>Single pass over all equipped items, comparing their
* {@link IV2BondageItem#getPosePriority()} to select the dominant model.
* Returns both the model location and the winning ItemStack reference so
* callers can pass the ItemStack to {@link #computePerItemParts} without
* a second iteration.</p>
*
* @param equipped map of equipped V2 items by body region (may be empty, never null)
* @return the winning item's model info, or null if no V2 item has a GLB model (V1 fallback)
*/
@Nullable
public static GlbModelResult resolveWinningItem(Map<BodyRegionV2, ItemStack> equipped) {
ItemStack bestStack = null;
ResourceLocation bestModel = null;
int bestPriority = Integer.MIN_VALUE;
for (Map.Entry<BodyRegionV2, ItemStack> entry : equipped.entrySet()) {
ItemStack stack = entry.getValue();
if (stack.getItem() instanceof IV2BondageItem v2Item) {
ResourceLocation model = v2Item.getModelLocation(stack);
if (model != null && v2Item.getPosePriority(stack) > bestPriority) {
bestPriority = v2Item.getPosePriority(stack);
bestModel = model;
bestStack = stack;
}
}
}
if (bestStack == null || bestModel == null) return null;
// Extract animation source from data-driven item definitions.
// For hardcoded IV2BondageItem implementations, animSource stays null
// (the model's own animations are used).
ResourceLocation animSource = null;
if (bestStack.getItem() instanceof DataDrivenBondageItem) {
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(bestStack);
if (def != null) {
animSource = def.animationSource();
}
}
return new GlbModelResult(bestModel, animSource, bestStack);
}
/**
* Resolve ALL equipped V2 items with GLB models, with per-item bone ownership.
*
* <p>Each item gets ownership of its declared regions' bones. When two items claim
* the same bone, the higher-priority item wins. The highest-priority item is also
* designated as the "free bone donor" — it can animate free bones if its GLB has
* keyframes for them.</p>
*
* @param equipped map from representative region to equipped ItemStack
* @return list of V2ItemAnimInfo, sorted by priority descending. Empty if no V2 items.
* The first element (if any) is the free-bone donor.
*/
public static List<V2ItemAnimInfo> resolveAllV2Items(Map<BodyRegionV2, ItemStack> equipped) {
record ItemEntry(ItemStack stack, IV2BondageItem v2Item, ResourceLocation model,
@Nullable ResourceLocation animSource, Set<String> rawParts, int priority,
Map<String, Set<String>> animationBones) {}
List<ItemEntry> entries = new ArrayList<>();
Set<ItemStack> seen = Collections.newSetFromMap(new IdentityHashMap<>());
for (Map.Entry<BodyRegionV2, ItemStack> entry : equipped.entrySet()) {
ItemStack stack = entry.getValue();
if (seen.contains(stack)) continue;
seen.add(stack);
if (stack.getItem() instanceof IV2BondageItem v2Item) {
ResourceLocation model = v2Item.getModelLocation(stack);
if (model == null) continue;
Set<String> rawParts = new HashSet<>();
for (BodyRegionV2 region : v2Item.getOccupiedRegions(stack)) {
rawParts.addAll(getPartsForRegion(region));
}
if (rawParts.isEmpty()) continue;
ResourceLocation animSource = null;
Map<String, Set<String>> animBones = Map.of();
if (stack.getItem() instanceof DataDrivenBondageItem) {
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
if (def != null) {
animSource = def.animationSource();
animBones = def.animationBones();
}
}
entries.add(new ItemEntry(stack, v2Item, model, animSource, rawParts,
v2Item.getPosePriority(stack), animBones));
}
}
if (entries.isEmpty()) return List.of();
entries.sort((a, b) -> Integer.compare(b.priority(), a.priority()));
Set<String> claimed = new HashSet<>();
List<V2ItemAnimInfo> result = new ArrayList<>();
for (ItemEntry e : entries) {
Set<String> ownedParts = new HashSet<>(e.rawParts());
ownedParts.removeAll(claimed);
if (ownedParts.isEmpty()) continue;
claimed.addAll(ownedParts);
result.add(new V2ItemAnimInfo(e.model(), e.animSource(),
Collections.unmodifiableSet(ownedParts), e.priority(), e.animationBones()));
}
return Collections.unmodifiableList(result);
}
/**
* Compute the set of all bone parts owned by any item in the resolved list.
* Used to disable owned parts on the context layer.
*/
public static Set<String> computeAllOwnedParts(List<V2ItemAnimInfo> items) {
Set<String> allOwned = new HashSet<>();
for (V2ItemAnimInfo item : items) {
allOwned.addAll(item.ownedParts());
}
return Collections.unmodifiableSet(allOwned);
}
}

View File

@@ -0,0 +1,198 @@
package com.tiedup.remake.client.animation.render;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.items.base.ItemBind;
import com.tiedup.remake.items.base.PoseType;
import com.tiedup.remake.state.HumanChairHelper;
import com.tiedup.remake.state.PlayerBindState;
import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
import net.minecraft.client.player.AbstractClientPlayer;
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 net.minecraftforge.client.event.RenderPlayerEvent;
import net.minecraftforge.eventbus.api.EventPriority;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
/**
* Handles DOG and HUMAN_CHAIR pose rendering adjustments.
*
* <p>Applies vertical offset and smooth body rotation for DOG/HUMAN_CHAIR poses.
* Runs at HIGH priority to ensure transforms are applied before other Pre handlers.
*
* <p>Extracted from PlayerArmHideEventHandler for single-responsibility.
*/
@OnlyIn(Dist.CLIENT)
@Mod.EventBusSubscriber(
modid = TiedUpMod.MOD_ID,
bus = Mod.EventBusSubscriber.Bus.FORGE,
value = Dist.CLIENT
)
public class DogPoseRenderHandler {
/**
* DOG pose state tracking per player.
* Stores: [0: smoothedTarget, 1: currentRot, 2: appliedDelta, 3: isMoving (0/1)]
*/
private static final Int2ObjectMap<float[]> dogPoseState =
new Int2ObjectOpenHashMap<>();
// Array indices for dogPoseState
private static final int IDX_TARGET = 0;
private static final int IDX_CURRENT = 1;
private static final int IDX_DELTA = 2;
private static final int IDX_MOVING = 3;
/**
* Get the rotation delta applied to a player's render for DOG pose.
* Used by MixinPlayerModel to compensate head rotation.
*/
public static float getAppliedRotationDelta(int playerId) {
float[] state = dogPoseState.get(playerId);
return state != null ? state[IDX_DELTA] : 0f;
}
/**
* Check if a player is currently moving in DOG pose.
*/
public static boolean isDogPoseMoving(int playerId) {
float[] state = dogPoseState.get(playerId);
return state != null && state[IDX_MOVING] > 0.5f;
}
/**
* Clear all DOG pose state data.
* Called on world unload to prevent memory leaks.
*/
public static void clearState() {
dogPoseState.clear();
}
/**
* Before player render: Apply vertical offset and rotation for DOG/HUMAN_CHAIR poses.
* HIGH priority ensures this runs before arm/item hiding handlers.
*/
@SubscribeEvent(priority = EventPriority.HIGH)
public static void onRenderPlayerPre(RenderPlayerEvent.Pre event) {
Player player = event.getEntity();
if (!(player instanceof AbstractClientPlayer)) {
return;
}
if (player.isRemoved() || !player.isAlive()) {
return;
}
PlayerBindState state = PlayerBindState.getInstance(player);
if (state == null) {
return;
}
ItemStack bindForPose = state.getEquipment(BodyRegionV2.ARMS);
if (
bindForPose.isEmpty() ||
!(bindForPose.getItem() instanceof ItemBind itemBind)
) {
return;
}
PoseType bindPoseType = itemBind.getPoseType();
// Check for humanChairMode NBT override
bindPoseType = HumanChairHelper.resolveEffectivePose(
bindPoseType,
bindForPose
);
if (
bindPoseType != PoseType.DOG && bindPoseType != PoseType.HUMAN_CHAIR
) {
return;
}
// Lower player by 6 model units (6/16 = 0.375 blocks)
event
.getPoseStack()
.translate(0, RenderConstants.DOG_AND_PETBED_Y_OFFSET, 0);
int playerId = player.getId();
net.minecraft.world.phys.Vec3 movement = player.getDeltaMovement();
boolean isMoving = movement.horizontalDistanceSqr() > 0.0001;
// Get or create state - initialize to current body rotation
float[] s = dogPoseState.get(playerId);
if (s == null) {
s = new float[] { player.yBodyRot, player.yBodyRot, 0f, 0f };
dogPoseState.put(playerId, s);
}
// Human chair: lock rotation state — body must not turn
if (bindPoseType == PoseType.HUMAN_CHAIR) {
s[IDX_CURRENT] = player.yBodyRot;
s[IDX_TARGET] = player.yBodyRot;
s[IDX_DELTA] = 0f;
s[IDX_MOVING] = 0f;
} else {
// Determine target rotation
float rawTarget;
if (isMoving) {
// Moving: face movement direction
rawTarget = (float) Math.toDegrees(
Math.atan2(-movement.x, movement.z)
);
} else {
// Stationary: face where head is looking
rawTarget = player.yHeadRot;
}
// Check if head would be clamped (body lagging behind head)
float predictedHeadYaw = net.minecraft.util.Mth.wrapDegrees(
player.yHeadRot - s[IDX_CURRENT]
);
float maxYaw = isMoving
? RenderConstants.HEAD_MAX_YAW_MOVING
: RenderConstants.HEAD_MAX_YAW_STATIONARY;
boolean headAtLimit =
Math.abs(predictedHeadYaw) >
maxYaw * RenderConstants.HEAD_AT_LIMIT_RATIO;
if (headAtLimit && !isMoving) {
// Head at limit while stationary: snap body to release head
float sign = predictedHeadYaw > 0 ? 1f : -1f;
s[IDX_CURRENT] =
player.yHeadRot -
sign * maxYaw * RenderConstants.HEAD_SNAP_RELEASE_RATIO;
s[IDX_TARGET] = s[IDX_CURRENT];
} else {
// Normal smoothing
float targetDelta = net.minecraft.util.Mth.wrapDegrees(
rawTarget - s[IDX_TARGET]
);
float targetSpeed = isMoving
? RenderConstants.DOG_TARGET_SPEED_MOVING
: RenderConstants.DOG_TARGET_SPEED_STATIONARY;
s[IDX_TARGET] += targetDelta * targetSpeed;
float rotDelta = net.minecraft.util.Mth.wrapDegrees(
s[IDX_TARGET] - s[IDX_CURRENT]
);
float speed = isMoving
? RenderConstants.DOG_ROT_SPEED_MOVING
: RenderConstants.DOG_ROT_SPEED_STATIONARY;
s[IDX_CURRENT] += rotDelta * speed;
}
}
// Calculate and store the delta we apply to poseStack
s[IDX_DELTA] = player.yBodyRot - s[IDX_CURRENT];
s[IDX_MOVING] = isMoving ? 1f : 0f;
// Apply rotation to make body face our custom direction
event
.getPoseStack()
.mulPose(com.mojang.math.Axis.YP.rotationDegrees(s[IDX_DELTA]));
}
}

View File

@@ -0,0 +1,51 @@
package com.tiedup.remake.client.animation.render;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.state.PlayerBindState;
import net.minecraft.client.Minecraft;
import net.minecraft.client.player.LocalPlayer;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import net.minecraftforge.client.event.RenderHandEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
/**
* Hide first-person hand/item rendering based on bondage state.
*
* Behavior:
* - Tied up: Hide hands completely (hands are behind back)
* - Mittens: Hide hands + items (Forge limitation - can't separate them)
*/
@OnlyIn(Dist.CLIENT)
@Mod.EventBusSubscriber(
modid = TiedUpMod.MOD_ID,
bus = Mod.EventBusSubscriber.Bus.FORGE,
value = Dist.CLIENT
)
public class FirstPersonHandHideHandler {
@SubscribeEvent
public static void onRenderHand(RenderHandEvent event) {
Minecraft mc = Minecraft.getInstance();
if (mc == null) {
return;
}
LocalPlayer player = mc.player;
if (player == null) {
return;
}
PlayerBindState state = PlayerBindState.getInstance(player);
if (state == null) {
return;
}
// Tied or Mittens: hide hands completely
// (Forge limitation: RenderHandEvent controls hand + item together)
if (state.isTiedUp() || state.hasMittens()) {
event.setCanceled(true);
}
}
}

View File

@@ -0,0 +1,93 @@
package com.tiedup.remake.client.animation.render;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.state.PlayerBindState;
import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
import net.minecraft.client.player.AbstractClientPlayer;
import net.minecraft.world.InteractionHand;
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 net.minecraftforge.client.event.RenderPlayerEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
/**
* Hides held items when player has arms bound or is wearing mittens.
*
* <p>Uses Pre/Post pattern to temporarily replace held items with empty
* stacks for rendering, then restore them after.
*
* <p>Extracted from PlayerArmHideEventHandler for single-responsibility.
*/
@OnlyIn(Dist.CLIENT)
@Mod.EventBusSubscriber(
modid = TiedUpMod.MOD_ID,
bus = Mod.EventBusSubscriber.Bus.FORGE,
value = Dist.CLIENT
)
public class HeldItemHideHandler {
/**
* Stored items to restore after rendering.
* Key: Player entity ID (int), Value: [mainHand, offHand]
*/
private static final Int2ObjectMap<ItemStack[]> storedItems =
new Int2ObjectOpenHashMap<>();
@SubscribeEvent
public static void onRenderPlayerPre(RenderPlayerEvent.Pre event) {
Player player = event.getEntity();
if (!(player instanceof AbstractClientPlayer)) {
return;
}
if (player.isRemoved() || !player.isAlive()) {
return;
}
PlayerBindState state = PlayerBindState.getInstance(player);
if (state == null) {
return;
}
boolean hasArmsBound = state.hasArmsBound();
boolean hasMittens = state.hasMittens();
if (hasArmsBound || hasMittens) {
ItemStack mainHand = player.getItemInHand(
InteractionHand.MAIN_HAND
);
ItemStack offHand = player.getItemInHand(InteractionHand.OFF_HAND);
if (!mainHand.isEmpty() || !offHand.isEmpty()) {
storedItems.put(
player.getId(),
new ItemStack[] { mainHand.copy(), offHand.copy() }
);
player.setItemInHand(
InteractionHand.MAIN_HAND,
ItemStack.EMPTY
);
player.setItemInHand(InteractionHand.OFF_HAND, ItemStack.EMPTY);
}
}
}
@SubscribeEvent
public static void onRenderPlayerPost(RenderPlayerEvent.Post event) {
Player player = event.getEntity();
if (!(player instanceof AbstractClientPlayer)) {
return;
}
ItemStack[] items = storedItems.remove(player.getId());
if (items != null) {
player.setItemInHand(InteractionHand.MAIN_HAND, items[0]);
player.setItemInHand(InteractionHand.OFF_HAND, items[1]);
}
}
}

View File

@@ -0,0 +1,111 @@
package com.tiedup.remake.client.animation.render;
import com.tiedup.remake.client.state.PetBedClientState;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.items.base.ItemBind;
import com.tiedup.remake.items.base.PoseType;
import com.tiedup.remake.state.HumanChairHelper;
import com.tiedup.remake.state.PlayerBindState;
import net.minecraft.client.player.AbstractClientPlayer;
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 net.minecraftforge.client.event.RenderPlayerEvent;
import net.minecraftforge.eventbus.api.EventPriority;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
/**
* Handles pet bed render adjustments (SIT and SLEEP modes).
*
* <p>Applies vertical offset and forced standing pose for pet bed states.
* Runs at HIGH priority alongside DogPoseRenderHandler.
*
* <p>Extracted from PlayerArmHideEventHandler for single-responsibility.
*/
@OnlyIn(Dist.CLIENT)
@Mod.EventBusSubscriber(
modid = TiedUpMod.MOD_ID,
bus = Mod.EventBusSubscriber.Bus.FORGE,
value = Dist.CLIENT
)
public class PetBedRenderHandler {
/**
* Before player render: Apply vertical offset and forced pose for pet bed.
*/
@SubscribeEvent(priority = EventPriority.HIGH)
public static void onRenderPlayerPre(RenderPlayerEvent.Pre event) {
Player player = event.getEntity();
if (!(player instanceof AbstractClientPlayer)) {
return;
}
if (player.isRemoved() || !player.isAlive()) {
return;
}
java.util.UUID petBedUuid = player.getUUID();
byte petBedMode = PetBedClientState.get(petBedUuid);
if (petBedMode == 1 || petBedMode == 2) {
// Skip Y-offset if DogPoseRenderHandler already applies it
// (DOG/HUMAN_CHAIR pose uses the same offset amount)
if (!isDogOrChairPose(player)) {
event
.getPoseStack()
.translate(0, RenderConstants.DOG_AND_PETBED_Y_OFFSET, 0);
}
}
if (petBedMode == 2) {
// SLEEP: force STANDING pose to prevent vanilla sleeping rotation
player.setForcedPose(net.minecraft.world.entity.Pose.STANDING);
// Compensate for vanilla sleeping Y offset
player
.getSleepingPos()
.ifPresent(pos -> {
double yOffset = player.getY() - pos.getY();
if (yOffset > 0.01) {
event.getPoseStack().translate(0, -yOffset, 0);
}
});
}
}
/**
* Check if the player is in DOG or HUMAN_CHAIR pose.
* Used to avoid double Y-offset with DogPoseRenderHandler.
*/
private static boolean isDogOrChairPose(Player player) {
PlayerBindState state = PlayerBindState.getInstance(player);
if (state == null) return false;
ItemStack bind = state.getEquipment(BodyRegionV2.ARMS);
if (
bind.isEmpty() || !(bind.getItem() instanceof ItemBind itemBind)
) return false;
PoseType pose = HumanChairHelper.resolveEffectivePose(
itemBind.getPoseType(),
bind
);
return pose == PoseType.DOG || pose == PoseType.HUMAN_CHAIR;
}
/**
* After player render: Restore forced pose for pet bed SLEEP mode.
*/
@SubscribeEvent
public static void onRenderPlayerPost(RenderPlayerEvent.Post event) {
Player player = event.getEntity();
if (!(player instanceof AbstractClientPlayer)) {
return;
}
byte petBedMode = PetBedClientState.get(player.getUUID());
if (petBedMode == 2) {
player.setForcedPose(null);
}
}
}

View File

@@ -0,0 +1,135 @@
package com.tiedup.remake.client.animation.render;
import com.tiedup.remake.client.renderer.layers.ClothesRenderHelper;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.items.base.ItemBind;
import com.tiedup.remake.items.base.PoseType;
import com.tiedup.remake.items.clothes.ClothesProperties;
import com.tiedup.remake.state.PlayerBindState;
import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
import net.minecraft.client.model.PlayerModel;
import net.minecraft.client.player.AbstractClientPlayer;
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 net.minecraftforge.client.event.RenderPlayerEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
/**
* Hide player arms and outer layers based on bondage/clothes state.
*
* <p>Responsibilities (after extraction of dog pose, pet bed, and held items):
* <ul>
* <li>Hide arms for wrap/latex_sack poses</li>
* <li>Hide outer layers (hat, jacket, sleeves, pants) based on clothes settings</li>
* </ul>
*
* <p>Uses Pre/Post pattern to temporarily modify and restore state.
*/
@OnlyIn(Dist.CLIENT)
@Mod.EventBusSubscriber(
modid = TiedUpMod.MOD_ID,
bus = Mod.EventBusSubscriber.Bus.FORGE,
value = Dist.CLIENT
)
public class PlayerArmHideEventHandler {
/**
* Stored layer visibility to restore after rendering.
* Key: Player entity ID (int), Value: [hat, jacket, leftSleeve, rightSleeve, leftPants, rightPants]
*/
private static final Int2ObjectMap<boolean[]> storedLayers =
new Int2ObjectOpenHashMap<>();
/**
* Before player render:
* - Hide arms for wrap/latex_sack poses
* - Hide outer layers based on clothes settings (Phase 19)
*/
@SubscribeEvent
public static void onRenderPlayerPre(RenderPlayerEvent.Pre event) {
Player player = event.getEntity();
if (!(player instanceof AbstractClientPlayer clientPlayer)) {
return;
}
if (player.isRemoved() || !player.isAlive()) {
return;
}
PlayerBindState state = PlayerBindState.getInstance(player);
if (state == null) {
return;
}
PlayerModel<?> model = event.getRenderer().getModel();
// === HIDE ARMS (wrap/latex_sack poses) ===
if (state.hasArmsBound()) {
ItemStack bind = state.getEquipment(BodyRegionV2.ARMS);
if (
!bind.isEmpty() && bind.getItem() instanceof ItemBind itemBind
) {
PoseType poseType = itemBind.getPoseType();
// Only hide arms for wrap/sack poses (arms are covered by the item)
if (
poseType == PoseType.WRAP || poseType == PoseType.LATEX_SACK
) {
model.leftArm.visible = false;
model.rightArm.visible = false;
model.leftSleeve.visible = false;
model.rightSleeve.visible = false;
}
}
}
// === HIDE WEARER LAYERS (clothes settings) - Phase 19 ===
ItemStack clothes = state.getEquipment(BodyRegionV2.TORSO);
if (!clothes.isEmpty()) {
ClothesProperties props =
ClothesRenderHelper.getPropsForLayerHiding(
clothes,
clientPlayer
);
if (props != null) {
boolean[] savedLayers = ClothesRenderHelper.hideWearerLayers(
model,
props
);
if (savedLayers != null) {
storedLayers.put(player.getId(), savedLayers);
}
}
}
}
/**
* After player render: Restore arm visibility and layer visibility.
*/
@SubscribeEvent
public static void onRenderPlayerPost(RenderPlayerEvent.Post event) {
Player player = event.getEntity();
if (!(player instanceof AbstractClientPlayer)) {
return;
}
PlayerModel<?> model = event.getRenderer().getModel();
// === RESTORE ARM VISIBILITY ===
model.leftArm.visible = true;
model.rightArm.visible = true;
model.leftSleeve.visible = true;
model.rightSleeve.visible = true;
// === RESTORE WEARER LAYERS - Phase 19 ===
boolean[] savedLayers = storedLayers.remove(player.getId());
if (savedLayers != null) {
ClothesRenderHelper.restoreWearerLayers(model, savedLayers);
}
}
}

View File

@@ -0,0 +1,58 @@
package com.tiedup.remake.client.animation.render;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
/**
* Centralizes magic numbers used across render handlers.
*
* <p>DOG pose rotation smoothing, head clamp limits, and vertical offsets
* that were previously scattered as unnamed literals.
*/
@OnlyIn(Dist.CLIENT)
public final class RenderConstants {
private RenderConstants() {}
// === DOG pose rotation smoothing speeds ===
/** Speed for smoothing body rotation toward target while moving */
public static final float DOG_ROT_SPEED_MOVING = 0.15f;
/** Speed for smoothing body rotation toward target while stationary */
public static final float DOG_ROT_SPEED_STATIONARY = 0.12f;
/** Speed for smoothing target rotation while moving */
public static final float DOG_TARGET_SPEED_MOVING = 0.2f;
/** Speed for smoothing target rotation while stationary */
public static final float DOG_TARGET_SPEED_STATIONARY = 0.3f;
// === Head clamp limits ===
/** Maximum head yaw relative to body while moving (degrees) */
public static final float HEAD_MAX_YAW_MOVING = 60f;
/** Maximum head yaw relative to body while stationary (degrees) */
public static final float HEAD_MAX_YAW_STATIONARY = 90f;
/** Threshold ratio for detecting head-at-limit (triggers body snap) */
public static final float HEAD_AT_LIMIT_RATIO = 0.85f;
/** Ratio of max yaw to snap body to when releasing head */
public static final float HEAD_SNAP_RELEASE_RATIO = 0.7f;
// === Vertical offsets (model units, 16 = 1 block) ===
/** Y offset for DOG and PET BED poses (6/16 = 0.375 blocks) */
public static final double DOG_AND_PETBED_Y_OFFSET = -6.0 / 16.0;
/** Y offset for Damsel sitting pose (model units) */
public static final float DAMSEL_SIT_OFFSET = -10.0f;
/** Y offset for Damsel kneeling pose (model units) */
public static final float DAMSEL_KNEEL_OFFSET = -5.0f;
/** Y offset for Damsel dog pose (model units) */
public static final float DAMSEL_DOG_OFFSET = -7.0f;
}

View File

@@ -0,0 +1,318 @@
package com.tiedup.remake.client.animation.tick;
import com.mojang.logging.LogUtils;
import com.tiedup.remake.client.animation.AnimationStateRegistry;
import com.tiedup.remake.client.animation.BondageAnimationManager;
import com.tiedup.remake.client.animation.PendingAnimationManager;
import com.tiedup.remake.client.animation.util.AnimationIdBuilder;
import com.tiedup.remake.client.events.CellHighlightHandler;
import com.tiedup.remake.client.events.LeashProxyClientHandler;
import com.tiedup.remake.client.gltf.GltfAnimationApplier;
import com.tiedup.remake.client.state.ClothesClientCache;
import com.tiedup.remake.client.state.MovementStyleClientState;
import com.tiedup.remake.client.state.PetBedClientState;
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.IV2BondageEquipment;
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
import com.tiedup.remake.v2.bondage.movement.MovementStyle;
import com.tiedup.remake.client.animation.context.AnimationContext;
import com.tiedup.remake.client.animation.context.AnimationContextResolver;
import com.tiedup.remake.client.animation.context.RegionBoneMapper;
import com.tiedup.remake.state.HumanChairHelper;
import com.tiedup.remake.state.PlayerBindState;
import java.util.Map;
import java.util.UUID;
import net.minecraft.client.Minecraft;
import net.minecraft.client.player.AbstractClientPlayer;
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 net.minecraftforge.event.TickEvent;
import net.minecraftforge.event.entity.player.PlayerEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
import org.slf4j.Logger;
/**
* Event handler for player animation tick updates.
*
* <p>Simplified handler that:
* <ul>
* <li>Tracks tied/struggling/sneaking state for players</li>
* <li>Plays animations via BondageAnimationManager when state changes</li>
* <li>Handles cleanup on logout/world unload</li>
* </ul>
*
* <p>Registered on the FORGE event bus (not MOD bus).
*/
@Mod.EventBusSubscriber(
modid = "tiedup",
value = Dist.CLIENT,
bus = Mod.EventBusSubscriber.Bus.FORGE
)
@OnlyIn(Dist.CLIENT)
public class AnimationTickHandler {
private static final Logger LOGGER = LogUtils.getLogger();
/** Tick counter for periodic cleanup tasks */
private static int cleanupTickCounter = 0;
/**
* Client tick event - called every tick on the client.
* Updates animations for all players when their bondage state changes.
*/
@SubscribeEvent
public static void onClientTick(TickEvent.ClientTickEvent event) {
if (event.phase != TickEvent.Phase.END) {
return;
}
Minecraft mc = Minecraft.getInstance();
if (mc.level == null || mc.isPaused()) {
return;
}
// Process pending animations first (retry failed animations for remote players)
PendingAnimationManager.processPending(mc.level);
// Periodic cleanup of stale cache entries (every 60 seconds = 1200 ticks)
if (++cleanupTickCounter >= 1200) {
cleanupTickCounter = 0;
ClothesClientCache.cleanupStale();
}
// Then update all player animations
for (Player player : mc.level.players()) {
if (player instanceof AbstractClientPlayer clientPlayer) {
updatePlayerAnimation(clientPlayer);
}
// Safety: remove stale furniture animations for players no longer on seats
BondageAnimationManager.tickFurnitureSafety(player);
}
}
/**
* Update animation for a single player.
*/
private static void updatePlayerAnimation(AbstractClientPlayer player) {
// Safety check: skip for removed/dead players
if (player.isRemoved() || !player.isAlive()) {
return;
}
PlayerBindState state = PlayerBindState.getInstance(player);
UUID uuid = player.getUUID();
// Check if player has ANY V2 bondage item equipped (not just ARMS).
// isTiedUp() only checks ARMS, but items on LEGS, HEAD, etc. also need animation.
boolean isTied = state != null && (state.isTiedUp()
|| V2EquipmentHelper.hasAnyEquipment(player));
boolean wasTied =
AnimationStateRegistry.getLastTiedState().getOrDefault(uuid, false);
// Pet bed animations take priority over bondage animations
if (PetBedClientState.get(uuid) != 0) {
// Lock body rotation to bed facing (prevents camera from rotating the model)
float lockedRot = PetBedClientState.getFacing(uuid);
player.yBodyRot = lockedRot;
player.yBodyRotO = lockedRot;
// Clamp head rotation to ±50° from body (like vehicle)
float headRot = player.getYHeadRot();
float clamped =
lockedRot +
net.minecraft.util.Mth.clamp(
net.minecraft.util.Mth.wrapDegrees(headRot - lockedRot),
-50f,
50f
);
player.setYHeadRot(clamped);
player.yHeadRotO = clamped;
AnimationStateRegistry.getLastTiedState().put(uuid, isTied);
return;
}
// Human chair: clamp 1st-person camera only (body lock handled by MixinLivingEntityBodyRot)
// NO return — animation HUMAN_CHAIR must continue playing below
if (isTied && state != null) {
ItemStack chairBind = state.getEquipment(BodyRegionV2.ARMS);
if (HumanChairHelper.isActive(chairBind)) {
// 1st person only: clamp yRot so player can't look behind
// 3rd person: yRot untouched → camera orbits freely 360°
if (
player == Minecraft.getInstance().player &&
Minecraft.getInstance().options.getCameraType() ==
net.minecraft.client.CameraType.FIRST_PERSON
) {
float lockedRot = HumanChairHelper.getFacing(chairBind);
float camClamped =
lockedRot +
net.minecraft.util.Mth.clamp(
net.minecraft.util.Mth.wrapDegrees(
player.getYRot() - lockedRot
),
-90f,
90f
);
player.setYRot(camClamped);
player.yRotO =
lockedRot +
net.minecraft.util.Mth.clamp(
net.minecraft.util.Mth.wrapDegrees(
player.yRotO - lockedRot
),
-90f,
90f
);
}
}
}
if (isTied) {
// Resolve V2 equipped items
IV2BondageEquipment equipment = V2EquipmentHelper.getEquipment(player);
Map<BodyRegionV2, ItemStack> equipped = equipment != null
? equipment.getAllEquipped() : Map.of();
// Resolve ALL V2 items with GLB models and per-item bone ownership
java.util.List<RegionBoneMapper.V2ItemAnimInfo> v2Items =
RegionBoneMapper.resolveAllV2Items(equipped);
if (!v2Items.isEmpty()) {
// V2 path: multi-item composite animation
java.util.Set<String> allOwnedParts = RegionBoneMapper.computeAllOwnedParts(v2Items);
MovementStyle activeStyle = MovementStyleClientState.get(player.getUUID());
AnimationContext context = AnimationContextResolver.resolve(player, state, activeStyle);
GltfAnimationApplier.applyMultiItemV2Animation(player, v2Items, context, allOwnedParts);
// Clear V1 tracking so transition back works
AnimationStateRegistry.getLastAnimId().remove(uuid);
} else {
// V1 fallback
if (GltfAnimationApplier.hasActiveState(player)) {
GltfAnimationApplier.clearV2Animation(player);
}
String animId = buildAnimationId(player, state);
String lastId = AnimationStateRegistry.getLastAnimId().get(uuid);
if (!animId.equals(lastId)) {
boolean success = BondageAnimationManager.playAnimation(player, animId);
if (success) {
AnimationStateRegistry.getLastAnimId().put(uuid, animId);
}
}
}
} else if (wasTied) {
// Was tied, now free - stop all animations
if (GltfAnimationApplier.hasActiveState(player)) {
GltfAnimationApplier.clearV2Animation(player);
} else {
BondageAnimationManager.stopAnimation(player);
}
AnimationStateRegistry.getLastAnimId().remove(uuid);
}
AnimationStateRegistry.getLastTiedState().put(uuid, isTied);
}
/**
* Build animation ID from player's current state (V1 path).
*/
private static String buildAnimationId(
Player player,
PlayerBindState state
) {
ItemStack bind = state.getEquipment(BodyRegionV2.ARMS);
PoseType poseType = PoseType.STANDARD;
if (bind.getItem() instanceof ItemBind itemBind) {
poseType = itemBind.getPoseType();
// Human chair mode: override DOG pose to HUMAN_CHAIR (straight limbs)
poseType = HumanChairHelper.resolveEffectivePose(poseType, bind);
}
// Derive bound state from V2 regions (works client-side, synced via capability)
boolean armsBound = V2EquipmentHelper.isRegionOccupied(player, BodyRegionV2.ARMS);
boolean legsBound = V2EquipmentHelper.isRegionOccupied(player, BodyRegionV2.LEGS);
// V1 fallback: if no V2 regions are set but player is tied, derive from ItemBind NBT
if (!armsBound && !legsBound && bind.getItem() instanceof ItemBind) {
armsBound = ItemBind.hasArmsBound(bind);
legsBound = ItemBind.hasLegsBound(bind);
}
boolean isStruggling = state.isStruggling();
boolean isSneaking = player.isCrouching();
boolean isMoving =
player.getDeltaMovement().horizontalDistanceSqr() > 1e-6;
// Build animation ID with sneak and movement support
return AnimationIdBuilder.build(
poseType,
armsBound,
legsBound,
null,
isStruggling,
true,
isSneaking,
isMoving
);
}
/**
* Player logout event - cleanup animation data.
*/
@SubscribeEvent
public static void onPlayerLogout(PlayerEvent.PlayerLoggedOutEvent event) {
if (event.getEntity().level().isClientSide()) {
UUID uuid = event.getEntity().getUUID();
AnimationStateRegistry.getLastTiedState().remove(uuid);
AnimationStateRegistry.getLastAnimId().remove(uuid);
BondageAnimationManager.cleanup(uuid);
GltfAnimationApplier.removeTracking(uuid);
}
}
/**
* World unload event - clear all animation and cache data.
* FIX: Now also clears client-side caches to prevent memory leaks and stale data.
*/
@SubscribeEvent
public static void onWorldUnload(
net.minecraftforge.event.level.LevelEvent.Unload event
) {
if (event.getLevel().isClientSide()) {
// Animation state (includes BondageAnimationManager, PendingAnimationManager,
// DogPoseRenderHandler, MCAAnimationTickCache)
// AnimationStateRegistry.clearAll() handles GltfAnimationApplier.clearAll() transitively
AnimationStateRegistry.clearAll();
// Non-animation client-side caches
PetBedClientState.clearAll();
MovementStyleClientState.clearAll();
com.tiedup.remake.client.state.CollarRegistryClient.clear();
CellHighlightHandler.clearCache();
LeashProxyClientHandler.clearAll();
com.tiedup.remake.client.state.ClientLaborState.clearTask();
com.tiedup.remake.client.state.ClothesClientCache.clearAll();
com.tiedup.remake.client.texture.DynamicTextureManager.getInstance().clearAll();
// C1: Player bind state client instances (prevents stale Player references)
PlayerBindState.clearClientInstances();
// C2: Armor stand bondage data (entity IDs are not stable across worlds)
com.tiedup.remake.entities.armorstand.ArmorStandBondageClientCache.clear();
// C3: Furniture GLB model cache (resource-backed, also cleared on F3+T reload)
com.tiedup.remake.v2.furniture.client.FurnitureGltfCache.clear();
LOGGER.debug(
"Cleared all animation and cache data due to world unload"
);
}
}
}

View File

@@ -0,0 +1,51 @@
package com.tiedup.remake.client.animation.tick;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
/**
* Cache for MCA villager animation tick tracking.
* Used by MixinVillagerEntityBaseModelMCA to prevent animations from ticking
* multiple times per game tick.
*
* <p>This is extracted from the mixin so it can be cleared on world unload
* to prevent memory leaks.
*/
@OnlyIn(Dist.CLIENT)
public final class MCAAnimationTickCache {
private static final Map<UUID, Integer> lastTickMap = new HashMap<>();
private MCAAnimationTickCache() {
// Utility class
}
/**
* Get the last tick value for an entity.
* @param uuid Entity UUID
* @return Last tick value, or -1 if not cached
*/
public static int getLastTick(UUID uuid) {
return lastTickMap.getOrDefault(uuid, -1);
}
/**
* Set the last tick value for an entity.
* @param uuid Entity UUID
* @param tick Current tick value
*/
public static void setLastTick(UUID uuid, int tick) {
lastTickMap.put(uuid, tick);
}
/**
* Clear all cached data.
* Called on world unload to prevent memory leaks.
*/
public static void clear() {
lastTickMap.clear();
}
}

View File

@@ -0,0 +1,198 @@
package com.tiedup.remake.client.animation.tick;
import com.tiedup.remake.client.animation.BondageAnimationManager;
import com.tiedup.remake.client.animation.context.AnimationContext;
import com.tiedup.remake.client.animation.context.AnimationContextResolver;
import com.tiedup.remake.client.animation.context.RegionBoneMapper;
import com.tiedup.remake.client.animation.util.AnimationIdBuilder;
import com.tiedup.remake.client.gltf.GltfAnimationApplier;
import com.tiedup.remake.entities.AbstractTiedUpNpc;
import com.tiedup.remake.entities.EntityMaster;
import com.tiedup.remake.entities.ai.master.MasterState;
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.IV2BondageEquipment;
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.UUID;
import net.minecraft.client.Minecraft;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.item.ItemStack;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import net.minecraftforge.event.TickEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
/**
* Tick handler for NPC (AbstractTiedUpNpc) bondage animations.
*
* <p>Same pattern as AnimationTickHandler for players, but for loaded
* AbstractTiedUpNpc instances. Tracks last animation ID per NPC UUID and
* triggers BondageAnimationManager.playAnimation() on state changes.
*
* <p>Extracted from DamselModel.setupAnim() to decouple animation
* triggering from model rendering.
*/
@OnlyIn(Dist.CLIENT)
@Mod.EventBusSubscriber(
modid = "tiedup",
value = Dist.CLIENT,
bus = Mod.EventBusSubscriber.Bus.FORGE
)
public class NpcAnimationTickHandler {
/** Track last animation ID per NPC to avoid redundant updates */
private static final Map<UUID, String> lastNpcAnimId = new ConcurrentHashMap<>();
/**
* Client tick: update animations for all loaded AbstractTiedUpNpc instances.
*/
@SubscribeEvent
public static void onClientTick(TickEvent.ClientTickEvent event) {
if (event.phase != TickEvent.Phase.END) {
return;
}
Minecraft mc = Minecraft.getInstance();
if (mc.level == null || mc.isPaused()) {
return;
}
for (Entity entity : mc.level.entitiesForRendering()) {
if (
entity instanceof AbstractTiedUpNpc damsel &&
entity.isAlive() &&
!entity.isRemoved()
) {
updateNpcAnimation(damsel);
}
}
}
/**
* Update animation for a single NPC.
*
* <p>Dual-layer V2 path: if the highest-priority equipped V2 item has a GLB model,
* uses {@link GltfAnimationApplier#applyV2Animation} which plays a context layer
* (base posture) and an item layer (GLB-driven bones). Sitting and kneeling are
* handled by the context resolver, so the V2 path now covers all postures.
*
* <p>V1 fallback: if no V2 GLB model is found, falls back to JSON-based
* PlayerAnimator animations via {@link BondageAnimationManager}.
*/
private static void updateNpcAnimation(AbstractTiedUpNpc entity) {
boolean inPose =
entity.isTiedUp() || entity.isSitting() || entity.isKneeling();
UUID uuid = entity.getUUID();
if (inPose) {
// Resolve V2 equipment map
IV2BondageEquipment equipment = V2EquipmentHelper.getEquipment(entity);
Map<BodyRegionV2, net.minecraft.world.item.ItemStack> equipped = equipment != null
? equipment.getAllEquipped() : Map.of();
RegionBoneMapper.GlbModelResult glbResult = RegionBoneMapper.resolveWinningItem(equipped);
if (glbResult != null) {
// V2 path: dual-layer animation with per-item bone ownership
RegionBoneMapper.BoneOwnership ownership =
RegionBoneMapper.computePerItemParts(equipped, glbResult.winningItem());
AnimationContext context = AnimationContextResolver.resolveNpc(entity);
GltfAnimationApplier.applyV2Animation(entity, glbResult.modelLoc(),
glbResult.animSource(), context, ownership);
lastNpcAnimId.remove(uuid);
} else {
// V1 fallback: JSON-based PlayerAnimator animations
if (GltfAnimationApplier.hasActiveState(entity)) {
GltfAnimationApplier.clearV2Animation(entity);
}
String animId = buildNpcAnimationId(entity);
String lastId = lastNpcAnimId.get(uuid);
if (!animId.equals(lastId)) {
BondageAnimationManager.playAnimation(entity, animId);
lastNpcAnimId.put(uuid, animId);
}
}
} else {
if (lastNpcAnimId.containsKey(uuid) || GltfAnimationApplier.hasActiveState(entity)) {
if (GltfAnimationApplier.hasActiveState(entity)) {
GltfAnimationApplier.clearV2Animation(entity);
} else {
BondageAnimationManager.stopAnimation(entity);
}
lastNpcAnimId.remove(uuid);
}
}
}
/**
* Build animation ID for an NPC from its current state (V1 path).
*/
private static String buildNpcAnimationId(AbstractTiedUpNpc entity) {
// Determine position prefix for SIT/KNEEL poses
String positionPrefix = null;
if (entity.isSitting()) {
positionPrefix = "sit";
} else if (entity.isKneeling()) {
positionPrefix = "kneel";
}
net.minecraft.world.item.ItemStack bind = entity.getEquipment(BodyRegionV2.ARMS);
PoseType poseType = PoseType.STANDARD;
boolean hasBind = false;
if (bind.getItem() instanceof ItemBind itemBind) {
poseType = itemBind.getPoseType();
hasBind = true;
}
// Derive bound state from V2 regions (AbstractTiedUpNpc implements IV2EquipmentHolder)
boolean armsBound = V2EquipmentHelper.isRegionOccupied(entity, BodyRegionV2.ARMS);
boolean legsBound = V2EquipmentHelper.isRegionOccupied(entity, BodyRegionV2.LEGS);
// V1 fallback: if no V2 regions set but NPC has a bind, derive from ItemBind NBT
if (!armsBound && !legsBound && bind.getItem() instanceof ItemBind) {
armsBound = ItemBind.hasArmsBound(bind);
legsBound = ItemBind.hasLegsBound(bind);
}
boolean isStruggling = entity.isStruggling();
boolean isSneaking = entity.isCrouching();
boolean isMoving =
entity.getDeltaMovement().horizontalDistanceSqr() > 1e-6;
String animId = AnimationIdBuilder.build(
poseType,
armsBound,
legsBound,
positionPrefix,
isStruggling,
hasBind,
isSneaking,
isMoving
);
// Master NPC sitting on human chair: use dedicated sitting animation
if (
entity instanceof EntityMaster masterEntity &&
masterEntity.getMasterState() == MasterState.HUMAN_CHAIR &&
masterEntity.isSitting()
) {
animId = "master_chair_sit_idle";
}
return animId;
}
/**
* Clear all NPC animation state.
* Called on world unload to prevent memory leaks.
*/
public static void clearAll() {
lastNpcAnimId.clear();
}
}

View File

@@ -0,0 +1,273 @@
package com.tiedup.remake.client.animation.util;
import com.tiedup.remake.items.base.PoseType;
import net.minecraft.resources.ResourceLocation;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
/**
* Utility class for building animation ResourceLocation IDs.
*
* <p>Centralizes the logic for constructing animation file names.
* Used by BondageAnimationManager, NpcAnimationTickHandler, and AnimationTickHandler.
*
* <p>Animation naming convention:
* <pre>
* {poseType}_{bindMode}_{variant}.json
*
* poseType: tied_up_basic | straitjacket | wrap | latex_sack
* bindMode: (empty for FULL) | _arms | _legs
* variant: _idle | _struggle | (empty for static)
* </pre>
*
* <p>Examples:
* <ul>
* <li>tiedup:tied_up_basic_idle - STANDARD + FULL + idle</li>
* <li>tiedup:straitjacket_arms_struggle - STRAITJACKET + ARMS + struggle</li>
* <li>tiedup:wrap_idle - WRAP + FULL + idle</li>
* </ul>
*/
@OnlyIn(Dist.CLIENT)
public final class AnimationIdBuilder {
private static final String NAMESPACE = "tiedup";
// Bind mode suffixes
private static final String SUFFIX_ARMS = "_arms";
private static final String SUFFIX_LEGS = "_legs";
// Variant suffixes
private static final String SUFFIX_IDLE = "_idle";
private static final String SUFFIX_WALK = "_walk";
private static final String SUFFIX_STRUGGLE = "_struggle";
private static final String SUFFIX_SNEAK = "_sneak";
private AnimationIdBuilder() {
// Utility class - no instantiation
}
/**
* Get base animation name from pose type.
* Delegates to {@link PoseType#getAnimationId()}.
*
* @param poseType Pose type
* @return Base name string
*/
public static String getBaseName(PoseType poseType) {
return poseType.getAnimationId();
}
/**
* Get suffix for bind mode derived from region flags.
*
* @param armsBound whether ARMS region is occupied
* @param legsBound whether LEGS region is occupied
* @return Suffix string: "" for FULL (both), "_arms" for arms-only, "_legs" for legs-only
*/
public static String getModeSuffix(boolean armsBound, boolean legsBound) {
if (armsBound && legsBound) return ""; // FULL has no suffix
if (armsBound) return SUFFIX_ARMS;
if (legsBound) return SUFFIX_LEGS;
return ""; // neither bound = no suffix (shouldn't happen in practice)
}
/**
* Get bind type name for SIT/KNEEL animations.
* Delegates to {@link PoseType#getBindTypeName()}.
*
* @param poseType Pose type
* @return Bind type name ("basic", "straitjacket", "wrap", "latex_sack")
*/
public static String getBindTypeName(PoseType poseType) {
return poseType.getBindTypeName();
}
// ========================================
// Unified Build Method
// ========================================
/**
* Build animation ID string for entities.
*
* <p>This method handles all cases:
* <ul>
* <li>Standing poses: tied_up_basic_idle, straitjacket_struggle, etc.</li>
* <li>Sitting poses: sit_basic_idle, sit_free_idle, etc.</li>
* <li>Kneeling poses: kneel_basic_idle, kneel_wrap_struggle, etc.</li>
* </ul>
*
* @param poseType The bind pose type (STANDARD, STRAITJACKET, etc.)
* @param armsBound whether ARMS region is occupied
* @param legsBound whether LEGS region is occupied
* @param positionPrefix Position prefix ("sit", "kneel") or null for standing
* @param isStruggling Whether entity is struggling
* @param hasBind Whether entity has a bind equipped
* @return Animation ID string (without namespace)
*/
public static String build(
PoseType poseType,
boolean armsBound,
boolean legsBound,
String positionPrefix,
boolean isStruggling,
boolean hasBind
) {
return build(
poseType,
armsBound,
legsBound,
positionPrefix,
isStruggling,
hasBind,
false
);
}
/**
* Build animation ID string for entities with sneak support.
*
* @param poseType The bind pose type (STANDARD, STRAITJACKET, etc.)
* @param armsBound whether ARMS region is occupied
* @param legsBound whether LEGS region is occupied
* @param positionPrefix Position prefix ("sit", "kneel") or null for standing
* @param isStruggling Whether entity is struggling
* @param hasBind Whether entity has a bind equipped
* @param isSneaking Whether entity is sneaking
* @return Animation ID string (without namespace)
*/
public static String build(
PoseType poseType,
boolean armsBound,
boolean legsBound,
String positionPrefix,
boolean isStruggling,
boolean hasBind,
boolean isSneaking
) {
return build(
poseType,
armsBound,
legsBound,
positionPrefix,
isStruggling,
hasBind,
isSneaking,
false
);
}
/**
* Build animation ID string for entities with sneak and movement support.
*
* @param poseType The bind pose type (STANDARD, STRAITJACKET, etc.)
* @param armsBound whether ARMS region is occupied
* @param legsBound whether LEGS region is occupied
* @param positionPrefix Position prefix ("sit", "kneel") or null for standing
* @param isStruggling Whether entity is struggling
* @param hasBind Whether entity has a bind equipped
* @param isSneaking Whether entity is sneaking
* @param isMoving Whether entity is moving
* @return Animation ID string (without namespace)
*/
public static String build(
PoseType poseType,
boolean armsBound,
boolean legsBound,
String positionPrefix,
boolean isStruggling,
boolean hasBind,
boolean isSneaking,
boolean isMoving
) {
String sneakSuffix = isSneaking ? SUFFIX_SNEAK : "";
// Determine variant suffix based on state priority: struggle > walk > idle
String variantSuffix;
if (isStruggling) {
variantSuffix = SUFFIX_STRUGGLE;
} else if (isMoving && poseType == PoseType.DOG) {
// DOG pose has a walking animation (tied_up_dog_walk.json)
variantSuffix = SUFFIX_WALK;
} else {
variantSuffix = SUFFIX_IDLE;
}
// SIT or KNEEL pose
if (positionPrefix != null) {
if (!hasBind) {
// No bind: free pose (arms natural)
return positionPrefix + "_free" + sneakSuffix + variantSuffix;
}
// Has bind
String bindTypeName;
if (legsBound && !armsBound) {
// LEGS-only mode = arms free
bindTypeName = "legs";
} else {
// FULL or ARMS mode
bindTypeName = getBindTypeName(poseType);
}
return (
positionPrefix +
"_" +
bindTypeName +
sneakSuffix +
variantSuffix
);
}
// Standing pose (no position prefix)
String baseName = getBaseName(poseType);
String modeSuffix = getModeSuffix(armsBound, legsBound);
// LEGS-only mode: only lock legs, arms are free - no idle/struggle variants needed
if (legsBound && !armsBound) {
return baseName + modeSuffix;
}
return baseName + modeSuffix + sneakSuffix + variantSuffix;
}
/**
* Build animation ResourceLocation for SIT or KNEEL pose.
*
* @param posePrefix "sit" or "kneel"
* @param poseType Bind pose type
* @param armsBound whether ARMS region is occupied
* @param legsBound whether LEGS region is occupied
* @param isStruggling Whether entity is struggling
* @return Animation ResourceLocation
*/
public static ResourceLocation buildPositionAnimation(
String posePrefix,
PoseType poseType,
boolean armsBound,
boolean legsBound,
boolean isStruggling
) {
String bindTypeName;
if (legsBound && !armsBound) {
bindTypeName = "legs";
} else {
bindTypeName = getBindTypeName(poseType);
}
String variantSuffix = isStruggling ? SUFFIX_STRUGGLE : SUFFIX_IDLE;
String animationName = posePrefix + "_" + bindTypeName + variantSuffix;
return ResourceLocation.fromNamespaceAndPath(NAMESPACE, animationName);
}
/**
* Build animation ResourceLocation for SIT or KNEEL pose when NOT bound.
*
* @param posePrefix "sit" or "kneel"
* @return Animation ResourceLocation for free pose
*/
public static ResourceLocation buildFreePositionAnimation(
String posePrefix
) {
String animationName = posePrefix + "_free" + SUFFIX_IDLE;
return ResourceLocation.fromNamespaceAndPath(NAMESPACE, animationName);
}
}

View File

@@ -0,0 +1,149 @@
package com.tiedup.remake.client.animation.util;
import net.minecraft.client.model.geom.ModelPart;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
/**
* Utility class for DOG pose head compensation.
*
* <h2>Problem</h2>
* <p>When in DOG pose, the body is rotated -90° pitch (horizontal, face down).
* This makes the head point at the ground. We need to compensate:
* <ul>
* <li>Head pitch: add -90° offset so head looks forward</li>
* <li>Head yaw: convert to zRot (roll) since yRot axis is sideways</li>
* </ul>
*
* <h2>Architecture: Players vs NPCs</h2>
* <pre>
* ┌─────────────────────────────────────────────────────────────────┐
* │ PLAYERS │
* ├─────────────────────────────────────────────────────────────────┤
* │ 1. PlayerArmHideEventHandler.onRenderPlayerPre() │
* │ - Offset vertical (-6 model units) │
* │ - Rotation Y lissée (dogPoseState tracking) │
* │ │
* │ 2. Animation (PlayerAnimator) │
* │ - body.pitch = -90° → appliqué au PoseStack automatiquement │
* │ │
* │ 3. MixinPlayerModel.setupAnim() @TAIL │
* │ - Uses DogPoseHelper.applyHeadCompensationClamped() │
* └─────────────────────────────────────────────────────────────────┘
*
* ┌─────────────────────────────────────────────────────────────────┐
* │ NPCs │
* ├─────────────────────────────────────────────────────────────────┤
* │ 1. EntityDamsel.tick() │
* │ - Uses RotationSmoother for Y rotation (10% per tick) │
* │ │
* │ 2. DamselRenderer.setupRotations() │
* │ - super.setupRotations() (applique rotation Y) │
* │ - Rotation X -90° au PoseStack (APRÈS Y = espace local) │
* │ - Offset vertical (-7 model units) │
* │ │
* │ 3. DamselModel.setupAnim() │
* │ - body.xRot = 0 (évite double rotation) │
* │ - Uses DogPoseHelper.applyHeadCompensation() │
* └─────────────────────────────────────────────────────────────────┘
* </pre>
*
* <h2>Key Differences</h2>
* <table>
* <tr><th>Aspect</th><th>Players</th><th>NPCs</th></tr>
* <tr><td>Rotation X application</td><td>Auto by PlayerAnimator</td><td>Manual in setupRotations()</td></tr>
* <tr><td>Rotation Y smoothing</td><td>PlayerArmHideEventHandler</td><td>EntityDamsel.tick() via RotationSmoother</td></tr>
* <tr><td>Head compensation</td><td>MixinPlayerModel</td><td>DamselModel.setupAnim()</td></tr>
* <tr><td>Reset body.xRot</td><td>Not needed</td><td>Yes (prevents double rotation)</td></tr>
* <tr><td>Vertical offset</td><td>-6 model units</td><td>-7 model units</td></tr>
* </table>
*
* <h2>Usage</h2>
* <p>Used by:
* <ul>
* <li>MixinPlayerModel - for player head compensation</li>
* <li>DamselModel - for NPC head compensation</li>
* </ul>
*
* @see RotationSmoother for Y rotation smoothing
* @see com.tiedup.remake.mixin.client.MixinPlayerModel
* @see com.tiedup.remake.client.model.DamselModel
*/
@OnlyIn(Dist.CLIENT)
public final class DogPoseHelper {
private static final float DEG_TO_RAD = (float) Math.PI / 180F;
private static final float HEAD_PITCH_OFFSET = (float) Math.toRadians(-90);
private DogPoseHelper() {
// Utility class - no instantiation
}
/**
* Apply head compensation for DOG pose (horizontal body).
*
* <p>When body is horizontal (-90° pitch), the head needs compensation:
* <ul>
* <li>xRot: -90° offset + player's up/down look (headPitch)</li>
* <li>yRot: 0 (this axis points sideways when body is horizontal)</li>
* <li>zRot: -headYaw (left/right look, replaces yaw)</li>
* </ul>
*
* @param head The head ModelPart to modify
* @param hat The hat ModelPart to sync (can be null)
* @param headPitch Player's up/down look angle in degrees
* @param headYaw Head yaw relative to body in degrees (netHeadYaw for NPCs,
* netHeadYaw + rotationDelta for players)
*/
public static void applyHeadCompensation(
ModelPart head,
ModelPart hat,
float headPitch,
float headYaw
) {
float pitchRad = headPitch * DEG_TO_RAD;
float yawRad = headYaw * DEG_TO_RAD;
// xRot: base offset (-90° to look forward) + player's up/down look
head.xRot = HEAD_PITCH_OFFSET + pitchRad;
// yRot: stays at 0 (this axis points sideways when body is horizontal)
head.yRot = 0;
// zRot: used for left/right look (replaces yaw since body is horizontal)
head.zRot = -yawRad;
// Sync hat layer if provided
if (hat != null) {
hat.copyFrom(head);
}
}
/**
* Apply head compensation with yaw clamping.
*
* <p>Same as {@link #applyHeadCompensation} but clamps yaw to a maximum angle.
* Used for players where yaw range depends on movement state.
*
* @param head The head ModelPart to modify
* @param hat The hat ModelPart to sync (can be null)
* @param headPitch Player's up/down look angle in degrees
* @param headYaw Head yaw relative to body in degrees
* @param maxYaw Maximum allowed yaw angle in degrees
*/
public static void applyHeadCompensationClamped(
ModelPart head,
ModelPart hat,
float headPitch,
float headYaw,
float maxYaw
) {
// Wrap first so 350° becomes -10° before clamping (fixes full-rotation accumulation)
float clampedYaw = net.minecraft.util.Mth.clamp(
net.minecraft.util.Mth.wrapDegrees(headYaw),
-maxYaw,
maxYaw
);
applyHeadCompensation(head, hat, headPitch, clampedYaw);
}
}

View File

@@ -0,0 +1,234 @@
package com.tiedup.remake.client.events;
import com.mojang.blaze3d.systems.RenderSystem;
import com.tiedup.remake.core.ModConfig;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.state.PlayerBindState;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.player.LocalPlayer;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.entity.HumanoidArm;
import net.minecraft.world.item.ItemStack;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.client.event.RenderGuiOverlayEvent;
import net.minecraftforge.client.gui.overlay.VanillaGuiOverlay;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
/**
* Phase 5: Blindfold Rendering
*
* Based on the original TiedUp! mod (1.12.2) by Yuti & Marl Velius.
*
* The original approach:
* 1. Render blindfold texture over the entire screen (covers everything)
* 2. Manually redraw the hotbar on top of the blindfold
*
* This ensures the hotbar remains visible while the game world is obscured.
*/
@Mod.EventBusSubscriber(
modid = TiedUpMod.MOD_ID,
bus = Mod.EventBusSubscriber.Bus.FORGE,
value = Dist.CLIENT
)
public class BlindfoldRenderEventHandler {
private static final ResourceLocation BLINDFOLD_TEXTURE =
ResourceLocation.fromNamespaceAndPath(
TiedUpMod.MOD_ID,
"textures/misc/blindfolded.png"
);
// Vanilla widgets texture (contains hotbar graphics)
private static final ResourceLocation WIDGETS_TEXTURE =
ResourceLocation.fromNamespaceAndPath(
"minecraft",
"textures/gui/widgets.png"
);
private static boolean wasBlindfolded = false;
/**
* Render the blindfold overlay AFTER the hotbar is rendered.
* Then redraw the hotbar on top of the blindfold (original mod approach).
*/
@SubscribeEvent
public static void onRenderGuiPost(RenderGuiOverlayEvent.Post event) {
// Render after HOTBAR (same as original mod)
if (event.getOverlay() != VanillaGuiOverlay.HOTBAR.type()) {
return;
}
Minecraft mc = Minecraft.getInstance();
LocalPlayer player = mc.player;
// Safety checks
if (player == null || mc.options.hideGui) {
return;
}
// Non-hardcore mode: hide blindfold when a GUI screen is open
boolean hardcore = ModConfig.CLIENT.hardcoreBlindfold.get();
if (!hardcore && mc.screen != null) {
return;
}
// Get player state
PlayerBindState state = PlayerBindState.getInstance(player);
if (state == null) {
return;
}
boolean isBlindfolded = state.isBlindfolded();
// Log state changes only
if (isBlindfolded != wasBlindfolded) {
if (isBlindfolded) {
TiedUpMod.LOGGER.info(
"[BLINDFOLD] Player is now blindfolded - rendering overlay"
);
} else {
TiedUpMod.LOGGER.info(
"[BLINDFOLD] Player is no longer blindfolded - stopping overlay"
);
}
wasBlindfolded = isBlindfolded;
}
// Only render if blindfolded
if (!isBlindfolded) {
return;
}
try {
int screenWidth = mc.getWindow().getGuiScaledWidth();
int screenHeight = mc.getWindow().getGuiScaledHeight();
// Set opacity: hardcore forces full opacity, otherwise use config
float opacity = hardcore
? 1.0F
: ModConfig.CLIENT.blindfoldOverlayOpacity
.get()
.floatValue();
RenderSystem.setShaderColor(1.0F, 1.0F, 1.0F, opacity);
RenderSystem.enableBlend();
RenderSystem.defaultBlendFunc();
// Step 1: Render the blindfold texture over the entire screen
event
.getGuiGraphics()
.blit(
BLINDFOLD_TEXTURE,
0,
0,
0.0F,
0.0F,
screenWidth,
screenHeight,
screenWidth,
screenHeight
);
// Reset shader color for hotbar
RenderSystem.setShaderColor(1.0F, 1.0F, 1.0F, 1.0F);
// Step 2: Redraw the hotbar on top of the blindfold (original mod approach)
redrawHotbar(
mc,
event.getGuiGraphics(),
screenWidth,
screenHeight,
player
);
} catch (RuntimeException e) {
TiedUpMod.LOGGER.error("[BLINDFOLD] Error rendering overlay", e);
}
}
/**
* Manually redraw the hotbar on top of the blindfold texture.
* Based on the original mod's redrawHotBar() function.
*
* This draws:
* - Hotbar background (182x22 pixels)
* - Selected slot highlight
* - Offhand slot (if item present)
*/
private static void redrawHotbar(
Minecraft mc,
GuiGraphics guiGraphics,
int screenWidth,
int screenHeight,
LocalPlayer player
) {
// Reset render state
RenderSystem.setShaderColor(1.0F, 1.0F, 1.0F, 1.0F);
RenderSystem.enableBlend();
RenderSystem.defaultBlendFunc();
// Center of screen (hotbar is centered)
int centerX = screenWidth / 2;
int hotbarY = screenHeight - 22; // Hotbar is 22 pixels from bottom
// Draw hotbar background (182 pixels wide, 22 pixels tall)
// Original: this.drawTexturedModalRect(i - 91, sr.getScaledHeight() - 22, 0, 0, 182, 22);
guiGraphics.blit(
WIDGETS_TEXTURE,
centerX - 91,
hotbarY, // Position
0,
0, // Texture UV start
182,
22 // Size
);
// Draw selected slot highlight (24x22 pixels)
// Original: this.drawTexturedModalRect(i - 91 - 1 + entityplayer.inventory.currentItem * 20, ...);
int selectedSlot = player.getInventory().selected;
guiGraphics.blit(
WIDGETS_TEXTURE,
centerX - 91 - 1 + selectedSlot * 20,
hotbarY - 1, // Position (offset by selected slot)
0,
22, // Texture UV (highlight texture)
24,
22 // Size
);
// Draw offhand slot if player has an item in offhand
ItemStack offhandItem = player.getItemInHand(InteractionHand.OFF_HAND);
if (!offhandItem.isEmpty()) {
HumanoidArm offhandSide = player.getMainArm().getOpposite();
if (offhandSide == HumanoidArm.LEFT) {
// Offhand on left side
// Original: this.drawTexturedModalRect(i - 91 - 29, sr.getScaledHeight() - 23, 24, 22, 29, 24);
guiGraphics.blit(
WIDGETS_TEXTURE,
centerX - 91 - 29,
hotbarY - 1, // Position
24,
22, // Texture UV
29,
24 // Size
);
} else {
// Offhand on right side
// Original: this.drawTexturedModalRect(i + 91, sr.getScaledHeight() - 23, 53, 22, 29, 24);
guiGraphics.blit(
WIDGETS_TEXTURE,
centerX + 91,
hotbarY - 1, // Position
53,
22, // Texture UV
29,
24 // Size
);
}
}
RenderSystem.disableBlend();
}
}

View File

@@ -0,0 +1,323 @@
package com.tiedup.remake.client.events;
import com.mojang.blaze3d.vertex.PoseStack;
import com.tiedup.remake.blocks.BlockMarker;
import com.tiedup.remake.blocks.entity.MarkerBlockEntity;
import com.tiedup.remake.cells.CellDataV2;
import com.tiedup.remake.cells.CellRegistryV2;
import com.tiedup.remake.cells.MarkerType;
import com.tiedup.remake.client.renderer.CellOutlineRenderer;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.items.ItemAdminWand;
import java.util.UUID;
import net.minecraft.client.Camera;
import net.minecraft.client.Minecraft;
import net.minecraft.client.player.LocalPlayer;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.phys.Vec3;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.client.event.RenderLevelStageEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
/**
* Event handler for rendering cell outlines when holding an admin wand.
*
* Phase: Kidnapper Revamp - Cell System
*
* Renders colored outlines around cell positions when:
* - Player is holding an Admin Wand
* - A cell is currently selected in the wand
*
* The outlines help builders visualize which blocks are part of the cell.
*
* Network sync: On dedicated servers, cell data is synced via PacketSyncCellData.
* On integrated servers, direct access to server data is used as a fallback.
*/
@Mod.EventBusSubscriber(
modid = TiedUpMod.MOD_ID,
bus = Mod.EventBusSubscriber.Bus.FORGE,
value = Dist.CLIENT
)
public class CellHighlightHandler {
// Client-side cache of cell data (synced from server via PacketSyncCellData)
private static final java.util.Map<UUID, CellDataV2> syncedCells =
new java.util.concurrent.ConcurrentHashMap<>();
// Legacy single-cell cache for backward compatibility
private static CellDataV2 cachedCellData = null;
private static UUID cachedCellId = null;
/**
* Render cell outlines after translucent blocks.
*/
@SubscribeEvent
public static void onRenderLevelStage(RenderLevelStageEvent event) {
// Only render after translucent stage (so outlines appear on top)
if (
event.getStage() !=
RenderLevelStageEvent.Stage.AFTER_TRANSLUCENT_BLOCKS
) {
return;
}
Minecraft mc = Minecraft.getInstance();
LocalPlayer player = mc.player;
if (player == null) return;
// Check if player is holding an Admin Wand
ItemStack mainHand = player.getMainHandItem();
ItemStack offHand = player.getOffhandItem();
boolean holdingAdminWand =
mainHand.getItem() instanceof ItemAdminWand ||
offHand.getItem() instanceof ItemAdminWand;
if (!holdingAdminWand) {
cachedCellData = null;
cachedCellId = null;
return;
}
PoseStack poseStack = event.getPoseStack();
Camera camera = event.getCamera();
// If holding Admin Wand, render nearby structure markers and preview
renderNearbyStructureMarkers(poseStack, camera, player);
renderAdminWandPreview(poseStack, camera, player, mainHand, offHand);
}
/**
* Render a preview outline showing where the Admin Wand will place a marker.
*/
private static void renderAdminWandPreview(
PoseStack poseStack,
Camera camera,
LocalPlayer player,
ItemStack mainHand,
ItemStack offHand
) {
// Get the block the player is looking at
net.minecraft.world.phys.HitResult hitResult =
Minecraft.getInstance().hitResult;
if (
hitResult == null ||
hitResult.getType() != net.minecraft.world.phys.HitResult.Type.BLOCK
) {
return;
}
net.minecraft.world.phys.BlockHitResult blockHit =
(net.minecraft.world.phys.BlockHitResult) hitResult;
BlockPos targetPos = blockHit.getBlockPos().above(); // Marker goes above the clicked block
// Get the current marker type from the wand
MarkerType type;
if (mainHand.getItem() instanceof ItemAdminWand) {
type = ItemAdminWand.getCurrentType(mainHand);
} else {
type = ItemAdminWand.getCurrentType(offHand);
}
Vec3 cameraPos = camera.getPosition();
float[] color = CellOutlineRenderer.getColorForType(type);
// Make preview semi-transparent and pulsing
float alpha =
0.5f + 0.3f * (float) Math.sin(System.currentTimeMillis() / 200.0);
float[] previewColor = { color[0], color[1], color[2], alpha };
// Setup rendering (depth test off so preview shows through blocks)
com.mojang.blaze3d.systems.RenderSystem.enableBlend();
com.mojang.blaze3d.systems.RenderSystem.defaultBlendFunc();
com.mojang.blaze3d.systems.RenderSystem.disableDepthTest();
com.mojang.blaze3d.systems.RenderSystem.depthMask(false);
com.mojang.blaze3d.systems.RenderSystem.setShader(
net.minecraft.client.renderer.GameRenderer::getPositionColorShader
);
CellOutlineRenderer.renderFilledBlock(
poseStack,
targetPos,
cameraPos,
previewColor
);
com.mojang.blaze3d.systems.RenderSystem.depthMask(true);
com.mojang.blaze3d.systems.RenderSystem.enableDepthTest();
com.mojang.blaze3d.systems.RenderSystem.disableBlend();
}
/**
* Render outlines for nearby structure markers (markers without cell IDs).
*/
private static void renderNearbyStructureMarkers(
PoseStack poseStack,
Camera camera,
LocalPlayer player
) {
Level level = player.level();
BlockPos playerPos = player.blockPosition();
Vec3 cameraPos = camera.getPosition();
// Collect markers first to check if we need to render anything
java.util.List<
java.util.Map.Entry<BlockPos, MarkerType>
> markersToRender = new java.util.ArrayList<>();
// Scan in a 32-block radius for structure markers
int radius = 32;
for (int x = -radius; x <= radius; x++) {
for (int y = -radius / 2; y <= radius / 2; y++) {
for (int z = -radius; z <= radius; z++) {
BlockPos pos = playerPos.offset(x, y, z);
if (
level.getBlockState(pos).getBlock() instanceof
BlockMarker
) {
BlockEntity be = level.getBlockEntity(pos);
if (be instanceof MarkerBlockEntity marker) {
// Only render structure markers (no cell ID)
if (marker.getCellId() == null) {
markersToRender.add(
java.util.Map.entry(
pos,
marker.getMarkerType()
)
);
}
}
}
}
}
}
// Only setup rendering if we have markers to render
if (!markersToRender.isEmpty()) {
// Setup rendering state (depth test off so markers show through blocks)
com.mojang.blaze3d.systems.RenderSystem.enableBlend();
com.mojang.blaze3d.systems.RenderSystem.defaultBlendFunc();
com.mojang.blaze3d.systems.RenderSystem.disableDepthTest();
com.mojang.blaze3d.systems.RenderSystem.depthMask(false);
com.mojang.blaze3d.systems.RenderSystem.setShader(
net.minecraft.client.renderer
.GameRenderer::getPositionColorShader
);
for (var entry : markersToRender) {
BlockPos pos = entry.getKey();
MarkerType type = entry.getValue();
float[] baseColor = CellOutlineRenderer.getColorForType(type);
// Semi-transparent fill
float[] fillColor = {
baseColor[0],
baseColor[1],
baseColor[2],
0.4f,
};
CellOutlineRenderer.renderFilledBlock(
poseStack,
pos,
cameraPos,
fillColor
);
}
// Restore rendering state
com.mojang.blaze3d.systems.RenderSystem.depthMask(true);
com.mojang.blaze3d.systems.RenderSystem.enableDepthTest();
com.mojang.blaze3d.systems.RenderSystem.disableBlend();
}
}
/**
* Get cell data on client side.
* First checks the network-synced cache, then falls back to integrated server access.
*
* @param cellId The cell UUID to look up
* @return CellDataV2 if found, null otherwise
*/
private static CellDataV2 getCellDataClient(UUID cellId) {
if (cellId == null) return null;
// Priority 1: Check network-synced cache (works on dedicated servers)
CellDataV2 synced = syncedCells.get(cellId);
if (synced != null) {
return synced;
}
// Priority 2: Check legacy single-cell cache
if (cellId.equals(cachedCellId) && cachedCellData != null) {
return cachedCellData;
}
// Priority 3: On integrated server, access server level directly (fallback)
Minecraft mc = Minecraft.getInstance();
if (mc.getSingleplayerServer() != null) {
ServerLevel serverLevel = mc.getSingleplayerServer().overworld();
if (serverLevel != null) {
CellRegistryV2 registry = CellRegistryV2.get(serverLevel);
CellDataV2 cell = registry.getCell(cellId);
if (cell != null) {
// Cache for future use
cachedCellId = cellId;
cachedCellData = cell;
return cell;
}
}
}
// Not found - on dedicated server, packet hasn't arrived yet
return null;
}
/**
* Update cached cell data (called from network sync - PacketSyncCellData).
* Stores in both the synced map and legacy cache for compatibility.
*
* @param cell The cell data received from server
*/
public static void updateCachedCell(CellDataV2 cell) {
if (cell != null) {
// Store in synced map
syncedCells.put(cell.getId(), cell);
// Also update legacy cache
cachedCellId = cell.getId();
cachedCellData = cell;
}
}
/**
* Remove a cell from the cache (e.g., when cell is deleted).
*
* @param cellId The cell UUID to remove
*/
public static void removeCachedCell(UUID cellId) {
if (cellId != null) {
syncedCells.remove(cellId);
if (cellId.equals(cachedCellId)) {
cachedCellId = null;
cachedCellData = null;
}
}
}
/**
* Clear all cached cell data.
* Called when disconnecting from server or on dimension change.
*/
public static void clearCache() {
syncedCells.clear();
cachedCellId = null;
cachedCellData = null;
}
}

View File

@@ -0,0 +1,91 @@
package com.tiedup.remake.client.events;
import com.tiedup.remake.client.MuffledSoundInstance;
import com.tiedup.remake.core.ModConfig;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.state.PlayerBindState;
import net.minecraft.client.Minecraft;
import net.minecraft.client.player.LocalPlayer;
import net.minecraft.client.resources.sounds.SoundInstance;
import net.minecraft.sounds.SoundSource;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import net.minecraftforge.client.event.sound.PlaySoundEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
/**
* Client-side sound handler for earplugs effect.
*
* When the player has earplugs equipped, all sounds are muffled
* (volume reduced to simulate hearing impairment).
*
* Based on Forge's PlaySoundEvent to intercept and modify sounds.
*/
@OnlyIn(Dist.CLIENT)
@Mod.EventBusSubscriber(
modid = TiedUpMod.MOD_ID,
bus = Mod.EventBusSubscriber.Bus.FORGE,
value = Dist.CLIENT
)
public class EarplugSoundHandler {
/** Pitch modifier to make sounds more muffled (much lower for "underwater" effect) */
private static final float MUFFLED_PITCH_MODIFIER = 0.6f;
/** Categories to always let through at normal volume (important UI feedback) */
private static final SoundSource[] UNAFFECTED_CATEGORIES = {
SoundSource.MASTER, // Master volume controls
SoundSource.MUSIC, // Music is internal, not muffled by earplugs
};
/**
* Intercept sound events and muffle them if player has earplugs.
*/
@SubscribeEvent
public static void onPlaySound(PlaySoundEvent event) {
// Get the sound being played
SoundInstance sound = event.getSound();
if (sound == null) {
return;
}
// Check if player has earplugs
Minecraft mc = Minecraft.getInstance();
if (mc == null) {
return;
}
LocalPlayer player = mc.player;
if (player == null) {
return;
}
PlayerBindState state = PlayerBindState.getInstance(player);
if (state == null || !state.hasEarplugs()) {
return;
}
// Check if this sound category should be affected
SoundSource source = sound.getSource();
for (SoundSource unaffected : UNAFFECTED_CATEGORIES) {
if (source == unaffected) {
return; // Don't muffle this category
}
}
// Don't wrap already-wrapped sounds (prevent infinite recursion)
if (sound instanceof MuffledSoundInstance) {
return;
}
// Wrap the sound with our muffling wrapper
// The wrapper delegates to the original but modifies getVolume()/getPitch()
SoundInstance muffledSound = new MuffledSoundInstance(
sound,
ModConfig.CLIENT.earplugVolumeMultiplier.get().floatValue(),
MUFFLED_PITCH_MODIFIER
);
event.setSound(muffledSound);
}
}

View File

@@ -0,0 +1,71 @@
package com.tiedup.remake.client.events;
import com.mojang.logging.LogUtils;
import com.tiedup.remake.client.animation.BondageAnimationManager;
import com.tiedup.remake.client.animation.PendingAnimationManager;
import com.tiedup.remake.core.TiedUpMod;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.event.entity.EntityLeaveLevelEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
import org.slf4j.Logger;
/**
* Automatic cleanup handler for entity-related resources.
*
* <p>This handler automatically cleans up animation layers and pending animations
* when entities leave the world, preventing memory leaks from stale cache entries.
*
* <p>Phase: Performance & Memory Management
*
* <p>Previously, cleanup had to be called manually via {@link BondageAnimationManager#cleanup(java.util.UUID)},
* which was error-prone and could lead to memory leaks if forgotten.
* This handler ensures cleanup happens automatically on entity removal.
*/
@Mod.EventBusSubscriber(
modid = TiedUpMod.MOD_ID,
bus = Mod.EventBusSubscriber.Bus.FORGE,
value = Dist.CLIENT
)
public class EntityCleanupHandler {
private static final Logger LOGGER = LogUtils.getLogger();
/**
* Automatically clean up animation resources when an entity leaves the world.
*
* <p>This event fires when:
* <ul>
* <li>An entity is removed from the world (killed, despawned, unloaded)</li>
* <li>A player logs out</li>
* <li>A chunk is unloaded and its entities are removed</li>
* </ul>
*
* <p>Cleanup includes:
* <ul>
* <li>Removing animation layers from {@link BondageAnimationManager}</li>
* <li>Removing pending animations from {@link PendingAnimationManager}</li>
* </ul>
*
* @param event The entity leave level event
*/
@SubscribeEvent
public static void onEntityLeaveLevel(EntityLeaveLevelEvent event) {
// Only process on client side
if (!event.getLevel().isClientSide()) {
return;
}
// Clean up animation layers
BondageAnimationManager.cleanup(event.getEntity().getUUID());
// Clean up pending animation queue
PendingAnimationManager.remove(event.getEntity().getUUID());
LOGGER.debug(
"Auto-cleaned animation resources for entity: {} (type: {})",
event.getEntity().getUUID(),
event.getEntity().getClass().getSimpleName()
);
}
}

View File

@@ -0,0 +1,156 @@
package com.tiedup.remake.client.events;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.items.ModItems;
import com.tiedup.remake.items.base.BindVariant;
import com.tiedup.remake.state.IBondageState;
import com.tiedup.remake.util.KidnappedHelper;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import net.minecraft.client.Minecraft;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.Level;
import net.minecraft.world.phys.Vec3;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import net.minecraftforge.event.TickEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
/**
* Client-side handler for smooth leash proxy positioning.
*
* FIX: Changed from RenderLevelStageEvent.AFTER_ENTITIES to ClientTickEvent.
* AFTER_ENTITIES positioned the proxy AFTER rendering, causing 1-frame lag.
* ClientTickEvent positions BEFORE rendering for smooth leash display.
*
* Instead of waiting for server position updates (which causes lag),
* this handler repositions the proxy entity locally each tick based
* on the player's current position.
*/
@OnlyIn(Dist.CLIENT)
@Mod.EventBusSubscriber(
modid = TiedUpMod.MOD_ID,
value = Dist.CLIENT,
bus = Mod.EventBusSubscriber.Bus.FORGE
)
public class LeashProxyClientHandler {
/**
* Map of player UUID -> proxy entity ID.
* Uses UUID for player (persistent) and entity ID for proxy (runtime).
*/
private static final Map<UUID, Integer> playerToProxy =
new ConcurrentHashMap<>();
/** Default Y offset for normal standing pose (neck height) */
private static final double DEFAULT_Y_OFFSET = 1.3;
/** Y offset for dogwalk pose (back/hip level) */
private static final double DOGWALK_Y_OFFSET = 0.35;
/**
* Handle sync packet from server.
* Called when a player gets leashed or unleashed.
*/
public static void handleSyncPacket(
UUID targetPlayerUUID,
int proxyId,
boolean attach
) {
if (attach) {
playerToProxy.put(targetPlayerUUID, proxyId);
TiedUpMod.LOGGER.debug(
"[LeashProxyClient] Registered proxy {} for player {}",
proxyId,
targetPlayerUUID
);
} else {
playerToProxy.remove(targetPlayerUUID);
TiedUpMod.LOGGER.debug(
"[LeashProxyClient] Removed proxy for player {}",
targetPlayerUUID
);
}
}
/**
* FIX: Use ClientTickEvent instead of RenderLevelStageEvent.AFTER_ENTITIES.
* This positions proxies BEFORE rendering, eliminating the 1-frame lag
* that caused jittery leash rendering.
*/
@SubscribeEvent
public static void onClientTick(TickEvent.ClientTickEvent event) {
// Only run at end of tick (after player position is updated)
if (event.phase != TickEvent.Phase.END) {
return;
}
if (playerToProxy.isEmpty()) {
return;
}
Minecraft mc = Minecraft.getInstance();
if (mc == null || mc.level == null || mc.isPaused()) {
return;
}
Level level = mc.level;
// Reposition each tracked proxy
for (Map.Entry<UUID, Integer> entry : playerToProxy.entrySet()) {
UUID playerUUID = entry.getKey();
int proxyId = entry.getValue();
Player playerEntity = level.getPlayerByUUID(playerUUID);
Entity proxyEntity = level.getEntity(proxyId);
if (playerEntity != null && proxyEntity != null) {
// FIX: Calculate Y offset based on bind type (dogwalk vs normal)
double yOffset = calculateYOffset(playerEntity);
// Use current position (interpolation will be handled by renderer)
double x = playerEntity.getX();
double y = playerEntity.getY() + yOffset;
double z = playerEntity.getZ() - 0.15;
// Set proxy position
proxyEntity.setPos(x, y, z);
// Update old positions for smooth interpolation
proxyEntity.xOld = proxyEntity.xo = x;
proxyEntity.yOld = proxyEntity.yo = y;
proxyEntity.zOld = proxyEntity.zo = z;
}
}
}
/**
* Calculate Y offset based on player's bind type.
* Dogwalk (DOGBINDER) uses lower offset for 4-legged pose.
*/
private static double calculateYOffset(Player player) {
IBondageState state = KidnappedHelper.getKidnappedState(player);
if (state != null && state.isTiedUp()) {
ItemStack bind = state.getEquipment(BodyRegionV2.ARMS);
if (
!bind.isEmpty() &&
bind.getItem() == ModItems.getBind(BindVariant.DOGBINDER)
) {
return DOGWALK_Y_OFFSET;
}
}
return DEFAULT_Y_OFFSET;
}
/**
* Clear all tracked proxies.
* Called when disconnecting from server.
*/
public static void clearAll() {
playerToProxy.clear();
}
}

View File

@@ -0,0 +1,179 @@
package com.tiedup.remake.client.events;
import com.tiedup.remake.items.base.*;
import com.tiedup.remake.network.ModNetwork;
import com.tiedup.remake.v2.bondage.IV2BondageItem;
import com.tiedup.remake.network.selfbondage.PacketSelfBondage;
import net.minecraft.client.Minecraft;
import net.minecraft.client.player.LocalPlayer;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import net.minecraftforge.event.TickEvent;
import net.minecraftforge.event.entity.player.PlayerInteractEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
/**
* Client-side event handler for self-bondage input.
*
* Intercepts left-click when holding bondage items and
* sends packets continuously to the server to perform self-bondage.
*
* Self-bondage items:
* - Binds (rope, chain, etc.) - Self-tie (requires holding left-click)
* - Gags - Self-gag (if already tied, instant)
* - Blindfolds - Self-blindfold (if already tied, instant)
* - Mittens - Self-mitten (if already tied, instant)
* - Earplugs - Self-earplug (if already tied, instant)
* - Collar - NOT ALLOWED (cannot self-collar)
*/
@Mod.EventBusSubscriber(
modid = "tiedup",
value = Dist.CLIENT,
bus = Mod.EventBusSubscriber.Bus.FORGE
)
@OnlyIn(Dist.CLIENT)
public class SelfBondageInputHandler {
/** Track if we're currently in self-bondage mode */
private static boolean isSelfBondageActive = false;
/** The hand we're using for self-bondage */
private static InteractionHand activeHand = null;
/** Tick counter for packet sending interval */
private static int tickCounter = 0;
/** Send packet every 4 ticks (5 times per second) for smooth progress */
private static final int PACKET_INTERVAL = 4;
/**
* Handle left-click in empty air - START self-bondage.
*/
@SubscribeEvent
public static void onLeftClickEmpty(
PlayerInteractEvent.LeftClickEmpty event
) {
startSelfBondage();
}
/**
* Handle left-click on block - START self-bondage (cancel block breaking).
*/
@SubscribeEvent
public static void onLeftClickBlock(
PlayerInteractEvent.LeftClickBlock event
) {
if (!event.getLevel().isClientSide()) return;
ItemStack stack = event.getItemStack();
if (isSelfBondageItem(stack.getItem())) {
event.setCanceled(true);
startSelfBondage();
}
}
/**
* Start self-bondage mode if holding a bondage item.
*/
private static void startSelfBondage() {
LocalPlayer player = Minecraft.getInstance().player;
if (player == null) return;
// Check main hand first, then off hand
InteractionHand hand = InteractionHand.MAIN_HAND;
ItemStack stack = player.getMainHandItem();
if (!isSelfBondageItem(stack.getItem())) {
stack = player.getOffhandItem();
hand = InteractionHand.OFF_HAND;
if (!isSelfBondageItem(stack.getItem())) {
return; // No bondage item in either hand
}
}
// Start self-bondage mode
isSelfBondageActive = true;
activeHand = hand;
tickCounter = 0;
// Send initial packet immediately
ModNetwork.sendToServer(new PacketSelfBondage(hand));
}
/**
* Client tick - continuously send packets while attack button is held.
*/
@SubscribeEvent
public static void onClientTick(TickEvent.ClientTickEvent event) {
if (event.phase != TickEvent.Phase.END) return;
if (!isSelfBondageActive) return;
Minecraft mc = Minecraft.getInstance();
LocalPlayer player = mc.player;
// Stop if conditions are no longer valid
if (player == null || mc.screen != null) {
stopSelfBondage();
return;
}
// Check if attack button is still held
if (!mc.options.keyAttack.isDown()) {
stopSelfBondage();
return;
}
// Check if still holding bondage item in the active hand
ItemStack stack = player.getItemInHand(activeHand);
if (!isSelfBondageItem(stack.getItem())) {
stopSelfBondage();
return;
}
// Send packet at interval for continuous progress
tickCounter++;
if (tickCounter >= PACKET_INTERVAL) {
tickCounter = 0;
ModNetwork.sendToServer(new PacketSelfBondage(activeHand));
}
}
/**
* Stop self-bondage mode.
*/
private static void stopSelfBondage() {
isSelfBondageActive = false;
activeHand = null;
tickCounter = 0;
}
/**
* Check if an item supports self-bondage.
* Collar is explicitly excluded.
*/
private static boolean isSelfBondageItem(Item item) {
// Collar cannot be self-equipped (V1 collar guard)
if (item instanceof ItemCollar) {
return false;
}
// V2 bondage items support self-bondage (left-click hold with tying duration)
if (item instanceof IV2BondageItem) {
return true;
}
// V1 bondage items (legacy)
return (
item instanceof ItemBind ||
item instanceof ItemGag ||
item instanceof ItemBlindfold ||
item instanceof ItemMittens ||
item instanceof ItemEarplugs
);
}
}

View File

@@ -0,0 +1,628 @@
package com.tiedup.remake.client.gltf;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.joml.Matrix4f;
import org.joml.Quaternionf;
import org.joml.Vector3f;
/**
* Parser for binary .glb (glTF 2.0) files.
* Extracts mesh geometry, skinning data, bone hierarchy, and animations.
* Filters out meshes named "Player".
*/
public final class GlbParser {
private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
private static final int GLB_MAGIC = 0x46546C67; // "glTF"
private static final int GLB_VERSION = 2;
private static final int CHUNK_JSON = 0x4E4F534A; // "JSON"
private static final int CHUNK_BIN = 0x004E4942; // "BIN\0"
private GlbParser() {}
/**
* Parse a .glb file from an InputStream.
*
* @param input the input stream (will be fully read)
* @param debugName name for log messages
* @return parsed GltfData
* @throws IOException if the file is malformed or I/O fails
*/
public static GltfData parse(InputStream input, String debugName) throws IOException {
byte[] allBytes = input.readAllBytes();
ByteBuffer buf = ByteBuffer.wrap(allBytes).order(ByteOrder.LITTLE_ENDIAN);
// -- Header --
int magic = buf.getInt();
if (magic != GLB_MAGIC) {
throw new IOException("Not a GLB file: " + debugName);
}
int version = buf.getInt();
if (version != GLB_VERSION) {
throw new IOException("Unsupported GLB version " + version + " in " + debugName);
}
int totalLength = buf.getInt();
// -- JSON chunk --
int jsonChunkLength = buf.getInt();
int jsonChunkType = buf.getInt();
if (jsonChunkType != CHUNK_JSON) {
throw new IOException("Expected JSON chunk in " + debugName);
}
byte[] jsonBytes = new byte[jsonChunkLength];
buf.get(jsonBytes);
String jsonStr = new String(jsonBytes, StandardCharsets.UTF_8);
JsonObject root = JsonParser.parseString(jsonStr).getAsJsonObject();
// -- BIN chunk --
ByteBuffer binData = null;
if (buf.hasRemaining()) {
int binChunkLength = buf.getInt();
int binChunkType = buf.getInt();
if (binChunkType != CHUNK_BIN) {
throw new IOException("Expected BIN chunk in " + debugName);
}
byte[] binBytes = new byte[binChunkLength];
buf.get(binBytes);
binData = ByteBuffer.wrap(binBytes).order(ByteOrder.LITTLE_ENDIAN);
}
if (binData == null) {
throw new IOException("No BIN chunk in " + debugName);
}
JsonArray accessors = root.getAsJsonArray("accessors");
JsonArray bufferViews = root.getAsJsonArray("bufferViews");
JsonArray nodes = root.getAsJsonArray("nodes");
JsonArray meshes = root.getAsJsonArray("meshes");
// -- Find skin --
JsonArray skins = root.getAsJsonArray("skins");
if (skins == null || skins.size() == 0) {
throw new IOException("No skins found in " + debugName);
}
JsonObject skin = skins.get(0).getAsJsonObject();
JsonArray skinJoints = skin.getAsJsonArray("joints");
// Filter skin joints to only include known deforming bones
List<Integer> filteredJointNodes = new ArrayList<>();
int[] skinJointRemap = new int[skinJoints.size()]; // old skin index -> new filtered index
java.util.Arrays.fill(skinJointRemap, -1);
for (int j = 0; j < skinJoints.size(); j++) {
int nodeIdx = skinJoints.get(j).getAsInt();
JsonObject node = nodes.get(nodeIdx).getAsJsonObject();
String name = node.has("name") ? node.get("name").getAsString() : "joint_" + j;
if (GltfBoneMapper.isKnownBone(name)) {
skinJointRemap[j] = filteredJointNodes.size();
filteredJointNodes.add(nodeIdx);
} else {
LOGGER.debug("[GltfPipeline] Skipping non-deforming bone: '{}' (node {})", name, nodeIdx);
}
}
int jointCount = filteredJointNodes.size();
String[] jointNames = new String[jointCount];
int[] parentJointIndices = new int[jointCount];
Quaternionf[] restRotations = new Quaternionf[jointCount];
Vector3f[] restTranslations = new Vector3f[jointCount];
// Map node index -> joint index (filtered)
int[] nodeToJoint = new int[nodes.size()];
java.util.Arrays.fill(nodeToJoint, -1);
for (int j = 0; j < jointCount; j++) {
int nodeIdx = filteredJointNodes.get(j);
nodeToJoint[nodeIdx] = j;
}
// Read joint names, rest pose, and build parent mapping
java.util.Arrays.fill(parentJointIndices, -1);
for (int j = 0; j < jointCount; j++) {
int nodeIdx = filteredJointNodes.get(j);
JsonObject node = nodes.get(nodeIdx).getAsJsonObject();
jointNames[j] = node.has("name") ? node.get("name").getAsString() : "joint_" + j;
// Rest rotation
if (node.has("rotation")) {
JsonArray r = node.getAsJsonArray("rotation");
restRotations[j] = new Quaternionf(
r.get(0).getAsFloat(), r.get(1).getAsFloat(),
r.get(2).getAsFloat(), r.get(3).getAsFloat()
);
} else {
restRotations[j] = new Quaternionf(); // identity
}
// Rest translation
if (node.has("translation")) {
JsonArray t = node.getAsJsonArray("translation");
restTranslations[j] = new Vector3f(
t.get(0).getAsFloat(), t.get(1).getAsFloat(), t.get(2).getAsFloat()
);
} else {
restTranslations[j] = new Vector3f();
}
}
// Build parent indices by traversing node children
for (int ni = 0; ni < nodes.size(); ni++) {
JsonObject node = nodes.get(ni).getAsJsonObject();
if (node.has("children")) {
int parentJoint = nodeToJoint[ni];
JsonArray children = node.getAsJsonArray("children");
for (JsonElement child : children) {
int childNodeIdx = child.getAsInt();
int childJoint = nodeToJoint[childNodeIdx];
if (childJoint >= 0 && parentJoint >= 0) {
parentJointIndices[childJoint] = parentJoint;
}
}
}
}
// -- Inverse Bind Matrices --
// IBM accessor is indexed by original skin joint order, so we pick the filtered entries
Matrix4f[] inverseBindMatrices = new Matrix4f[jointCount];
if (skin.has("inverseBindMatrices")) {
int ibmAccessor = skin.get("inverseBindMatrices").getAsInt();
float[] ibmData = GlbParserUtils.readFloatAccessor(accessors, bufferViews, binData, ibmAccessor);
for (int origJ = 0; origJ < skinJoints.size(); origJ++) {
int newJ = skinJointRemap[origJ];
if (newJ >= 0) {
inverseBindMatrices[newJ] = new Matrix4f();
inverseBindMatrices[newJ].set(ibmData, origJ * 16);
}
}
} else {
for (int j = 0; j < jointCount; j++) {
inverseBindMatrices[j] = new Matrix4f(); // identity
}
}
// -- Find mesh (ignore "Player" mesh, take LAST non-Player) --
// WORKAROUND: Takes the LAST non-Player mesh because modelers may leave prototype meshes
// in the .glb. Revert to first non-Player mesh once modeler workflow is standardized.
int targetMeshIdx = -1;
if (meshes != null) {
for (int mi = 0; mi < meshes.size(); mi++) {
JsonObject mesh = meshes.get(mi).getAsJsonObject();
String meshName = mesh.has("name") ? mesh.get("name").getAsString() : "";
if (!"Player".equals(meshName)) {
targetMeshIdx = mi;
}
}
}
// -- Parse root material names (for tint channel detection) --
String[] materialNames = GlbParserUtils.parseMaterialNames(root);
// Mesh data: empty arrays if no mesh found (animation-only GLB)
float[] positions;
float[] normals;
float[] texCoords;
int[] indices;
int vertexCount;
int[] meshJoints;
float[] weights;
List<GltfData.Primitive> parsedPrimitives = new ArrayList<>();
if (targetMeshIdx >= 0) {
JsonObject mesh = meshes.get(targetMeshIdx).getAsJsonObject();
JsonArray primitives = mesh.getAsJsonArray("primitives");
// -- Accumulate vertex data from ALL primitives --
List<float[]> allPositions = new ArrayList<>();
List<float[]> allNormals = new ArrayList<>();
List<float[]> allTexCoords = new ArrayList<>();
List<int[]> allJoints = new ArrayList<>();
List<float[]> allWeights = new ArrayList<>();
int cumulativeVertexCount = 0;
for (int pi = 0; pi < primitives.size(); pi++) {
JsonObject primitive = primitives.get(pi).getAsJsonObject();
JsonObject attributes = primitive.getAsJsonObject("attributes");
// -- Read this primitive's vertex data --
float[] primPositions = GlbParserUtils.readFloatAccessor(
accessors, bufferViews, binData,
attributes.get("POSITION").getAsInt()
);
float[] primNormals = attributes.has("NORMAL")
? GlbParserUtils.readFloatAccessor(accessors, bufferViews, binData, attributes.get("NORMAL").getAsInt())
: new float[primPositions.length];
float[] primTexCoords = attributes.has("TEXCOORD_0")
? GlbParserUtils.readFloatAccessor(accessors, bufferViews, binData, attributes.get("TEXCOORD_0").getAsInt())
: new float[primPositions.length / 3 * 2];
int primVertexCount = primPositions.length / 3;
// -- Read this primitive's indices (offset by cumulative vertex count) --
int[] primIndices;
if (primitive.has("indices")) {
primIndices = GlbParserUtils.readIntAccessor(
accessors, bufferViews, binData,
primitive.get("indices").getAsInt()
);
} else {
// Non-indexed: generate sequential indices
primIndices = new int[primVertexCount];
for (int i = 0; i < primVertexCount; i++) primIndices[i] = i;
}
// Offset indices by cumulative vertex count from prior primitives
if (cumulativeVertexCount > 0) {
for (int i = 0; i < primIndices.length; i++) {
primIndices[i] += cumulativeVertexCount;
}
}
// -- Read skinning attributes for this primitive --
int[] primJoints = new int[primVertexCount * 4];
float[] primWeights = new float[primVertexCount * 4];
if (attributes.has("JOINTS_0")) {
primJoints = GlbParserUtils.readIntAccessor(
accessors, bufferViews, binData,
attributes.get("JOINTS_0").getAsInt()
);
// Remap vertex joint indices from original skin order to filtered order
for (int i = 0; i < primJoints.length; i++) {
int origIdx = primJoints[i];
if (origIdx >= 0 && origIdx < skinJointRemap.length) {
primJoints[i] = skinJointRemap[origIdx] >= 0 ? skinJointRemap[origIdx] : 0;
} else {
primJoints[i] = 0;
}
}
}
if (attributes.has("WEIGHTS_0")) {
primWeights = GlbParserUtils.readFloatAccessor(
accessors, bufferViews, binData,
attributes.get("WEIGHTS_0").getAsInt()
);
}
// -- Resolve material name and tint channel --
String matName = null;
if (primitive.has("material")) {
int matIdx = primitive.get("material").getAsInt();
if (matIdx >= 0 && matIdx < materialNames.length) {
matName = materialNames[matIdx];
}
}
boolean isTintable = matName != null && matName.startsWith("tintable_");
String tintChannel = isTintable ? matName : null;
parsedPrimitives.add(new GltfData.Primitive(primIndices, matName, isTintable, tintChannel));
allPositions.add(primPositions);
allNormals.add(primNormals);
allTexCoords.add(primTexCoords);
allJoints.add(primJoints);
allWeights.add(primWeights);
cumulativeVertexCount += primVertexCount;
}
// -- Flatten accumulated data into single arrays --
vertexCount = cumulativeVertexCount;
positions = GlbParserUtils.flattenFloats(allPositions);
normals = GlbParserUtils.flattenFloats(allNormals);
texCoords = GlbParserUtils.flattenFloats(allTexCoords);
meshJoints = GlbParserUtils.flattenInts(allJoints);
weights = GlbParserUtils.flattenFloats(allWeights);
// Build union of all primitive indices (for backward-compat indices() accessor)
int totalIndices = 0;
for (GltfData.Primitive p : parsedPrimitives) totalIndices += p.indices().length;
indices = new int[totalIndices];
int offset = 0;
for (GltfData.Primitive p : parsedPrimitives) {
System.arraycopy(p.indices(), 0, indices, offset, p.indices().length);
offset += p.indices().length;
}
} else {
// Animation-only GLB: no mesh data
LOGGER.info("[GltfPipeline] No mesh found in '{}' (animation-only GLB)", debugName);
positions = new float[0];
normals = new float[0];
texCoords = new float[0];
indices = new int[0];
vertexCount = 0;
meshJoints = new int[0];
weights = new float[0];
}
// -- Read ALL animations --
Map<String, GltfData.AnimationClip> allClips = new LinkedHashMap<>();
JsonArray animations = root.getAsJsonArray("animations");
if (animations != null) {
for (int ai = 0; ai < animations.size(); ai++) {
JsonObject anim = animations.get(ai).getAsJsonObject();
String animName = anim.has("name") ? anim.get("name").getAsString() : "animation_" + ai;
// Strip the "ArmatureName|" prefix if present (Blender convention)
if (animName.contains("|")) {
animName = animName.substring(animName.lastIndexOf('|') + 1);
}
GltfData.AnimationClip clip = parseAnimation(anim, accessors, bufferViews, binData, nodeToJoint, jointCount);
if (clip != null) {
allClips.put(animName, clip);
}
}
}
// Default animation = first clip (for backward compat)
GltfData.AnimationClip animClip = allClips.isEmpty() ? null : allClips.values().iterator().next();
LOGGER.info("[GltfPipeline] Parsed '{}': vertices={}, indices={}, joints={}, animations={}",
debugName, vertexCount, indices.length, jointCount, allClips.size());
for (String name : allClips.keySet()) {
LOGGER.debug("[GltfPipeline] animation: '{}'", name);
}
for (int j = 0; j < jointCount; j++) {
Quaternionf rq = restRotations[j];
Vector3f rt = restTranslations[j];
LOGGER.debug(String.format("[GltfPipeline] joint[%d] = '%s', parent=%d, restQ=(%.3f,%.3f,%.3f,%.3f) restT=(%.3f,%.3f,%.3f)",
j, jointNames[j], parentJointIndices[j],
rq.x, rq.y, rq.z, rq.w, rt.x, rt.y, rt.z));
}
// Log animation translation channels for default clip (BEFORE MC conversion)
if (animClip != null && animClip.translations() != null) {
Vector3f[][] animTrans = animClip.translations();
for (int j = 0; j < jointCount; j++) {
if (j < animTrans.length && animTrans[j] != null) {
Vector3f at = animTrans[j][0]; // first frame
Vector3f rt = restTranslations[j];
LOGGER.debug(String.format(
"[GltfPipeline] joint[%d] '%s' has ANIM TRANSLATION: (%.4f,%.4f,%.4f) vs rest (%.4f,%.4f,%.4f) delta=(%.4f,%.4f,%.4f)",
j, jointNames[j],
at.x, at.y, at.z,
rt.x, rt.y, rt.z,
at.x - rt.x, at.y - rt.y, at.z - rt.z));
}
}
} else {
LOGGER.debug("[GltfPipeline] Default animation has NO translation channels");
}
// Save raw glTF rotations BEFORE coordinate conversion (for pose converter)
// MC model space faces +Z just like glTF, so delta quaternions for ModelPart
// rotation should be computed from raw glTF data, not from the converted data.
Quaternionf[] rawRestRotations = new Quaternionf[jointCount];
for (int j = 0; j < jointCount; j++) {
rawRestRotations[j] = new Quaternionf(restRotations[j]);
}
// Build raw copies of ALL animation clips (before MC conversion)
Map<String, GltfData.AnimationClip> rawAllClips = new LinkedHashMap<>();
for (Map.Entry<String, GltfData.AnimationClip> entry : allClips.entrySet()) {
rawAllClips.put(entry.getKey(), GlbParserUtils.deepCopyClip(entry.getValue()));
}
GltfData.AnimationClip rawAnimClip = rawAllClips.isEmpty() ? null : rawAllClips.values().iterator().next();
// Convert from glTF coordinate system (Y-up, faces +Z) to MC (Y-up, faces -Z)
// This is a 180° rotation around Y: negate X and Z for all spatial data
// Convert ALL animation clips to MC space
for (GltfData.AnimationClip clip : allClips.values()) {
GlbParserUtils.convertAnimationToMinecraftSpace(clip, jointCount);
}
convertToMinecraftSpace(positions, normals, restTranslations, restRotations,
inverseBindMatrices, null, jointCount); // pass null — clips already converted above
LOGGER.debug("[GltfPipeline] Converted all data to Minecraft coordinate space");
return new GltfData(
positions, normals, texCoords,
indices, meshJoints, weights,
jointNames, parentJointIndices,
inverseBindMatrices,
restRotations, restTranslations,
rawRestRotations,
rawAnimClip,
animClip,
allClips, rawAllClips,
parsedPrimitives,
vertexCount, jointCount
);
}
// ---- Animation parsing ----
private static GltfData.AnimationClip parseAnimation(
JsonObject animation,
JsonArray accessors, JsonArray bufferViews,
ByteBuffer binData,
int[] nodeToJoint, int jointCount
) {
JsonArray channels = animation.getAsJsonArray("channels");
JsonArray samplers = animation.getAsJsonArray("samplers");
// Collect rotation and translation channels
List<Integer> rotJoints = new ArrayList<>();
List<float[]> rotTimestamps = new ArrayList<>();
List<Quaternionf[]> rotValues = new ArrayList<>();
List<Integer> transJoints = new ArrayList<>();
List<float[]> transTimestamps = new ArrayList<>();
List<Vector3f[]> transValues = new ArrayList<>();
for (JsonElement chElem : channels) {
JsonObject channel = chElem.getAsJsonObject();
JsonObject target = channel.getAsJsonObject("target");
String path = target.get("path").getAsString();
int nodeIdx = target.get("node").getAsInt();
if (nodeIdx >= nodeToJoint.length || nodeToJoint[nodeIdx] < 0) continue;
int jointIdx = nodeToJoint[nodeIdx];
int samplerIdx = channel.get("sampler").getAsInt();
JsonObject sampler = samplers.get(samplerIdx).getAsJsonObject();
float[] times = GlbParserUtils.readFloatAccessor(
accessors, bufferViews, binData,
sampler.get("input").getAsInt()
);
if ("rotation".equals(path)) {
float[] quats = GlbParserUtils.readFloatAccessor(
accessors, bufferViews, binData,
sampler.get("output").getAsInt()
);
Quaternionf[] qArr = new Quaternionf[times.length];
for (int i = 0; i < times.length; i++) {
qArr[i] = new Quaternionf(
quats[i * 4], quats[i * 4 + 1],
quats[i * 4 + 2], quats[i * 4 + 3]
);
}
rotJoints.add(jointIdx);
rotTimestamps.add(times);
rotValues.add(qArr);
} else if ("translation".equals(path)) {
float[] vecs = GlbParserUtils.readFloatAccessor(
accessors, bufferViews, binData,
sampler.get("output").getAsInt()
);
Vector3f[] tArr = new Vector3f[times.length];
for (int i = 0; i < times.length; i++) {
tArr[i] = new Vector3f(
vecs[i * 3], vecs[i * 3 + 1], vecs[i * 3 + 2]
);
}
transJoints.add(jointIdx);
transTimestamps.add(times);
transValues.add(tArr);
}
}
if (rotJoints.isEmpty() && transJoints.isEmpty()) return null;
// Use the first available channel's timestamps as reference
float[] timestamps = !rotTimestamps.isEmpty()
? rotTimestamps.get(0)
: transTimestamps.get(0);
int frameCount = timestamps.length;
// Build per-joint rotation arrays (null if no animation for that joint)
Quaternionf[][] rotations = new Quaternionf[jointCount][];
for (int i = 0; i < rotJoints.size(); i++) {
int jIdx = rotJoints.get(i);
Quaternionf[] vals = rotValues.get(i);
rotations[jIdx] = new Quaternionf[frameCount];
for (int f = 0; f < frameCount; f++) {
rotations[jIdx][f] = f < vals.length ? vals[f] : vals[vals.length - 1];
}
}
// Build per-joint translation arrays (null if no animation for that joint)
Vector3f[][] translations = new Vector3f[jointCount][];
for (int i = 0; i < transJoints.size(); i++) {
int jIdx = transJoints.get(i);
Vector3f[] vals = transValues.get(i);
translations[jIdx] = new Vector3f[frameCount];
for (int f = 0; f < frameCount; f++) {
translations[jIdx][f] = f < vals.length
? new Vector3f(vals[f])
: new Vector3f(vals[vals.length - 1]);
}
}
// Log translation channels found
if (!transJoints.isEmpty()) {
LOGGER.debug("[GltfPipeline] Animation has {} translation channel(s)",
transJoints.size());
}
return new GltfData.AnimationClip(timestamps, rotations, translations, frameCount);
}
// ---- Coordinate system conversion ----
/**
* Convert all spatial data from glTF space to MC model-def space.
* The Blender-exported character faces -Z in glTF, same as MC model-def.
* Only X (right→left) and Y (up→down) differ between the two spaces.
* Equivalent to a 180° rotation around Z: negate X and Y components.
*
* For positions/normals/translations: (x,y,z) → (-x, -y, z)
* For quaternions: (x,y,z,w) → (-x, -y, z, w) (conjugation by 180° Z)
* For matrices: M → C * M * C where C = diag(-1, -1, 1, 1)
*/
private static void convertToMinecraftSpace(
float[] positions, float[] normals,
Vector3f[] restTranslations, Quaternionf[] restRotations,
Matrix4f[] inverseBindMatrices,
GltfData.AnimationClip animClip, int jointCount
) {
// Vertex positions: negate X and Y
for (int i = 0; i < positions.length; i += 3) {
positions[i] = -positions[i]; // X
positions[i + 1] = -positions[i + 1]; // Y
}
// Vertex normals: negate X and Y
for (int i = 0; i < normals.length; i += 3) {
normals[i] = -normals[i];
normals[i + 1] = -normals[i + 1];
}
// Rest translations: negate X and Y
for (Vector3f t : restTranslations) {
t.x = -t.x;
t.y = -t.y;
}
// Rest rotations: conjugate by 180° Z = negate qx and qy
for (Quaternionf q : restRotations) {
q.x = -q.x;
q.y = -q.y;
}
// Inverse bind matrices: C * M * C where C = diag(-1, -1, 1)
Matrix4f C = new Matrix4f().scaling(-1, -1, 1);
Matrix4f temp = new Matrix4f();
for (Matrix4f ibm : inverseBindMatrices) {
temp.set(C).mul(ibm).mul(C);
ibm.set(temp);
}
// Animation quaternions: same conjugation
if (animClip != null) {
Quaternionf[][] rotations = animClip.rotations();
for (int j = 0; j < jointCount; j++) {
if (j < rotations.length && rotations[j] != null) {
for (Quaternionf q : rotations[j]) {
q.x = -q.x;
q.y = -q.y;
}
}
}
// Animation translations: negate X and Y (same as rest translations)
Vector3f[][] translations = animClip.translations();
if (translations != null) {
for (int j = 0; j < jointCount; j++) {
if (j < translations.length && translations[j] != null) {
for (Vector3f t : translations[j]) {
t.x = -t.x;
t.y = -t.y;
}
}
}
}
}
}
}

View File

@@ -0,0 +1,253 @@
package com.tiedup.remake.client.gltf;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import java.nio.ByteBuffer;
import java.util.List;
import org.joml.Quaternionf;
import org.joml.Vector3f;
/**
* Shared stateless utilities for parsing binary glTF (.glb) files.
*
* <p>These methods are used by both {@link GlbParser} (single-armature bondage meshes)
* and {@link com.tiedup.remake.v2.furniture.client.FurnitureGlbParser FurnitureGlbParser}
* (multi-armature furniture meshes). Extracted to eliminate ~160 lines of verbatim
* duplication between the two parsers.</p>
*
* <p>All methods are pure functions (no state, no side effects).</p>
*/
public final class GlbParserUtils {
// glTF component type constants
public static final int BYTE = 5120;
public static final int UNSIGNED_BYTE = 5121;
public static final int SHORT = 5122;
public static final int UNSIGNED_SHORT = 5123;
public static final int UNSIGNED_INT = 5125;
public static final int FLOAT = 5126;
private GlbParserUtils() {}
// ---- Material name parsing ----
/**
* Parse the root "materials" array and extract each material's "name" field.
* Returns an empty array if no materials are present.
*/
public static String[] parseMaterialNames(JsonObject root) {
if (!root.has("materials") || !root.get("materials").isJsonArray()) {
return new String[0];
}
JsonArray materials = root.getAsJsonArray("materials");
String[] names = new String[materials.size()];
for (int i = 0; i < materials.size(); i++) {
JsonObject mat = materials.get(i).getAsJsonObject();
names[i] = mat.has("name") ? mat.get("name").getAsString() : null;
}
return names;
}
// ---- Array flattening utilities ----
public static float[] flattenFloats(List<float[]> arrays) {
int total = 0;
for (float[] a : arrays) total += a.length;
float[] result = new float[total];
int offset = 0;
for (float[] a : arrays) {
System.arraycopy(a, 0, result, offset, a.length);
offset += a.length;
}
return result;
}
public static int[] flattenInts(List<int[]> arrays) {
int total = 0;
for (int[] a : arrays) total += a.length;
int[] result = new int[total];
int offset = 0;
for (int[] a : arrays) {
System.arraycopy(a, 0, result, offset, a.length);
offset += a.length;
}
return result;
}
// ---- Accessor reading utilities ----
public static float[] readFloatAccessor(
JsonArray accessors, JsonArray bufferViews,
ByteBuffer binData, int accessorIdx
) {
JsonObject accessor = accessors.get(accessorIdx).getAsJsonObject();
int count = accessor.get("count").getAsInt();
int componentType = accessor.get("componentType").getAsInt();
String type = accessor.get("type").getAsString();
int components = typeComponents(type);
int bvIdx = accessor.get("bufferView").getAsInt();
JsonObject bv = bufferViews.get(bvIdx).getAsJsonObject();
int byteOffset = (bv.has("byteOffset") ? bv.get("byteOffset").getAsInt() : 0)
+ (accessor.has("byteOffset") ? accessor.get("byteOffset").getAsInt() : 0);
int byteStride = bv.has("byteStride") ? bv.get("byteStride").getAsInt() : 0;
int totalElements = count * components;
float[] result = new float[totalElements];
int componentSize = componentByteSize(componentType);
int stride = byteStride > 0 ? byteStride : components * componentSize;
for (int i = 0; i < count; i++) {
int pos = byteOffset + i * stride;
for (int c = 0; c < components; c++) {
binData.position(pos + c * componentSize);
result[i * components + c] = readComponentAsFloat(binData, componentType);
}
}
return result;
}
public static int[] readIntAccessor(
JsonArray accessors, JsonArray bufferViews,
ByteBuffer binData, int accessorIdx
) {
JsonObject accessor = accessors.get(accessorIdx).getAsJsonObject();
int count = accessor.get("count").getAsInt();
int componentType = accessor.get("componentType").getAsInt();
String type = accessor.get("type").getAsString();
int components = typeComponents(type);
int bvIdx = accessor.get("bufferView").getAsInt();
JsonObject bv = bufferViews.get(bvIdx).getAsJsonObject();
int byteOffset = (bv.has("byteOffset") ? bv.get("byteOffset").getAsInt() : 0)
+ (accessor.has("byteOffset") ? accessor.get("byteOffset").getAsInt() : 0);
int byteStride = bv.has("byteStride") ? bv.get("byteStride").getAsInt() : 0;
int totalElements = count * components;
int[] result = new int[totalElements];
int componentSize = componentByteSize(componentType);
int stride = byteStride > 0 ? byteStride : components * componentSize;
for (int i = 0; i < count; i++) {
int pos = byteOffset + i * stride;
for (int c = 0; c < components; c++) {
binData.position(pos + c * componentSize);
result[i * components + c] = readComponentAsInt(binData, componentType);
}
}
return result;
}
public static float readComponentAsFloat(ByteBuffer buf, int componentType) {
return switch (componentType) {
case FLOAT -> buf.getFloat();
case BYTE -> buf.get() / 127.0f;
case UNSIGNED_BYTE -> (buf.get() & 0xFF) / 255.0f;
case SHORT -> buf.getShort() / 32767.0f;
case UNSIGNED_SHORT -> (buf.getShort() & 0xFFFF) / 65535.0f;
case UNSIGNED_INT -> (buf.getInt() & 0xFFFFFFFFL) / (float) 0xFFFFFFFFL;
default -> throw new IllegalArgumentException("Unknown component type: " + componentType);
};
}
public static int readComponentAsInt(ByteBuffer buf, int componentType) {
return switch (componentType) {
case BYTE -> buf.get();
case UNSIGNED_BYTE -> buf.get() & 0xFF;
case SHORT -> buf.getShort();
case UNSIGNED_SHORT -> buf.getShort() & 0xFFFF;
case UNSIGNED_INT -> buf.getInt();
case FLOAT -> (int) buf.getFloat();
default -> throw new IllegalArgumentException("Unknown component type: " + componentType);
};
}
public static int typeComponents(String type) {
return switch (type) {
case "SCALAR" -> 1;
case "VEC2" -> 2;
case "VEC3" -> 3;
case "VEC4" -> 4;
case "MAT4" -> 16;
default -> throw new IllegalArgumentException("Unknown accessor type: " + type);
};
}
public static int componentByteSize(int componentType) {
return switch (componentType) {
case BYTE, UNSIGNED_BYTE -> 1;
case SHORT, UNSIGNED_SHORT -> 2;
case UNSIGNED_INT, FLOAT -> 4;
default -> throw new IllegalArgumentException("Unknown component type: " + componentType);
};
}
// ---- Deep-copy utility ----
/**
* Deep-copy an AnimationClip (preserves original data before MC conversion).
*/
public static GltfData.AnimationClip deepCopyClip(GltfData.AnimationClip clip) {
Quaternionf[][] rawRotations = new Quaternionf[clip.rotations().length][];
for (int j = 0; j < clip.rotations().length; j++) {
if (clip.rotations()[j] != null) {
rawRotations[j] = new Quaternionf[clip.rotations()[j].length];
for (int f = 0; f < clip.rotations()[j].length; f++) {
rawRotations[j][f] = new Quaternionf(clip.rotations()[j][f]);
}
}
}
Vector3f[][] rawTranslations = null;
if (clip.translations() != null) {
rawTranslations = new Vector3f[clip.translations().length][];
for (int j = 0; j < clip.translations().length; j++) {
if (clip.translations()[j] != null) {
rawTranslations[j] = new Vector3f[clip.translations()[j].length];
for (int f = 0; f < clip.translations()[j].length; f++) {
rawTranslations[j][f] = new Vector3f(clip.translations()[j][f]);
}
}
}
}
return new GltfData.AnimationClip(
clip.timestamps().clone(), rawRotations, rawTranslations,
clip.frameCount()
);
}
// ---- Coordinate system conversion ----
/**
* Convert an animation clip's rotations and translations to MC space.
* Negate qx/qy for rotations and negate tx/ty for translations.
*/
public static void convertAnimationToMinecraftSpace(GltfData.AnimationClip clip, int jointCount) {
if (clip == null) return;
Quaternionf[][] rotations = clip.rotations();
for (int j = 0; j < jointCount; j++) {
if (j < rotations.length && rotations[j] != null) {
for (Quaternionf q : rotations[j]) {
q.x = -q.x;
q.y = -q.y;
}
}
}
Vector3f[][] translations = clip.translations();
if (translations != null) {
for (int j = 0; j < jointCount; j++) {
if (j < translations.length && translations[j] != null) {
for (Vector3f t : translations[j]) {
t.x = -t.x;
t.y = -t.y;
}
}
}
}
}
}

View File

@@ -0,0 +1,450 @@
package com.tiedup.remake.client.gltf;
import com.tiedup.remake.client.animation.BondageAnimationManager;
import com.tiedup.remake.client.animation.context.AnimationContext;
import com.tiedup.remake.client.animation.context.ContextAnimationFactory;
import com.tiedup.remake.client.animation.context.GlbAnimationResolver;
import com.tiedup.remake.client.animation.context.RegionBoneMapper;
import dev.kosmx.playerAnim.core.data.KeyframeAnimation;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import org.jetbrains.annotations.Nullable;
import net.minecraft.client.Minecraft;
import net.minecraft.client.player.AbstractClientPlayer;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.entity.LivingEntity;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
/**
* V2 Animation Applier -- manages dual-layer animation for V2 bondage items.
*
* <p>Orchestrates two PlayerAnimator layers simultaneously:
* <ul>
* <li><b>Context layer</b> (priority 40): base body posture (stand/sit/kneel/sneak/walk)
* with item-owned parts disabled, via {@link ContextAnimationFactory}</li>
* <li><b>Item layer</b> (priority 42): per-item GLB animation with only owned bones enabled,
* via {@link GltfPoseConverter#convertSelective}</li>
* </ul>
*
* <p>Each equipped V2 item controls ONLY the bones matching its occupied body regions.
* Bones not owned by any item pass through from the context layer, which provides the
* appropriate base posture animation.
*
* <p>State tracking avoids redundant animation replays: a composite key of
* {@code animSource|context|ownedParts} is compared per-entity to skip no-op updates.
*
* <p>Item animations are cached by {@code animSource#context#ownedParts} since the same
* GLB + context + owned parts always produces the same KeyframeAnimation.
*
* @see ContextAnimationFactory
* @see GlbAnimationResolver
* @see GltfPoseConverter#convertSelective
* @see BondageAnimationManager
*/
@OnlyIn(Dist.CLIENT)
public final class GltfAnimationApplier {
private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
/**
* Cache of converted item-layer KeyframeAnimations.
* Keyed by "animSource#context#ownedPartsHash".
* Same GLB + same context + same owned parts = same KeyframeAnimation.
*/
private static final Map<String, KeyframeAnimation> itemAnimCache = new ConcurrentHashMap<>();
/**
* Track which composite state is currently active per entity, to avoid redundant replays.
* Keyed by entity UUID, value is "animSource|context|sortedParts".
*/
private static final Map<UUID, String> activeStateKeys = new ConcurrentHashMap<>();
/** Track cache keys where GLB loading failed, to avoid per-tick retries. */
private static final Set<String> failedLoadKeys = ConcurrentHashMap.newKeySet();
private GltfAnimationApplier() {}
// ========================================
// INIT (legacy)
// ========================================
/**
* Legacy init method -- called by GltfClientSetup.
* No-op: layer registration is handled by {@link BondageAnimationManager#init()}.
*/
public static void init() {
// No-op: animation layers are managed by BondageAnimationManager
}
// ========================================
// V2 DUAL-LAYER API
// ========================================
/**
* Apply the full V2 animation state: context layer + item layer.
*
* <p>Flow:
* <ol>
* <li>Build a composite state key and skip if unchanged</li>
* <li>Create/retrieve a context animation with disabledOnContext parts disabled,
* play on context layer via {@link BondageAnimationManager#playContext}</li>
* <li>Load the GLB (from {@code animationSource} or {@code modelLoc}),
* resolve the named animation via {@link GlbAnimationResolver#resolve},
* convert with selective parts via {@link GltfPoseConverter#convertSelective},
* play on item layer via {@link BondageAnimationManager#playDirect}</li>
* </ol>
*
* <p>The ownership model enables "free bone" animation: if a bone is not claimed
* by any item, the winning item can animate it IF its GLB has keyframes for that bone.
* This allows a straitjacket (ARMS+TORSO) to also animate free legs.</p>
*
* @param entity the entity to animate
* @param modelLoc the item's GLB model (for mesh rendering, and default animation source)
* @param animationSource separate GLB for animations (shared template), or null to use modelLoc
* @param context current animation context (STAND_IDLE, SIT_IDLE, etc.)
* @param ownership bone ownership: which parts this item owns vs other items
* @return true if the item layer animation was applied successfully
*/
public static boolean applyV2Animation(LivingEntity entity, ResourceLocation modelLoc,
@Nullable ResourceLocation animationSource,
AnimationContext context, RegionBoneMapper.BoneOwnership ownership) {
if (entity == null || modelLoc == null) return false;
ResourceLocation animSource = animationSource != null ? animationSource : modelLoc;
// Cache key includes both owned and enabled parts for full disambiguation
String ownedKey = canonicalPartsKey(ownership.thisParts());
String enabledKey = canonicalPartsKey(ownership.enabledParts());
String partsKey = ownedKey + ";" + enabledKey;
// Build composite state key to avoid redundant updates
String stateKey = animSource + "|" + context.name() + "|" + partsKey;
String currentKey = activeStateKeys.get(entity.getUUID());
if (stateKey.equals(currentKey)) {
return true; // Already active, no-op
}
// === Layer 1: Context animation (base body posture) ===
// Parts owned by ANY item (this or others) are disabled on the context layer.
// Only free parts remain enabled on context.
KeyframeAnimation contextAnim = ContextAnimationFactory.create(
context, ownership.disabledOnContext());
if (contextAnim != null) {
BondageAnimationManager.playContext(entity, contextAnim);
}
// === Layer 2: Item animation (GLB pose with selective bones) ===
String itemCacheKey = buildItemCacheKey(animSource, context, partsKey);
// Skip if this GLB already failed to load
if (failedLoadKeys.contains(itemCacheKey)) {
activeStateKeys.put(entity.getUUID(), stateKey);
return false;
}
KeyframeAnimation itemAnim = itemAnimCache.get(itemCacheKey);
if (itemAnim == null) {
GltfData animData = GlbAnimationResolver.resolveAnimationData(modelLoc, animationSource);
if (animData == null) {
LOGGER.warn("[GltfPipeline] Failed to load animation GLB: {}", animSource);
failedLoadKeys.add(itemCacheKey);
activeStateKeys.put(entity.getUUID(), stateKey);
return false;
}
// Resolve which named animation to use (with fallback chain + variant selection)
String glbAnimName = GlbAnimationResolver.resolve(animData, context);
// Pass both owned parts and enabled parts (owned + free) for selective enabling
itemAnim = GltfPoseConverter.convertSelective(
animData, glbAnimName, ownership.thisParts(), ownership.enabledParts());
itemAnimCache.put(itemCacheKey, itemAnim);
}
BondageAnimationManager.playDirect(entity, itemAnim);
activeStateKeys.put(entity.getUUID(), stateKey);
return true;
}
/**
* Apply V2 animation from ALL equipped items simultaneously.
*
* <p>Each item contributes keyframes for only its owned bones into a shared
* {@link KeyframeAnimation.AnimationBuilder}. The first item in the list (highest priority)
* can additionally animate free bones if its GLB has keyframes for them.</p>
*
* @param entity the entity to animate
* @param items resolved V2 items with per-item ownership, sorted by priority desc
* @param context current animation context
* @param allOwnedParts union of all owned parts across all items
* @return true if the composite animation was applied
*/
public static boolean applyMultiItemV2Animation(LivingEntity entity,
List<RegionBoneMapper.V2ItemAnimInfo> items,
AnimationContext context, Set<String> allOwnedParts) {
if (entity == null || items.isEmpty()) return false;
// Build composite state key
StringBuilder keyBuilder = new StringBuilder();
for (RegionBoneMapper.V2ItemAnimInfo item : items) {
ResourceLocation src = item.animSource() != null ? item.animSource() : item.modelLoc();
keyBuilder.append(src).append(':').append(canonicalPartsKey(item.ownedParts())).append(';');
}
keyBuilder.append(context.name());
String stateKey = keyBuilder.toString();
String currentKey = activeStateKeys.get(entity.getUUID());
if (stateKey.equals(currentKey)) {
return true; // Already active
}
// === Layer 1: Context animation ===
KeyframeAnimation contextAnim = ContextAnimationFactory.create(context, allOwnedParts);
if (contextAnim != null) {
BondageAnimationManager.playContext(entity, contextAnim);
}
// === Layer 2: Composite item animation ===
String compositeCacheKey = "multi#" + stateKey;
if (failedLoadKeys.contains(compositeCacheKey)) {
activeStateKeys.put(entity.getUUID(), stateKey);
return false;
}
KeyframeAnimation compositeAnim = itemAnimCache.get(compositeCacheKey);
if (compositeAnim == null) {
KeyframeAnimation.AnimationBuilder builder =
new KeyframeAnimation.AnimationBuilder(
dev.kosmx.playerAnim.core.data.AnimationFormat.JSON_EMOTECRAFT);
builder.beginTick = 0;
builder.endTick = 1;
builder.stopTick = 1;
builder.isLooped = true;
builder.returnTick = 0;
builder.name = "gltf_composite";
boolean anyLoaded = false;
for (int i = 0; i < items.size(); i++) {
RegionBoneMapper.V2ItemAnimInfo item = items.get(i);
ResourceLocation animSource = item.animSource() != null ? item.animSource() : item.modelLoc();
GltfData animData = GlbAnimationResolver.resolveAnimationData(item.modelLoc(), item.animSource());
if (animData == null) {
LOGGER.warn("[GltfPipeline] Failed to load GLB for multi-item: {}", animSource);
continue;
}
String glbAnimName = GlbAnimationResolver.resolve(animData, context);
GltfData.AnimationClip rawClip;
if (glbAnimName != null) {
rawClip = animData.getRawAnimation(glbAnimName);
} else {
rawClip = null;
}
if (rawClip == null) {
rawClip = animData.rawGltfAnimation();
}
// Compute effective parts: intersect animation_bones whitelist with ownedParts
// if the item declares per-animation bone filtering.
Set<String> effectiveParts = item.ownedParts();
if (glbAnimName != null && !item.animationBones().isEmpty()) {
Set<String> override = item.animationBones().get(glbAnimName);
if (override != null) {
Set<String> filtered = new HashSet<>(override);
filtered.retainAll(item.ownedParts());
if (!filtered.isEmpty()) {
effectiveParts = filtered;
}
}
}
GltfPoseConverter.addBonesToBuilder(
builder, animData, rawClip, effectiveParts);
anyLoaded = true;
LOGGER.debug("[GltfPipeline] Multi-item: {} -> owned={}, effective={}, anim={}",
animSource, item.ownedParts(), effectiveParts, glbAnimName);
}
if (!anyLoaded) {
failedLoadKeys.add(compositeCacheKey);
activeStateKeys.put(entity.getUUID(), stateKey);
return false;
}
// Enable only owned parts on the item layer.
// Free parts (head, body, etc. not owned by any item) are disabled here
// so they pass through to the context layer / vanilla animation.
String[] allPartNames = {"head", "body", "rightArm", "leftArm", "rightLeg", "leftLeg"};
for (String partName : allPartNames) {
KeyframeAnimation.StateCollection part = getPartByName(builder, partName);
if (part != null) {
if (allOwnedParts.contains(partName)) {
part.fullyEnablePart(false);
} else {
part.setEnabled(false);
}
}
}
compositeAnim = builder.build();
itemAnimCache.put(compositeCacheKey, compositeAnim);
}
BondageAnimationManager.playDirect(entity, compositeAnim);
activeStateKeys.put(entity.getUUID(), stateKey);
return true;
}
// ========================================
// CLEAR / QUERY
// ========================================
/**
* Clear all V2 animation layers from an entity and remove tracking.
* Stops both the context layer and the item layer.
*
* @param entity the entity to clear animations from
*/
public static void clearV2Animation(LivingEntity entity) {
if (entity == null) return;
activeStateKeys.remove(entity.getUUID());
BondageAnimationManager.stopContext(entity);
BondageAnimationManager.stopAnimation(entity);
}
/**
* Check if an entity has active V2 animation state.
*
* @param entity the entity to check
* @return true if the entity has an active V2 animation state key
*/
public static boolean hasActiveState(LivingEntity entity) {
return entity != null && activeStateKeys.containsKey(entity.getUUID());
}
/**
* Remove tracking for an entity (e.g., on logout/unload).
* Does NOT stop any currently playing animation -- use {@link #clearV2Animation} for that.
*
* @param entityId UUID of the entity to stop tracking
*/
public static void removeTracking(UUID entityId) {
activeStateKeys.remove(entityId);
}
// ========================================
// CACHE MANAGEMENT
// ========================================
/**
* Invalidate all cached item animations and tracking state.
* Call this on resource reload (F3+T) to pick up changed GLB/JSON files.
*
* <p>Does NOT clear ContextAnimationFactory or ContextGlbRegistry here.
* Those are cleared in the reload listener AFTER ContextGlbRegistry.reload()
* to prevent the render thread from caching stale JSON fallbacks during
* the window between clear and repopulate.</p>
*/
public static void invalidateCache() {
itemAnimCache.clear();
activeStateKeys.clear();
failedLoadKeys.clear();
}
/**
* Clear all state (cache + tracking). Called on world unload.
* Clears everything including context caches (no concurrent reload during unload).
*/
public static void clearAll() {
itemAnimCache.clear();
activeStateKeys.clear();
failedLoadKeys.clear();
com.tiedup.remake.client.animation.context.ContextGlbRegistry.clear();
ContextAnimationFactory.clearCache();
}
// ========================================
// LEGACY F9 DEBUG TOGGLE
// ========================================
private static boolean debugEnabled = false;
/**
* Toggle debug mode via F9 key.
* When enabled, applies handcuffs V2 animation (rightArm + leftArm) to the local player
* using STAND_IDLE context. When disabled, clears all V2 animation.
*/
public static void toggle() {
debugEnabled = !debugEnabled;
LOGGER.info("[GltfPipeline] Debug toggle: {}", debugEnabled ? "ON" : "OFF");
AbstractClientPlayer player = Minecraft.getInstance().player;
if (player == null) return;
if (debugEnabled) {
ResourceLocation modelLoc = ResourceLocation.fromNamespaceAndPath(
"tiedup", "models/gltf/v2/handcuffs/cuffs_prototype.glb"
);
Set<String> armParts = Set.of("rightArm", "leftArm");
RegionBoneMapper.BoneOwnership debugOwnership =
new RegionBoneMapper.BoneOwnership(armParts, Set.of());
applyV2Animation(player, modelLoc, null, AnimationContext.STAND_IDLE, debugOwnership);
} else {
clearV2Animation(player);
}
}
/**
* Whether F9 debug mode is currently enabled.
*/
public static boolean isEnabled() {
return debugEnabled;
}
// ========================================
// INTERNAL
// ========================================
/**
* Build cache key for item-layer animations.
* Format: "animSource#contextName#sortedParts"
*/
private static String buildItemCacheKey(ResourceLocation animSource,
AnimationContext context, String partsKey) {
return animSource + "#" + context.name() + "#" + partsKey;
}
/**
* Build a canonical, deterministic string from the owned parts set.
* Sorted alphabetically and joined by comma — guarantees no hash collisions.
*/
private static String canonicalPartsKey(Set<String> ownedParts) {
return String.join(",", new TreeSet<>(ownedParts));
}
/**
* Look up an {@link KeyframeAnimation.StateCollection} by part name on a builder.
*/
private static KeyframeAnimation.StateCollection getPartByName(
KeyframeAnimation.AnimationBuilder builder, String name) {
return switch (name) {
case "head" -> builder.head;
case "body" -> builder.body;
case "rightArm" -> builder.rightArm;
case "leftArm" -> builder.leftArm;
case "rightLeg" -> builder.rightLeg;
case "leftLeg" -> builder.leftLeg;
default -> null;
};
}
}

View File

@@ -0,0 +1,104 @@
package com.tiedup.remake.client.gltf;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import net.minecraft.client.model.HumanoidModel;
import net.minecraft.client.model.geom.ModelPart;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
/**
* Maps glTF bone names to Minecraft HumanoidModel parts.
* Handles upper bones (full rotation) and lower bones (bend only).
*/
@OnlyIn(Dist.CLIENT)
public final class GltfBoneMapper {
/** Maps glTF bone name -> MC model part field name */
private static final Map<String, String> BONE_TO_PART = new HashMap<>();
/** Lower bones that represent bend (elbow/knee) */
private static final Set<String> LOWER_BONES = Set.of(
"leftLowerArm", "rightLowerArm",
"leftLowerLeg", "rightLowerLeg"
);
/** Maps lower bone name -> corresponding upper bone name */
private static final Map<String, String> LOWER_TO_UPPER = Map.of(
"leftLowerArm", "leftUpperArm",
"rightLowerArm", "rightUpperArm",
"leftLowerLeg", "leftUpperLeg",
"rightLowerLeg", "rightUpperLeg"
);
static {
BONE_TO_PART.put("body", "body");
BONE_TO_PART.put("torso", "body");
BONE_TO_PART.put("head", "head");
BONE_TO_PART.put("leftUpperArm", "leftArm");
BONE_TO_PART.put("leftLowerArm", "leftArm");
BONE_TO_PART.put("rightUpperArm", "rightArm");
BONE_TO_PART.put("rightLowerArm", "rightArm");
BONE_TO_PART.put("leftUpperLeg", "leftLeg");
BONE_TO_PART.put("leftLowerLeg", "leftLeg");
BONE_TO_PART.put("rightUpperLeg", "rightLeg");
BONE_TO_PART.put("rightLowerLeg", "rightLeg");
}
private GltfBoneMapper() {}
/**
* Get the ModelPart corresponding to a glTF bone name.
*
* @param model the HumanoidModel
* @param boneName glTF bone name
* @return the ModelPart, or null if not mapped
*/
public static ModelPart getModelPart(HumanoidModel<?> model, String boneName) {
String partName = BONE_TO_PART.get(boneName);
if (partName == null) return null;
return switch (partName) {
case "body" -> model.body;
case "head" -> model.head;
case "leftArm" -> model.leftArm;
case "rightArm" -> model.rightArm;
case "leftLeg" -> model.leftLeg;
case "rightLeg" -> model.rightLeg;
default -> null;
};
}
/**
* Check if this bone represents a lower segment (bend: elbow/knee).
*/
public static boolean isLowerBone(String boneName) {
return LOWER_BONES.contains(boneName);
}
/**
* Get the upper bone name for a given lower bone.
* Returns null if not a lower bone.
*/
public static String getUpperBoneFor(String lowerBoneName) {
return LOWER_TO_UPPER.get(lowerBoneName);
}
/**
* Get the PlayerAnimator part name for a glTF bone.
* Both glTF and PlayerAnimator use "body" for the torso part.
*/
public static String getAnimPartName(String boneName) {
String partName = BONE_TO_PART.get(boneName);
if (partName == null) return null;
return partName;
}
/**
* Check if a bone name is known/mapped.
*/
public static boolean isKnownBone(String boneName) {
return BONE_TO_PART.containsKey(boneName);
}
}

View File

@@ -0,0 +1,67 @@
package com.tiedup.remake.client.gltf;
import java.io.InputStream;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
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 glTF data.
* Loads .glb files via Minecraft's ResourceManager on first access.
*/
@OnlyIn(Dist.CLIENT)
public final class GltfCache {
private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
private static final Map<ResourceLocation, GltfData> CACHE = new ConcurrentHashMap<>();
private GltfCache() {}
/**
* Get parsed glTF data for a resource, loading it on first access.
*
* @param location resource location of the .glb file (e.g. "tiedup:models/gltf/v2/handcuffs/cuffs_prototype.glb")
* @return parsed GltfData, or null if loading failed
*/
public static GltfData get(ResourceLocation location) {
GltfData cached = CACHE.get(location);
if (cached != null) return cached;
try {
Resource resource = Minecraft.getInstance()
.getResourceManager()
.getResource(location)
.orElse(null);
if (resource == null) {
LOGGER.error("[GltfPipeline] Resource not found: {}", location);
return null;
}
try (InputStream is = resource.open()) {
GltfData data = GlbParser.parse(is, location.toString());
CACHE.put(location, data);
return data;
}
} catch (Exception e) {
LOGGER.error("[GltfPipeline] Failed to load GLB: {}", location, e);
return null;
}
}
/** Clear all cached data (call on resource reload). */
public static void clearCache() {
CACHE.clear();
LOGGER.info("[GltfPipeline] Cache cleared");
}
/** Initialize the cache (called during FMLClientSetupEvent). */
public static void init() {
LOGGER.info("[GltfPipeline] GltfCache initialized");
}
}

View File

@@ -0,0 +1,140 @@
package com.tiedup.remake.client.gltf;
import com.mojang.blaze3d.platform.InputConstants;
import com.tiedup.remake.client.animation.context.ContextAnimationFactory;
import com.tiedup.remake.client.animation.context.ContextGlbRegistry;
import com.tiedup.remake.v2.bondage.client.V2BondageRenderLayer;
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemReloadListener;
import net.minecraft.client.KeyMapping;
import net.minecraft.client.renderer.entity.player.PlayerRenderer;
import net.minecraft.server.packs.resources.ResourceManager;
import net.minecraft.util.profiling.ProfilerFiller;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.client.event.EntityRenderersEvent;
import net.minecraftforge.client.event.RegisterClientReloadListenersEvent;
import net.minecraftforge.client.event.RegisterKeyMappingsEvent;
import net.minecraftforge.event.TickEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
import net.minecraftforge.fml.event.lifecycle.FMLClientSetupEvent;
import net.minecraft.server.packs.resources.SimplePreparableReloadListener;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
/**
* Forge event registration for the glTF pipeline.
* Registers keybind (F9), render layers, and animation factory.
*/
public final class GltfClientSetup {
private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
private static final String KEY_CATEGORY = "key.categories.tiedup";
static final KeyMapping TOGGLE_KEY = new KeyMapping(
"key.tiedup.gltf_toggle",
InputConstants.Type.KEYSYM,
InputConstants.KEY_F9,
KEY_CATEGORY
);
private GltfClientSetup() {}
/**
* MOD bus event subscribers (FMLClientSetupEvent, RegisterKeyMappings, AddLayers).
*/
@Mod.EventBusSubscriber(
modid = "tiedup",
bus = Mod.EventBusSubscriber.Bus.MOD,
value = Dist.CLIENT
)
public static class ModBusEvents {
@SubscribeEvent
public static void onClientSetup(FMLClientSetupEvent event) {
event.enqueueWork(() -> {
GltfCache.init();
GltfAnimationApplier.init();
LOGGER.info("[GltfPipeline] Client setup complete");
});
}
@SubscribeEvent
public static void onRegisterKeybindings(RegisterKeyMappingsEvent event) {
event.register(TOGGLE_KEY);
LOGGER.info("[GltfPipeline] Keybind registered: F9");
}
@SuppressWarnings("unchecked")
@SubscribeEvent
public static void onAddLayers(EntityRenderersEvent.AddLayers event) {
// Add GltfRenderLayer (prototype/debug with F9 toggle) to player renderers
var defaultRenderer = event.getSkin("default");
if (defaultRenderer instanceof PlayerRenderer playerRenderer) {
playerRenderer.addLayer(new GltfRenderLayer(playerRenderer));
playerRenderer.addLayer(new V2BondageRenderLayer<>(playerRenderer));
LOGGER.info("[GltfPipeline] Render layers added to 'default' player renderer");
}
// Add both layers to slim player renderer (Alex)
var slimRenderer = event.getSkin("slim");
if (slimRenderer instanceof PlayerRenderer playerRenderer) {
playerRenderer.addLayer(new GltfRenderLayer(playerRenderer));
playerRenderer.addLayer(new V2BondageRenderLayer<>(playerRenderer));
LOGGER.info("[GltfPipeline] Render layers added to 'slim' player renderer");
}
}
/**
* Register resource reload listener to clear GLB caches on resource pack reload.
* This ensures re-exported GLB models are picked up without restarting the game.
*/
@SubscribeEvent
public static void onRegisterReloadListeners(RegisterClientReloadListenersEvent event) {
event.registerReloadListener(new SimplePreparableReloadListener<Void>() {
@Override
protected Void prepare(ResourceManager resourceManager, ProfilerFiller profiler) {
return null;
}
@Override
protected void apply(Void nothing, ResourceManager resourceManager, ProfilerFiller profiler) {
GltfCache.clearCache();
GltfAnimationApplier.invalidateCache();
GltfMeshRenderer.clearRenderTypeCache();
// Reload context GLB animations from resource packs FIRST,
// then clear the factory cache so it rebuilds against the
// new GLB registry (prevents stale JSON fallback caching).
ContextGlbRegistry.reload(resourceManager);
ContextAnimationFactory.clearCache();
com.tiedup.remake.v2.furniture.client.FurnitureGltfCache.clear();
LOGGER.info("[GltfPipeline] Caches cleared on resource reload");
}
});
LOGGER.info("[GltfPipeline] Resource reload listener registered");
// Data-driven bondage item definitions (tiedup_items/*.json)
event.registerReloadListener(new DataDrivenItemReloadListener());
LOGGER.info("[GltfPipeline] Data-driven item reload listener registered");
}
}
/**
* FORGE bus event subscribers (ClientTickEvent for keybind toggle).
*/
@Mod.EventBusSubscriber(
modid = "tiedup",
bus = Mod.EventBusSubscriber.Bus.FORGE,
value = Dist.CLIENT
)
public static class ForgeBusEvents {
@SubscribeEvent
public static void onClientTick(TickEvent.ClientTickEvent event) {
if (event.phase != TickEvent.Phase.END) return;
while (TOGGLE_KEY.consumeClick()) {
GltfAnimationApplier.toggle();
}
}
}
}

View File

@@ -0,0 +1,194 @@
package com.tiedup.remake.client.gltf;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.jetbrains.annotations.Nullable;
import org.joml.Matrix4f;
import org.joml.Quaternionf;
import org.joml.Vector3f;
/**
* Immutable container for parsed glTF/GLB data.
* Holds mesh geometry, skinning data, bone hierarchy, and optional animations.
* <p>
* Supports multiple named animations per GLB file. The "default" animation
* (first clip) is accessible via {@link #animation()} and {@link #rawGltfAnimation()}
* for backward compatibility. All animations are available via
* {@link #namedAnimations()}.
*/
public final class GltfData {
// -- Mesh geometry (flattened arrays) --
private final float[] positions; // VEC3, length = vertexCount * 3
private final float[] normals; // VEC3, length = vertexCount * 3
private final float[] texCoords; // VEC2, length = vertexCount * 2
private final int[] indices; // triangle indices
// -- Skinning data (per-vertex, 4 influences) --
private final int[] joints; // 4 joint indices per vertex, length = vertexCount * 4
private final float[] weights; // 4 weights per vertex, length = vertexCount * 4
// -- Bone hierarchy (MC-converted for skinning) --
private final String[] jointNames;
private final int[] parentJointIndices; // -1 for root
private final Matrix4f[] inverseBindMatrices;
private final Quaternionf[] restRotations;
private final Vector3f[] restTranslations;
// -- Raw glTF rotations (unconverted, for pose conversion) --
private final Quaternionf[] rawGltfRestRotations;
@Nullable
private final AnimationClip rawGltfAnimation;
// -- Optional animation clip (MC-converted for skinning) --
@Nullable
private final AnimationClip animation;
// -- Multiple named animations --
private final Map<String, AnimationClip> namedAnimations; // MC-converted
private final Map<String, AnimationClip> rawNamedAnimations; // raw glTF space
// -- Per-primitive material/tint info --
private final List<Primitive> primitives;
// -- Counts --
private final int vertexCount;
private final int jointCount;
/**
* Full constructor with multiple named animations and per-primitive data.
*/
public GltfData(
float[] positions, float[] normals, float[] texCoords,
int[] indices, int[] joints, float[] weights,
String[] jointNames, int[] parentJointIndices,
Matrix4f[] inverseBindMatrices,
Quaternionf[] restRotations, Vector3f[] restTranslations,
Quaternionf[] rawGltfRestRotations,
@Nullable AnimationClip rawGltfAnimation,
@Nullable AnimationClip animation,
Map<String, AnimationClip> namedAnimations,
Map<String, AnimationClip> rawNamedAnimations,
List<Primitive> primitives,
int vertexCount, int jointCount
) {
this.positions = positions;
this.normals = normals;
this.texCoords = texCoords;
this.indices = indices;
this.joints = joints;
this.weights = weights;
this.jointNames = jointNames;
this.parentJointIndices = parentJointIndices;
this.inverseBindMatrices = inverseBindMatrices;
this.restRotations = restRotations;
this.restTranslations = restTranslations;
this.rawGltfRestRotations = rawGltfRestRotations;
this.rawGltfAnimation = rawGltfAnimation;
this.animation = animation;
this.namedAnimations = Collections.unmodifiableMap(new LinkedHashMap<>(namedAnimations));
this.rawNamedAnimations = Collections.unmodifiableMap(new LinkedHashMap<>(rawNamedAnimations));
this.primitives = List.copyOf(primitives);
this.vertexCount = vertexCount;
this.jointCount = jointCount;
}
/**
* Legacy constructor for backward compatibility (single animation only).
*/
public GltfData(
float[] positions, float[] normals, float[] texCoords,
int[] indices, int[] joints, float[] weights,
String[] jointNames, int[] parentJointIndices,
Matrix4f[] inverseBindMatrices,
Quaternionf[] restRotations, Vector3f[] restTranslations,
Quaternionf[] rawGltfRestRotations,
@Nullable AnimationClip rawGltfAnimation,
@Nullable AnimationClip animation,
int vertexCount, int jointCount
) {
this(positions, normals, texCoords, indices, joints, weights,
jointNames, parentJointIndices, inverseBindMatrices,
restRotations, restTranslations, rawGltfRestRotations,
rawGltfAnimation, animation,
new LinkedHashMap<>(), new LinkedHashMap<>(),
List.of(new Primitive(indices, null, false, null)),
vertexCount, jointCount);
}
public float[] positions() { return positions; }
public float[] normals() { return normals; }
public float[] texCoords() { return texCoords; }
public int[] indices() { return indices; }
public int[] joints() { return joints; }
public float[] weights() { return weights; }
public String[] jointNames() { return jointNames; }
public int[] parentJointIndices() { return parentJointIndices; }
public Matrix4f[] inverseBindMatrices() { return inverseBindMatrices; }
public Quaternionf[] restRotations() { return restRotations; }
public Vector3f[] restTranslations() { return restTranslations; }
public Quaternionf[] rawGltfRestRotations() { return rawGltfRestRotations; }
@Nullable
public AnimationClip rawGltfAnimation() { return rawGltfAnimation; }
@Nullable
public AnimationClip animation() { return animation; }
public int vertexCount() { return vertexCount; }
public int jointCount() { return jointCount; }
/** Per-primitive material and tint metadata. One entry per glTF primitive in the mesh. */
public List<Primitive> primitives() { return primitives; }
/** All named animations in MC-converted space. Keys are animation names (e.g. "BasicPose", "Struggle"). */
public Map<String, AnimationClip> namedAnimations() { return namedAnimations; }
/** Get a specific named animation in MC-converted space, or null if not found. */
@Nullable
public AnimationClip getAnimation(String name) { return namedAnimations.get(name); }
/** Get a specific named animation in raw glTF space, or null if not found. */
@Nullable
public AnimationClip getRawAnimation(String name) { return rawNamedAnimations.get(name); }
/**
* Animation clip: per-bone timestamps, quaternion rotations, and optional translations.
*/
public static final class AnimationClip {
private final float[] timestamps; // shared timestamps
private final Quaternionf[][] rotations; // [jointIndex][frameIndex], null if no anim
@Nullable
private final Vector3f[][] translations; // [jointIndex][frameIndex], null if no anim
private final int frameCount;
public AnimationClip(float[] timestamps, Quaternionf[][] rotations,
@Nullable Vector3f[][] translations, int frameCount) {
this.timestamps = timestamps;
this.rotations = rotations;
this.translations = translations;
this.frameCount = frameCount;
}
public float[] timestamps() { return timestamps; }
public Quaternionf[][] rotations() { return rotations; }
@Nullable
public Vector3f[][] translations() { return translations; }
public int frameCount() { return frameCount; }
}
/**
* Per-primitive metadata parsed from the glTF mesh.
* Each primitive corresponds to a material assignment in Blender.
*
* @param indices triangle indices for this primitive (already offset to the unified vertex buffer)
* @param materialName the glTF material name, or null if unassigned
* @param tintable true if the material name starts with "tintable_"
* @param tintChannel the tint channel key (e.g. "tintable_0"), or null if not tintable
*/
public record Primitive(
int[] indices,
@Nullable String materialName,
boolean tintable,
@Nullable String tintChannel
) {}
}

View File

@@ -0,0 +1,245 @@
package com.tiedup.remake.client.gltf;
import dev.kosmx.playerAnim.core.util.Pair;
import dev.kosmx.playerAnim.impl.IAnimatedPlayer;
import dev.kosmx.playerAnim.impl.animation.AnimationApplier;
import net.minecraft.client.model.HumanoidModel;
import net.minecraft.client.model.geom.ModelPart;
import net.minecraft.world.entity.LivingEntity;
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;
import org.joml.Quaternionf;
import org.joml.Vector3f;
/**
* Reads the LIVE skeleton state from HumanoidModel (after PlayerAnimator + bendy-lib
* have applied all rotations for the current frame) and produces joint matrices
* compatible with {@link GltfSkinningEngine#skinVertex}.
* <p>
* KEY INSIGHT: The ModelPart xRot/yRot/zRot values set by PlayerAnimator represent
* DELTA rotations (difference from rest pose) expressed in the MC model-def frame.
* GltfPoseConverter computed them as parent-frame deltas, decomposed to Euler ZYX.
* <p>
* To reconstruct the correct LOCAL rotation for the glTF hierarchy:
* <pre>
* delta = rotationZYX(zRot, yRot, xRot) // MC-frame delta from ModelPart
* localRot = delta * restQ_mc // delta applied on top of local rest
* </pre>
* No de-parenting is needed because both delta and restQ_mc are already in the
* parent's local frame. The MC-to-glTF conjugation (negate qx,qy) is a homomorphism,
* so frame relationships are preserved through the conversion.
* <p>
* For bones WITHOUT a MC ModelPart (root, torso), use the MC-converted rest rotation
* directly from GltfData.
*/
@OnlyIn(Dist.CLIENT)
public final class GltfLiveBoneReader {
private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
private GltfLiveBoneReader() {}
/**
* Compute joint matrices by reading live skeleton state from the HumanoidModel.
* <p>
* For upper bones: reconstructs the MC-frame delta from ModelPart euler angles,
* then composes with the MC-converted rest rotation to get the local rotation.
* For lower bones: reads bend values from the entity's AnimationApplier and
* composes the bend delta with the local rest rotation.
* For non-animated bones: uses rest rotation from GltfData directly.
* <p>
* The resulting joint matrices should match {@link GltfSkinningEngine#computeJointMatrices}
* when the player is in the rest pose (no animation active).
*
* @param model the HumanoidModel after PlayerAnimator has applied rotations
* @param data parsed glTF data (MC-converted)
* @param entity the living entity being rendered
* @return array of joint matrices ready for skinning, or null on failure
*/
public static Matrix4f[] computeJointMatricesFromModel(
HumanoidModel<?> model, GltfData data, LivingEntity entity
) {
if (model == null || data == null || entity == null) return null;
int jointCount = data.jointCount();
Matrix4f[] jointMatrices = new Matrix4f[jointCount];
Matrix4f[] worldTransforms = new Matrix4f[jointCount];
int[] parents = data.parentJointIndices();
String[] jointNames = data.jointNames();
Quaternionf[] restRotations = data.restRotations();
Vector3f[] restTranslations = data.restTranslations();
// Get the AnimationApplier for bend values (may be null)
AnimationApplier emote = getAnimationApplier(entity);
for (int j = 0; j < jointCount; j++) {
String boneName = jointNames[j];
Quaternionf localRot;
if (GltfBoneMapper.isLowerBone(boneName)) {
// --- Lower bone: reconstruct from bend values ---
localRot = computeLowerBoneLocalRotation(
boneName, j, restRotations, emote
);
} else if (hasUniqueModelPart(boneName)) {
// --- Upper bone with a unique ModelPart ---
ModelPart part = GltfBoneMapper.getModelPart(model, boneName);
if (part != null) {
localRot = computeUpperBoneLocalRotation(
part, j, restRotations
);
} else {
// Fallback: use rest rotation
localRot = new Quaternionf(restRotations[j]);
}
} else {
// --- Non-animated bone (root, torso, etc.): use rest rotation ---
localRot = new Quaternionf(restRotations[j]);
}
// Build local transform: translate(restTranslation) * rotate(localRot)
Matrix4f local = new Matrix4f();
local.translate(restTranslations[j]);
local.rotate(localRot);
// Compose with parent to get world transform
if (parents[j] >= 0 && worldTransforms[parents[j]] != null) {
worldTransforms[j] = new Matrix4f(worldTransforms[parents[j]]).mul(local);
} else {
worldTransforms[j] = new Matrix4f(local);
}
// Final joint matrix = worldTransform * inverseBindMatrix
jointMatrices[j] = new Matrix4f(worldTransforms[j])
.mul(data.inverseBindMatrices()[j]);
}
return jointMatrices;
}
/**
* Compute local rotation for an upper bone that has a unique ModelPart.
* <p>
* ModelPart xRot/yRot/zRot are DELTA rotations (set by PlayerAnimator) expressed
* as ZYX Euler angles in the MC model-def frame. These deltas were originally
* computed by GltfPoseConverter as parent-frame quantities.
* <p>
* The local rotation for the glTF hierarchy is simply:
* <pre>
* delta = rotationZYX(zRot, yRot, xRot)
* localRot = delta * restQ_mc
* </pre>
* No de-parenting is needed: both delta and restQ_mc are already in the parent's
* frame. The MC-to-glTF negate-xy conjugation is a group homomorphism, preserving
* the frame relationship.
*/
private static Quaternionf computeUpperBoneLocalRotation(
ModelPart part, int jointIndex,
Quaternionf[] restRotations
) {
// Reconstruct the MC-frame delta from ModelPart euler angles.
Quaternionf delta = new Quaternionf().rotationZYX(part.zRot, part.yRot, part.xRot);
// Local rotation = delta applied on top of the local rest rotation.
return new Quaternionf(delta).mul(restRotations[jointIndex]);
}
/**
* Compute local rotation for a lower bone (elbow/knee) from bend values.
* <p>
* Bend values are read from the entity's AnimationApplier. The bend delta is
* reconstructed as a quaternion rotation around the bend axis, then composed
* with the local rest rotation:
* <pre>
* bendQuat = axisAngle(cos(bendAxis)*s, 0, sin(bendAxis)*s, cos(halfAngle))
* localRot = bendQuat * restQ_mc
* </pre>
* No de-parenting needed — same reasoning as upper bones.
*/
private static Quaternionf computeLowerBoneLocalRotation(
String boneName, int jointIndex,
Quaternionf[] restRotations,
AnimationApplier emote
) {
if (emote != null) {
// Get the MC part name for the upper bone of this lower bone
String upperBone = GltfBoneMapper.getUpperBoneFor(boneName);
String animPartName = (upperBone != null)
? GltfBoneMapper.getAnimPartName(upperBone)
: null;
if (animPartName != null) {
Pair<Float, Float> bend = emote.getBend(animPartName);
if (bend != null) {
float bendAxis = bend.getLeft();
float bendValue = bend.getRight();
// Reconstruct bend as quaternion (this is the delta)
float ax = (float) Math.cos(bendAxis);
float az = (float) Math.sin(bendAxis);
float halfAngle = bendValue * 0.5f;
float s = (float) Math.sin(halfAngle);
Quaternionf bendQuat = new Quaternionf(
ax * s, 0, az * s, (float) Math.cos(halfAngle)
);
// Local rotation = bend delta applied on top of local rest rotation
return new Quaternionf(bendQuat).mul(restRotations[jointIndex]);
}
}
}
// No bend data or no AnimationApplier — use rest rotation (identity delta)
return new Quaternionf(restRotations[jointIndex]);
}
/**
* Check if a bone name corresponds to a bone that has its OWN unique ModelPart
* (not just a mapping — it must be the PRIMARY bone for that ModelPart).
* <p>
* "torso" maps to model.body but "body" is the primary bone for it.
* Lower bones share a ModelPart with their upper bone.
* Unknown bones (e.g., "PlayerArmature") have no ModelPart at all.
*/
private static boolean hasUniqueModelPart(String boneName) {
// Bones that should read their rotation from the live HumanoidModel.
//
// NOTE: "body" is deliberately EXCLUDED. MC's HumanoidModel is FLAT —
// body, arms, legs, head are all siblings with ABSOLUTE rotations.
// But the GLB skeleton is HIERARCHICAL (body → torso → arms).
// If we read body's live rotation (e.g., attack swing yRot), it propagates
// to arms/head through the hierarchy, but MC's flat model does NOT do this.
// Result: cuffs mesh rotates with body during attack while arms stay put.
//
// Body rotation effects that matter (sneak lean, sitting) are handled by
// LivingEntityRenderer's PoseStack transform, which applies to the entire
// mesh uniformly. No need to read body rotation into joint matrices.
return switch (boneName) {
case "head" -> true;
case "leftUpperArm" -> true;
case "rightUpperArm"-> true;
case "leftUpperLeg" -> true;
case "rightUpperLeg"-> true;
default -> false; // body, torso, lower bones, unknown
};
}
/**
* Get the AnimationApplier from an entity, if available.
* Works for both players (via mixin) and NPCs implementing IAnimatedPlayer.
*/
private static AnimationApplier getAnimationApplier(LivingEntity entity) {
if (entity instanceof IAnimatedPlayer animated) {
try {
return animated.playerAnimator_getAnimation();
} catch (Exception e) {
LOGGER.debug("[GltfPipeline] Could not get AnimationApplier for {}: {}",
entity.getClass().getSimpleName(), e.getMessage());
}
}
return null;
}
}

View File

@@ -0,0 +1,255 @@
package com.tiedup.remake.client.gltf;
import com.mojang.blaze3d.vertex.DefaultVertexFormat;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.blaze3d.vertex.VertexConsumer;
import com.mojang.blaze3d.vertex.VertexFormat;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import net.minecraft.client.renderer.MultiBufferSource;
import net.minecraft.client.renderer.RenderStateShard;
import net.minecraft.client.renderer.RenderType;
import net.minecraft.resources.ResourceLocation;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import org.joml.Matrix3f;
import org.joml.Matrix4f;
import org.joml.Vector4f;
/**
* Submits CPU-skinned glTF mesh vertices to Minecraft's rendering pipeline.
* Uses TRIANGLES mode RenderType (same pattern as ObjModelRenderer).
*/
@OnlyIn(Dist.CLIENT)
public final class GltfMeshRenderer extends RenderStateShard {
private static final ResourceLocation WHITE_TEXTURE =
ResourceLocation.fromNamespaceAndPath("tiedup", "models/obj/shared/white.png");
/** Cached default RenderType (white texture). Created once, reused every frame. */
private static RenderType cachedDefaultRenderType;
/** Cache for texture-specific RenderTypes, keyed by ResourceLocation. */
private static final Map<ResourceLocation, RenderType> RENDER_TYPE_CACHE = new ConcurrentHashMap<>();
private GltfMeshRenderer() {
super("tiedup_gltf_renderer", () -> {}, () -> {});
}
/**
* Get the default TRIANGLES-mode RenderType (white texture), creating it once if needed.
*/
private static RenderType getDefaultRenderType() {
if (cachedDefaultRenderType == null) {
cachedDefaultRenderType = createTriangleRenderType(WHITE_TEXTURE);
}
return cachedDefaultRenderType;
}
/**
* Public accessor for the default RenderType (white texture).
* Used by external renderers that need the same RenderType for tinted rendering.
*/
public static RenderType getRenderTypeForDefaultTexture() {
return getDefaultRenderType();
}
/**
* Get a RenderType for a specific texture, caching it for reuse.
*
* @param texture the texture ResourceLocation
* @return the cached or newly created RenderType
*/
private static RenderType getRenderTypeForTexture(ResourceLocation texture) {
return RENDER_TYPE_CACHE.computeIfAbsent(texture,
GltfMeshRenderer::createTriangleRenderType);
}
/**
* Create a TRIANGLES-mode RenderType for glTF mesh rendering with the given texture.
*/
private static RenderType createTriangleRenderType(ResourceLocation texture) {
RenderType.CompositeState state = RenderType.CompositeState.builder()
.setShaderState(RENDERTYPE_ENTITY_CUTOUT_NO_CULL_SHADER)
.setTextureState(
new RenderStateShard.TextureStateShard(texture, false, false)
)
.setTransparencyState(NO_TRANSPARENCY)
.setCullState(NO_CULL)
.setLightmapState(LIGHTMAP)
.setOverlayState(OVERLAY)
.createCompositeState(true);
return RenderType.create(
"tiedup_gltf_triangles",
DefaultVertexFormat.NEW_ENTITY,
VertexFormat.Mode.TRIANGLES,
256 * 1024,
true,
false,
state
);
}
/**
* Clear cached RenderTypes. Call on resource reload so that re-exported
* textures are picked up without restarting the game.
*/
public static void clearRenderTypeCache() {
cachedDefaultRenderType = null;
RENDER_TYPE_CACHE.clear();
}
/**
* Render a skinned glTF mesh using the default white texture.
*
* @param data parsed glTF data
* @param jointMatrices computed joint matrices from skinning engine
* @param poseStack current pose stack
* @param buffer multi-buffer source
* @param packedLight packed light value
* @param packedOverlay packed overlay value
*/
public static void renderSkinned(
GltfData data, Matrix4f[] jointMatrices,
PoseStack poseStack, MultiBufferSource buffer,
int packedLight, int packedOverlay
) {
renderSkinnedInternal(data, jointMatrices, poseStack, buffer,
packedLight, packedOverlay, getDefaultRenderType());
}
/**
* Render a skinned glTF mesh using a custom texture.
*
* @param data parsed glTF data
* @param jointMatrices computed joint matrices from skinning engine
* @param poseStack current pose stack
* @param buffer multi-buffer source
* @param packedLight packed light value
* @param packedOverlay packed overlay value
* @param texture the texture to use for rendering
*/
public static void renderSkinned(
GltfData data, Matrix4f[] jointMatrices,
PoseStack poseStack, MultiBufferSource buffer,
int packedLight, int packedOverlay,
ResourceLocation texture
) {
renderSkinnedInternal(data, jointMatrices, poseStack, buffer,
packedLight, packedOverlay, getRenderTypeForTexture(texture));
}
/**
* Internal rendering implementation shared by both overloads.
*/
private static void renderSkinnedInternal(
GltfData data, Matrix4f[] jointMatrices,
PoseStack poseStack, MultiBufferSource buffer,
int packedLight, int packedOverlay,
RenderType renderType
) {
Matrix4f pose = poseStack.last().pose();
Matrix3f normalMat = poseStack.last().normal();
VertexConsumer vc = buffer.getBuffer(renderType);
int[] indices = data.indices();
float[] texCoords = data.texCoords();
float[] outPos = new float[3];
float[] outNormal = new float[3];
// Pre-allocate scratch vectors outside the loop to avoid per-vertex allocations
Vector4f tmpPos = new Vector4f();
Vector4f tmpNorm = new Vector4f();
for (int idx : indices) {
// Skin this vertex
GltfSkinningEngine.skinVertex(data, idx, jointMatrices, outPos, outNormal, tmpPos, tmpNorm);
// UV coordinates
float u = texCoords[idx * 2];
float v = texCoords[idx * 2 + 1];
vc.vertex(pose, outPos[0], outPos[1], outPos[2])
.color(255, 255, 255, 255)
.uv(u, 1.0f - v)
.overlayCoords(packedOverlay)
.uv2(packedLight)
.normal(normalMat, outNormal[0], outNormal[1], outNormal[2])
.endVertex();
}
}
/**
* Render a skinned glTF mesh with per-primitive tint colors.
*
* <p>Each primitive in the mesh is checked against the tintColors map.
* If a primitive is tintable and its channel is present in the map,
* the corresponding RGB color is applied as vertex color (multiplied
* against the texture by the {@code rendertype_entity_cutout_no_cull} shader).
* Non-tintable primitives render with white (no tint).</p>
*
* <p>This is a single VertexConsumer stream — all primitives share the
* same RenderType and draw call, only the vertex color differs per range.</p>
*
* @param data parsed glTF data (must have primitives)
* @param jointMatrices computed joint matrices from skinning engine
* @param poseStack current pose stack
* @param buffer multi-buffer source
* @param packedLight packed light value
* @param packedOverlay packed overlay value
* @param renderType the RenderType to use
* @param tintColors channel name to RGB int (0xRRGGBB); empty map = white everywhere
*/
public static void renderSkinnedTinted(
GltfData data, Matrix4f[] jointMatrices,
PoseStack poseStack, MultiBufferSource buffer,
int packedLight, int packedOverlay,
RenderType renderType,
Map<String, Integer> tintColors
) {
Matrix4f pose = poseStack.last().pose();
Matrix3f normalMat = poseStack.last().normal();
VertexConsumer vc = buffer.getBuffer(renderType);
float[] texCoords = data.texCoords();
float[] outPos = new float[3];
float[] outNormal = new float[3];
Vector4f tmpPos = new Vector4f();
Vector4f tmpNorm = new Vector4f();
List<GltfData.Primitive> primitives = data.primitives();
for (GltfData.Primitive prim : primitives) {
// Determine color for this primitive
int r = 255, g = 255, b = 255;
if (prim.tintable() && prim.tintChannel() != null) {
Integer colorInt = tintColors.get(prim.tintChannel());
if (colorInt != null) {
r = (colorInt >> 16) & 0xFF;
g = (colorInt >> 8) & 0xFF;
b = colorInt & 0xFF;
}
}
for (int idx : prim.indices()) {
GltfSkinningEngine.skinVertex(data, idx, jointMatrices, outPos, outNormal, tmpPos, tmpNorm);
float u = texCoords[idx * 2];
float v = texCoords[idx * 2 + 1];
vc.vertex(pose, outPos[0], outPos[1], outPos[2])
.color(r, g, b, 255)
.uv(u, 1.0f - v)
.overlayCoords(packedOverlay)
.uv2(packedLight)
.normal(normalMat, outNormal[0], outNormal[1], outNormal[2])
.endVertex();
}
}
}
}

View File

@@ -0,0 +1,485 @@
package com.tiedup.remake.client.gltf;
import dev.kosmx.playerAnim.core.data.AnimationFormat;
import dev.kosmx.playerAnim.core.data.KeyframeAnimation;
import dev.kosmx.playerAnim.core.util.Ease;
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;
import org.joml.Quaternionf;
import org.joml.Vector3f;
/**
* Converts glTF rest pose + animation quaternions into a PlayerAnimator KeyframeAnimation.
* <p>
* Data is expected to be already in MC coordinate space (converted by GlbParser).
* For upper bones: computes delta quaternion, decomposes to Euler ZYX (pitch/yaw/roll).
* For lower bones: extracts bend angle from delta quaternion.
* <p>
* The GLB model's arm pivots are expected to match MC's exactly (world Y=1.376),
* so no angle scaling is needed. If the pivots don't match, fix the Blender model.
* <p>
* Produces a static looping pose (beginTick=0, endTick=1, looped).
*/
@OnlyIn(Dist.CLIENT)
public final class GltfPoseConverter {
private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
private GltfPoseConverter() {}
/**
* Convert a GltfData's rest pose (or first animation frame) to a KeyframeAnimation.
* Uses the default (first) animation clip.
* GltfData must already be in MC coordinate space.
*
* @param data parsed glTF data (in MC space)
* @return a static looping KeyframeAnimation suitable for PlayerAnimator
*/
public static KeyframeAnimation convert(GltfData data) {
return convertClip(data, data.rawGltfAnimation(), "gltf_pose");
}
/**
* Convert a specific named animation from GltfData to a KeyframeAnimation.
* Falls back to the default animation if the name is not found.
*
* @param data parsed glTF data (in MC space)
* @param animationName the name of the animation to convert (e.g. "Struggle", "Idle")
* @return a static looping KeyframeAnimation suitable for PlayerAnimator
*/
public static KeyframeAnimation convert(GltfData data, String animationName) {
GltfData.AnimationClip rawClip = data.getRawAnimation(animationName);
if (rawClip == null) {
LOGGER.warn("[GltfPipeline] Animation '{}' not found, falling back to default", animationName);
return convert(data);
}
return convertClip(data, rawClip, "gltf_" + animationName);
}
/**
* Convert a GLB animation with selective part enabling and free-bone support.
*
* <p>Owned parts are always enabled in the output animation. Free parts (in
* {@code enabledParts} but not in {@code ownedParts}) are only enabled if the
* GLB contains actual keyframe data for them. Parts not in {@code enabledParts}
* at all are always disabled (pass through to lower layers).</p>
*
* @param data parsed glTF data (in MC space)
* @param animationName animation name in GLB, or null for default
* @param ownedParts parts the item explicitly owns (always enabled)
* @param enabledParts parts the item may animate (owned + free); free parts
* are only enabled if the GLB has keyframes for them
* @return KeyframeAnimation with selective parts active
*/
public static KeyframeAnimation convertSelective(GltfData data, @Nullable String animationName,
Set<String> ownedParts, Set<String> enabledParts) {
GltfData.AnimationClip rawClip;
String animName;
if (animationName != null) {
rawClip = data.getRawAnimation(animationName);
animName = "gltf_" + animationName;
} else {
rawClip = null;
animName = "gltf_pose";
}
if (rawClip == null) {
rawClip = data.rawGltfAnimation();
}
return convertClipSelective(data, rawClip, animName, ownedParts, enabledParts);
}
/**
* Internal: convert a specific raw animation clip with selective part enabling
* and free-bone support.
*
* <p>Tracks which PlayerAnimator parts received actual keyframe data from the GLB.
* A bone has keyframes if {@code rawClip.rotations()[jointIndex] != null}.
* This information is used by {@link #enableSelectiveParts} to decide whether
* free parts should be enabled.</p>
*
* @param ownedParts parts the item explicitly owns (always enabled)
* @param enabledParts parts the item may animate (owned + free)
*/
private static KeyframeAnimation convertClipSelective(GltfData data, GltfData.AnimationClip rawClip,
String animName, Set<String> ownedParts, Set<String> enabledParts) {
KeyframeAnimation.AnimationBuilder builder =
new KeyframeAnimation.AnimationBuilder(AnimationFormat.JSON_EMOTECRAFT);
builder.beginTick = 0;
builder.endTick = 1;
builder.stopTick = 1;
builder.isLooped = true;
builder.returnTick = 0;
builder.name = animName;
String[] jointNames = data.jointNames();
Quaternionf[] rawRestRotations = data.rawGltfRestRotations();
// Track which PlayerAnimator part names received actual animation data
Set<String> partsWithKeyframes = new HashSet<>();
for (int j = 0; j < data.jointCount(); j++) {
String boneName = jointNames[j];
if (!GltfBoneMapper.isKnownBone(boneName)) continue;
// Check if this joint has explicit animation data (not just rest pose fallback).
// A bone counts as explicitly animated if it has rotation OR translation keyframes.
boolean hasExplicitAnim = rawClip != null && (
(j < rawClip.rotations().length && rawClip.rotations()[j] != null)
|| (rawClip.translations() != null
&& j < rawClip.translations().length
&& rawClip.translations()[j] != null)
);
Quaternionf animQ = getRawAnimQuaternion(rawClip, rawRestRotations, j);
Quaternionf restQ = rawRestRotations[j];
// delta_local = inverse(rest_q) * anim_q (in bone-local frame)
Quaternionf deltaLocal = new Quaternionf(restQ).invert().mul(animQ);
// Convert to PARENT frame: delta_parent = rest * delta_local * inv(rest)
Quaternionf deltaParent = new Quaternionf(restQ).mul(deltaLocal)
.mul(new Quaternionf(restQ).invert());
// Convert from glTF parent frame to MC model-def frame.
// 180deg rotation around Z (X and Y differ): negate qx and qy.
Quaternionf deltaQ = new Quaternionf(deltaParent);
deltaQ.x = -deltaQ.x;
deltaQ.y = -deltaQ.y;
if (GltfBoneMapper.isLowerBone(boneName)) {
convertLowerBone(builder, boneName, deltaQ);
} else {
convertUpperBone(builder, boneName, deltaQ);
}
// Record which PlayerAnimator part received data
if (hasExplicitAnim) {
String animPart = GltfBoneMapper.getAnimPartName(boneName);
if (animPart != null) {
partsWithKeyframes.add(animPart);
}
// For lower bones, the keyframe data goes to the upper bone's part
if (GltfBoneMapper.isLowerBone(boneName)) {
String upperBone = GltfBoneMapper.getUpperBoneFor(boneName);
if (upperBone != null) {
String upperPart = GltfBoneMapper.getAnimPartName(upperBone);
if (upperPart != null) {
partsWithKeyframes.add(upperPart);
}
}
}
}
}
// Selective: enable owned parts always, free parts only if they have keyframes
enableSelectiveParts(builder, ownedParts, enabledParts, partsWithKeyframes);
KeyframeAnimation anim = builder.build();
LOGGER.debug("[GltfPipeline] Converted selective animation '{}' (owned: {}, enabled: {}, withKeyframes: {})",
animName, ownedParts, enabledParts, partsWithKeyframes);
return anim;
}
/**
* Add keyframes for specific owned parts from a GLB animation clip to an existing builder.
*
* <p>Only writes keyframes for bones that map to a part in {@code ownedParts}.
* Other bones are skipped entirely. This allows multiple items to contribute
* to the same animation builder without overwriting each other's keyframes.</p>
*
* @param builder the shared animation builder to add keyframes to
* @param data parsed glTF data
* @param rawClip the raw animation clip, or null for rest pose
* @param ownedParts parts this item exclusively owns (only these get keyframes)
* @return set of part names that received actual keyframe data from the GLB
*/
public static Set<String> addBonesToBuilder(
KeyframeAnimation.AnimationBuilder builder,
GltfData data, @Nullable GltfData.AnimationClip rawClip,
Set<String> ownedParts) {
String[] jointNames = data.jointNames();
Quaternionf[] rawRestRotations = data.rawGltfRestRotations();
Set<String> partsWithKeyframes = new HashSet<>();
for (int j = 0; j < data.jointCount(); j++) {
String boneName = jointNames[j];
if (!GltfBoneMapper.isKnownBone(boneName)) continue;
// Only process bones that belong to this item's owned parts
String animPart = GltfBoneMapper.getAnimPartName(boneName);
if (animPart == null || !ownedParts.contains(animPart)) continue;
// For lower bones, check if the UPPER bone's part is owned
// (lower bone keyframes go to the upper bone's StateCollection)
if (GltfBoneMapper.isLowerBone(boneName)) {
String upperBone = GltfBoneMapper.getUpperBoneFor(boneName);
if (upperBone != null) {
String upperPart = GltfBoneMapper.getAnimPartName(upperBone);
if (upperPart == null || !ownedParts.contains(upperPart)) continue;
}
}
boolean hasExplicitAnim = rawClip != null && (
(j < rawClip.rotations().length && rawClip.rotations()[j] != null)
|| (rawClip.translations() != null
&& j < rawClip.translations().length
&& rawClip.translations()[j] != null)
);
Quaternionf animQ = getRawAnimQuaternion(rawClip, rawRestRotations, j);
Quaternionf restQ = rawRestRotations[j];
Quaternionf deltaLocal = new Quaternionf(restQ).invert().mul(animQ);
Quaternionf deltaParent = new Quaternionf(restQ).mul(deltaLocal)
.mul(new Quaternionf(restQ).invert());
Quaternionf deltaQ = new Quaternionf(deltaParent);
deltaQ.x = -deltaQ.x;
deltaQ.y = -deltaQ.y;
if (GltfBoneMapper.isLowerBone(boneName)) {
convertLowerBone(builder, boneName, deltaQ);
} else {
convertUpperBone(builder, boneName, deltaQ);
}
if (hasExplicitAnim) {
partsWithKeyframes.add(animPart);
if (GltfBoneMapper.isLowerBone(boneName)) {
String upperBone = GltfBoneMapper.getUpperBoneFor(boneName);
if (upperBone != null) {
String upperPart = GltfBoneMapper.getAnimPartName(upperBone);
if (upperPart != null) partsWithKeyframes.add(upperPart);
}
}
}
}
return partsWithKeyframes;
}
/**
* Convert an animation clip using skeleton data from a separate source.
*
* <p>This is useful when the animation clip is stored separately from the
* skeleton (e.g., furniture seat animations where the Player_* armature's
* clips are parsed into a separate map from the skeleton GltfData).</p>
*
* <p>The resulting animation has all parts fully enabled. Callers should
* create a mutable copy and selectively disable parts as needed.</p>
*
* @param skeleton the GltfData providing rest pose, joint names, and joint count
* @param clip the raw animation clip (in glTF space) to convert
* @param animName debug name for the resulting animation
* @return a static looping KeyframeAnimation with all parts enabled
*/
public static KeyframeAnimation convertWithSkeleton(
GltfData skeleton, GltfData.AnimationClip clip, String animName) {
return convertClip(skeleton, clip, animName);
}
/**
* Internal: convert a specific raw animation clip to a KeyframeAnimation.
*/
private static KeyframeAnimation convertClip(GltfData data, GltfData.AnimationClip rawClip, String animName) {
KeyframeAnimation.AnimationBuilder builder =
new KeyframeAnimation.AnimationBuilder(AnimationFormat.JSON_EMOTECRAFT);
builder.beginTick = 0;
builder.endTick = 1;
builder.stopTick = 1;
builder.isLooped = true;
builder.returnTick = 0;
builder.name = animName;
String[] jointNames = data.jointNames();
Quaternionf[] rawRestRotations = data.rawGltfRestRotations();
for (int j = 0; j < data.jointCount(); j++) {
String boneName = jointNames[j];
if (!GltfBoneMapper.isKnownBone(boneName)) continue;
Quaternionf animQ = getRawAnimQuaternion(rawClip, rawRestRotations, j);
Quaternionf restQ = rawRestRotations[j];
// delta_local = inverse(rest_q) * anim_q (in bone-local frame)
Quaternionf deltaLocal = new Quaternionf(restQ).invert().mul(animQ);
// Convert to PARENT frame: delta_parent = rest * delta_local * inv(rest)
// Simplifies algebraically to: animQ * inv(restQ)
Quaternionf deltaParent = new Quaternionf(restQ).mul(deltaLocal)
.mul(new Quaternionf(restQ).invert());
// Convert from glTF parent frame to MC model-def frame.
// 180° rotation around Z (X and Y differ): negate qx and qy.
Quaternionf deltaQ = new Quaternionf(deltaParent);
deltaQ.x = -deltaQ.x;
deltaQ.y = -deltaQ.y;
LOGGER.debug(String.format(
"[GltfPipeline] Bone '%s': restQ=(%.3f,%.3f,%.3f,%.3f) animQ=(%.3f,%.3f,%.3f,%.3f) deltaQ=(%.3f,%.3f,%.3f,%.3f)",
boneName,
restQ.x, restQ.y, restQ.z, restQ.w,
animQ.x, animQ.y, animQ.z, animQ.w,
deltaQ.x, deltaQ.y, deltaQ.z, deltaQ.w));
if (GltfBoneMapper.isLowerBone(boneName)) {
convertLowerBone(builder, boneName, deltaQ);
} else {
convertUpperBone(builder, boneName, deltaQ);
}
}
builder.fullyEnableParts();
KeyframeAnimation anim = builder.build();
LOGGER.debug("[GltfPipeline] Converted glTF animation '{}' to KeyframeAnimation", animName);
return anim;
}
/**
* Get the raw animation quaternion for a joint from a specific clip.
* Falls back to rest rotation if the clip is null or has no data for this joint.
*/
private static Quaternionf getRawAnimQuaternion(
GltfData.AnimationClip rawClip, Quaternionf[] rawRestRotations, int jointIndex
) {
if (rawClip != null && jointIndex < rawClip.rotations().length
&& rawClip.rotations()[jointIndex] != null) {
return rawClip.rotations()[jointIndex][0]; // first frame
}
return rawRestRotations[jointIndex]; // fallback to rest
}
private static void convertUpperBone(
KeyframeAnimation.AnimationBuilder builder,
String boneName, Quaternionf deltaQ
) {
// Decompose delta quaternion to Euler ZYX
// JOML's getEulerAnglesZYX stores: euler.x = X rotation, euler.y = Y rotation, euler.z = Z rotation
// (the "ZYX" refers to rotation ORDER, not storage order)
Vector3f euler = new Vector3f();
deltaQ.getEulerAnglesZYX(euler);
float pitch = euler.x; // X rotation (pitch)
float yaw = euler.y; // Y rotation (yaw)
float roll = euler.z; // Z rotation (roll)
LOGGER.debug(String.format(
"[GltfPipeline] Upper bone '%s': pitch=%.1f° yaw=%.1f° roll=%.1f°",
boneName,
Math.toDegrees(pitch),
Math.toDegrees(yaw),
Math.toDegrees(roll)));
// Get the StateCollection for this body part
String animPart = GltfBoneMapper.getAnimPartName(boneName);
if (animPart == null) return;
KeyframeAnimation.StateCollection part = getPartByName(builder, animPart);
if (part == null) return;
part.pitch.addKeyFrame(0, pitch, Ease.CONSTANT);
part.yaw.addKeyFrame(0, yaw, Ease.CONSTANT);
part.roll.addKeyFrame(0, roll, Ease.CONSTANT);
}
private static void convertLowerBone(
KeyframeAnimation.AnimationBuilder builder,
String boneName, Quaternionf deltaQ
) {
// Extract bend angle and axis from the delta quaternion
float angle = 2.0f * (float) Math.acos(
Math.min(1.0, Math.abs(deltaQ.w))
);
// Determine bend direction from axis
float bendDirection = 0.0f;
if (deltaQ.x * deltaQ.x + deltaQ.z * deltaQ.z > 0.001f) {
bendDirection = (float) Math.atan2(deltaQ.z, deltaQ.x);
}
// Sign: if w is negative, the angle wraps
if (deltaQ.w < 0) {
angle = -angle;
}
LOGGER.debug(String.format(
"[GltfPipeline] Lower bone '%s': bendAngle=%.1f° bendDir=%.1f°",
boneName,
Math.toDegrees(angle),
Math.toDegrees(bendDirection)));
// Apply bend to the upper bone's StateCollection
String upperBone = GltfBoneMapper.getUpperBoneFor(boneName);
if (upperBone == null) return;
String animPart = GltfBoneMapper.getAnimPartName(upperBone);
if (animPart == null) return;
KeyframeAnimation.StateCollection part = getPartByName(builder, animPart);
if (part == null || !part.isBendable) return;
part.bend.addKeyFrame(0, angle, Ease.CONSTANT);
part.bendDirection.addKeyFrame(0, bendDirection, Ease.CONSTANT);
}
private static KeyframeAnimation.StateCollection getPartByName(
KeyframeAnimation.AnimationBuilder builder, String name
) {
return switch (name) {
case "head" -> builder.head;
case "body" -> builder.body;
case "rightArm" -> builder.rightArm;
case "leftArm" -> builder.leftArm;
case "rightLeg" -> builder.rightLeg;
case "leftLeg" -> builder.leftLeg;
default -> null;
};
}
/**
* Enable parts selectively based on ownership and keyframe presence.
*
* <ul>
* <li>Owned parts: always enabled (the item controls these bones)</li>
* <li>Free parts WITH keyframes: enabled (the GLB has animation data for them)</li>
* <li>Free parts WITHOUT keyframes: disabled (no data to animate, pass through to context)</li>
* <li>Other items' parts: disabled (pass through to their own layer)</li>
* </ul>
*
* @param builder the animation builder with keyframes already added
* @param ownedParts parts the item explicitly owns (always enabled)
* @param enabledParts parts the item may animate (owned + free)
* @param partsWithKeyframes parts that received actual animation data from the GLB
*/
private static void enableSelectiveParts(
KeyframeAnimation.AnimationBuilder builder,
Set<String> ownedParts, Set<String> enabledParts,
Set<String> partsWithKeyframes) {
String[] allParts = {"head", "body", "rightArm", "leftArm", "rightLeg", "leftLeg"};
for (String partName : allParts) {
KeyframeAnimation.StateCollection part = getPartByName(builder, partName);
if (part != null) {
if (ownedParts.contains(partName)) {
// Always enable owned parts — the item controls these bones
part.fullyEnablePart(false);
} else if (enabledParts.contains(partName) && partsWithKeyframes.contains(partName)) {
// Free part WITH keyframes: enable so the GLB animation drives it
part.fullyEnablePart(false);
} else {
// Other item's part, or free part without keyframes: disable.
// Disabled parts pass through to the lower-priority context layer.
part.setEnabled(false);
}
}
}
}
}

View File

@@ -0,0 +1,94 @@
package com.tiedup.remake.client.gltf;
import com.mojang.blaze3d.vertex.PoseStack;
import net.minecraft.client.Minecraft;
import net.minecraft.client.model.PlayerModel;
import net.minecraft.client.player.AbstractClientPlayer;
import net.minecraft.client.renderer.MultiBufferSource;
import net.minecraft.client.renderer.entity.RenderLayerParent;
import net.minecraft.client.renderer.entity.layers.RenderLayer;
import net.minecraft.resources.ResourceLocation;
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;
/**
* RenderLayer that renders the glTF mesh (handcuffs) on the player.
* Only active when enabled and only renders on the local player.
* <p>
* Uses the live skinning path: reads live skeleton from HumanoidModel
* via {@link GltfLiveBoneReader}, following PlayerAnimator + bendy-lib rotations.
* Falls back to GLB-internal skinning via {@link GltfSkinningEngine} if live reading fails.
*/
@OnlyIn(Dist.CLIENT)
public class GltfRenderLayer
extends RenderLayer<AbstractClientPlayer, PlayerModel<AbstractClientPlayer>> {
private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
private static final ResourceLocation CUFFS_MODEL =
ResourceLocation.fromNamespaceAndPath(
"tiedup", "models/gltf/v2/handcuffs/cuffs_prototype.glb"
);
public GltfRenderLayer(
RenderLayerParent<AbstractClientPlayer, PlayerModel<AbstractClientPlayer>> renderer
) {
super(renderer);
}
/**
* The Y translate offset to place the glTF mesh in the MC PoseStack.
* <p>
* After LivingEntityRenderer's scale(-1,-1,1) + translate(0,-1.501,0),
* the PoseStack origin is at the model top (1.501 blocks above feet), Y-down.
* The glTF mesh (MC-converted) has feet at Y=0 and head at Y≈-1.5.
* Translating by 1.501 maps glTF feet to PoseStack feet and head to top.
*/
private static final float ALIGNMENT_Y = 1.501f;
@Override
public void render(
PoseStack poseStack,
MultiBufferSource buffer,
int packedLight,
AbstractClientPlayer entity,
float limbSwing,
float limbSwingAmount,
float partialTick,
float ageInTicks,
float netHeadYaw,
float headPitch
) {
if (!GltfAnimationApplier.isEnabled()) return;
if (entity != Minecraft.getInstance().player) return;
GltfData data = GltfCache.get(CUFFS_MODEL);
if (data == null) return;
// Live path: read skeleton from HumanoidModel (after PlayerAnimator)
PlayerModel<AbstractClientPlayer> parentModel = this.getParentModel();
Matrix4f[] joints = GltfLiveBoneReader.computeJointMatricesFromModel(
parentModel, data, entity
);
if (joints == null) {
// Fallback to GLB-internal path if live reading fails
joints = GltfSkinningEngine.computeJointMatrices(data);
}
poseStack.pushPose();
// Align glTF mesh with MC model (feet-to-feet alignment)
poseStack.translate(0, ALIGNMENT_Y, 0);
GltfMeshRenderer.renderSkinned(
data, joints, poseStack, buffer,
packedLight,
net.minecraft.client.renderer.entity.LivingEntityRenderer
.getOverlayCoords(entity, 0.0f)
);
poseStack.popPose();
}
}

View File

@@ -0,0 +1,296 @@
package com.tiedup.remake.client.gltf;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import org.joml.Matrix4f;
import org.joml.Quaternionf;
import org.joml.Vector3f;
import org.joml.Vector4f;
/**
* CPU-based Linear Blend Skinning (LBS) engine.
* Computes joint matrices purely from glTF data (rest translations + animation rotations).
* All data is in MC-converted space (consistent with IBMs and vertex positions).
*/
@OnlyIn(Dist.CLIENT)
public final class GltfSkinningEngine {
private GltfSkinningEngine() {}
/**
* Compute joint matrices from glTF animation/rest data (default animation).
* Each joint matrix = worldTransform * inverseBindMatrix.
* Uses MC-converted glTF data throughout for consistency.
*
* @param data parsed glTF data (MC-converted)
* @return array of joint matrices ready for skinning
*/
public static Matrix4f[] computeJointMatrices(GltfData data) {
return computeJointMatricesFromClip(data, data.animation());
}
/**
* Compute joint matrices with frame interpolation for animated entities.
* Uses SLERP for rotations and LERP for translations between adjacent keyframes.
*
* <p>The {@code time} parameter is in frame-space: 0.0 corresponds to the first
* keyframe and {@code frameCount - 1} to the last. Values between integer frames
* are interpolated. Out-of-range values are clamped.</p>
*
* @param data the parsed glTF data (MC-converted)
* @param clip the animation clip to sample (null = rest pose for all joints)
* @param time time in frame-space (0.0 = first frame, N-1 = last frame)
* @return interpolated joint matrices ready for skinning
*/
public static Matrix4f[] computeJointMatricesAnimated(
GltfData data, GltfData.AnimationClip clip, float time
) {
int jointCount = data.jointCount();
Matrix4f[] jointMatrices = new Matrix4f[jointCount];
Matrix4f[] worldTransforms = new Matrix4f[jointCount];
int[] parents = data.parentJointIndices();
for (int j = 0; j < jointCount; j++) {
// Build local transform: translate(interpT) * rotate(interpQ)
Matrix4f local = new Matrix4f();
local.translate(getInterpolatedTranslation(data, clip, j, time));
local.rotate(getInterpolatedRotation(data, clip, j, time));
// Compose with parent
if (parents[j] >= 0 && worldTransforms[parents[j]] != null) {
worldTransforms[j] = new Matrix4f(worldTransforms[parents[j]]).mul(local);
} else {
worldTransforms[j] = new Matrix4f(local);
}
// Final joint matrix = worldTransform * inverseBindMatrix
jointMatrices[j] = new Matrix4f(worldTransforms[j])
.mul(data.inverseBindMatrices()[j]);
}
return jointMatrices;
}
/**
* Internal: compute joint matrices from a specific animation clip.
*/
private static Matrix4f[] computeJointMatricesFromClip(GltfData data, GltfData.AnimationClip clip) {
int jointCount = data.jointCount();
Matrix4f[] jointMatrices = new Matrix4f[jointCount];
Matrix4f[] worldTransforms = new Matrix4f[jointCount];
int[] parents = data.parentJointIndices();
for (int j = 0; j < jointCount; j++) {
// Build local transform: translate(animT or restT) * rotate(animQ or restQ)
Matrix4f local = new Matrix4f();
local.translate(getAnimTranslation(data, clip, j));
local.rotate(getAnimRotation(data, clip, j));
// Compose with parent
if (parents[j] >= 0 && worldTransforms[parents[j]] != null) {
worldTransforms[j] = new Matrix4f(worldTransforms[parents[j]]).mul(local);
} else {
worldTransforms[j] = new Matrix4f(local);
}
// Final joint matrix = worldTransform * inverseBindMatrix
jointMatrices[j] = new Matrix4f(worldTransforms[j])
.mul(data.inverseBindMatrices()[j]);
}
return jointMatrices;
}
/**
* Get the animation rotation for a joint (MC-converted).
* Falls back to rest rotation if no animation.
*/
private static Quaternionf getAnimRotation(GltfData data, GltfData.AnimationClip clip, int jointIndex) {
if (clip != null && jointIndex < clip.rotations().length
&& clip.rotations()[jointIndex] != null) {
return clip.rotations()[jointIndex][0]; // first frame
}
return data.restRotations()[jointIndex];
}
/**
* Get the animation translation for a joint (MC-converted).
* Falls back to rest translation if no animation translation exists.
*/
private static Vector3f getAnimTranslation(GltfData data, GltfData.AnimationClip clip, int jointIndex) {
if (clip != null && clip.translations() != null
&& jointIndex < clip.translations().length
&& clip.translations()[jointIndex] != null) {
return clip.translations()[jointIndex][0]; // first frame
}
return data.restTranslations()[jointIndex];
}
// ---- Interpolated accessors (for computeJointMatricesAnimated) ----
/**
* Get an interpolated rotation for a joint at a fractional frame time.
* Uses SLERP between the two bounding keyframes.
*
* <p>Falls back to rest rotation when the clip is null or has no rotation
* data for the given joint. A single-frame channel returns that frame directly.</p>
*
* @param data parsed glTF data
* @param clip animation clip (may be null)
* @param jointIndex joint to query
* @param time frame-space time (clamped internally)
* @return new Quaternionf with the interpolated rotation (never mutates source data)
*/
private static Quaternionf getInterpolatedRotation(
GltfData data, GltfData.AnimationClip clip, int jointIndex, float time
) {
if (clip == null || jointIndex >= clip.rotations().length
|| clip.rotations()[jointIndex] == null) {
// No animation data for this joint -- use rest pose (copy to avoid mutation)
Quaternionf rest = data.restRotations()[jointIndex];
return new Quaternionf(rest);
}
Quaternionf[] frames = clip.rotations()[jointIndex];
if (frames.length == 1) {
return new Quaternionf(frames[0]);
}
// Clamp time to valid range [0, frameCount-1]
float clamped = Math.max(0.0f, Math.min(time, frames.length - 1));
int f0 = (int) Math.floor(clamped);
int f1 = Math.min(f0 + 1, frames.length - 1);
float alpha = clamped - f0;
if (alpha < 1e-6f || f0 == f1) {
return new Quaternionf(frames[f0]);
}
// SLERP: create a copy of frame0 and slerp toward frame1
return new Quaternionf(frames[f0]).slerp(frames[f1], alpha);
}
/**
* Get an interpolated translation for a joint at a fractional frame time.
* Uses LERP between the two bounding keyframes.
*
* <p>Falls back to rest translation when the clip is null, the clip has no
* translation data at all, or has no translation data for the given joint.
* A single-frame channel returns that frame directly.</p>
*
* @param data parsed glTF data
* @param clip animation clip (may be null)
* @param jointIndex joint to query
* @param time frame-space time (clamped internally)
* @return new Vector3f with the interpolated translation (never mutates source data)
*/
private static Vector3f getInterpolatedTranslation(
GltfData data, GltfData.AnimationClip clip, int jointIndex, float time
) {
if (clip == null || clip.translations() == null
|| jointIndex >= clip.translations().length
|| clip.translations()[jointIndex] == null) {
// No animation data for this joint -- use rest pose (copy to avoid mutation)
Vector3f rest = data.restTranslations()[jointIndex];
return new Vector3f(rest);
}
Vector3f[] frames = clip.translations()[jointIndex];
if (frames.length == 1) {
return new Vector3f(frames[0]);
}
// Clamp time to valid range [0, frameCount-1]
float clamped = Math.max(0.0f, Math.min(time, frames.length - 1));
int f0 = (int) Math.floor(clamped);
int f1 = Math.min(f0 + 1, frames.length - 1);
float alpha = clamped - f0;
if (alpha < 1e-6f || f0 == f1) {
return new Vector3f(frames[f0]);
}
// LERP: create a copy of frame0 and lerp toward frame1
return new Vector3f(frames[f0]).lerp(frames[f1], alpha);
}
/**
* Skin a single vertex using Linear Blend Skinning.
*
* <p>Callers should pre-allocate {@code tmpPos} and {@code tmpNorm} and reuse
* them across all vertices in a mesh to avoid per-vertex allocations (12k+
* allocations per frame for a typical mesh).</p>
*
* @param data parsed glTF data
* @param vertexIdx index into the vertex arrays
* @param jointMatrices joint matrices from computeJointMatrices
* @param outPos output skinned position (3 floats)
* @param outNormal output skinned normal (3 floats)
* @param tmpPos pre-allocated scratch Vector4f for position transforms
* @param tmpNorm pre-allocated scratch Vector4f for normal transforms
*/
public static void skinVertex(
GltfData data, int vertexIdx, Matrix4f[] jointMatrices,
float[] outPos, float[] outNormal,
Vector4f tmpPos, Vector4f tmpNorm
) {
float[] positions = data.positions();
float[] normals = data.normals();
int[] joints = data.joints();
float[] weights = data.weights();
// Rest position
float vx = positions[vertexIdx * 3];
float vy = positions[vertexIdx * 3 + 1];
float vz = positions[vertexIdx * 3 + 2];
// Rest normal
float nx = normals[vertexIdx * 3];
float ny = normals[vertexIdx * 3 + 1];
float nz = normals[vertexIdx * 3 + 2];
// LBS: v_skinned = Σ(w[i] * jointMatrix[j[i]] * v_rest)
float sx = 0, sy = 0, sz = 0;
float snx = 0, sny = 0, snz = 0;
for (int i = 0; i < 4; i++) {
int ji = joints[vertexIdx * 4 + i];
float w = weights[vertexIdx * 4 + i];
if (w <= 0.0f || ji >= jointMatrices.length) continue;
Matrix4f jm = jointMatrices[ji];
// Transform position
tmpPos.set(vx, vy, vz, 1.0f);
jm.transform(tmpPos);
sx += w * tmpPos.x;
sy += w * tmpPos.y;
sz += w * tmpPos.z;
// Transform normal (ignore translation)
tmpNorm.set(nx, ny, nz, 0.0f);
jm.transform(tmpNorm);
snx += w * tmpNorm.x;
sny += w * tmpNorm.y;
snz += w * tmpNorm.z;
}
outPos[0] = sx;
outPos[1] = sy;
outPos[2] = sz;
// Normalize the normal
float len = (float) Math.sqrt(snx * snx + sny * sny + snz * snz);
if (len > 0.0001f) {
outNormal[0] = snx / len;
outNormal[1] = sny / len;
outNormal[2] = snz / len;
} else {
outNormal[0] = 0;
outNormal[1] = 1;
outNormal[2] = 0;
}
}
}

View File

@@ -0,0 +1,158 @@
package com.tiedup.remake.client.gui.overlays;
import com.tiedup.remake.client.gui.util.GuiColors;
import com.tiedup.remake.client.gui.util.GuiRenderUtil;
import com.tiedup.remake.client.state.ClientLaborState;
import com.tiedup.remake.core.TiedUpMod;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import net.minecraftforge.client.event.RenderGuiOverlayEvent;
import net.minecraftforge.client.gui.overlay.VanillaGuiOverlay;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
/**
* Overlay that shows labor task progress bar.
* Displayed in the top-right corner when a labor task is active.
*/
@OnlyIn(Dist.CLIENT)
@Mod.EventBusSubscriber(modid = TiedUpMod.MOD_ID, value = Dist.CLIENT)
public class LaborProgressOverlay {
// Bar dimensions
private static final int BAR_WIDTH = 150;
private static final int BAR_HEIGHT = 10;
private static final int PADDING = 6;
private static final int MARGIN = 10;
// Animation
private static float smoothProgress = 0.0f;
private static final float SMOOTH_SPEED = 0.1f;
@SubscribeEvent
public static void onRenderOverlay(RenderGuiOverlayEvent.Post event) {
// Render after hotbar
if (event.getOverlay() != VanillaGuiOverlay.HOTBAR.type()) {
return;
}
// Check if we have an active task
if (!ClientLaborState.hasActiveTask()) {
// Fade out smoothly
smoothProgress = Math.max(0, smoothProgress - SMOOTH_SPEED);
if (smoothProgress <= 0.01f) {
return;
}
} else {
// Smooth interpolation towards target
float target = ClientLaborState.getProgressFraction();
smoothProgress += (target - smoothProgress) * SMOOTH_SPEED;
}
Minecraft mc = Minecraft.getInstance();
if (mc.player == null) {
return;
}
GuiGraphics graphics = event.getGuiGraphics();
int screenWidth = mc.getWindow().getGuiScaledWidth();
// Position in top-right corner
int x = screenWidth - BAR_WIDTH - PADDING * 2 - MARGIN;
int y = MARGIN;
// Calculate panel dimensions
int panelWidth = BAR_WIDTH + PADDING * 2;
int panelHeight = BAR_HEIGHT + PADDING * 2 + mc.font.lineHeight * 2 + 6;
// Background panel
graphics.fill(
x,
y,
x + panelWidth,
y + panelHeight,
GuiColors.withAlpha(GuiColors.BG_DARK, 220)
);
// Border
GuiRenderUtil.drawBorder(
graphics,
x,
y,
panelWidth,
panelHeight,
GuiColors.ACCENT_TAN
);
// Task description
String description = ClientLaborState.getTaskDescription();
graphics.drawString(
mc.font,
description,
x + PADDING,
y + PADDING,
GuiColors.TEXT_WHITE,
false
);
// Progress bar position
int barX = x + PADDING;
int barY = y + PADDING + mc.font.lineHeight + 4;
// Progress bar background
graphics.fill(
barX,
barY,
barX + BAR_WIDTH,
barY + BAR_HEIGHT,
GuiColors.BG_LIGHT
);
// Progress bar fill
int fillWidth = (int) (BAR_WIDTH * smoothProgress);
int fillColor =
smoothProgress >= 1.0f ? GuiColors.SUCCESS : GuiColors.ACCENT_TAN;
graphics.fill(
barX,
barY,
barX + fillWidth,
barY + BAR_HEIGHT,
fillColor
);
// Progress bar border
GuiRenderUtil.drawBorder(
graphics,
barX,
barY,
BAR_WIDTH,
BAR_HEIGHT,
GuiColors.BORDER_LIGHT
);
// Progress text inside bar
String progressStr = ClientLaborState.getProgressString();
int textWidth = mc.font.width(progressStr);
graphics.drawString(
mc.font,
progressStr,
barX + (BAR_WIDTH - textWidth) / 2,
barY + (BAR_HEIGHT - mc.font.lineHeight) / 2 + 1,
GuiColors.TEXT_WHITE,
false
);
// Value text below bar
String valueStr = ClientLaborState.getValueEmeralds() + " emeralds";
graphics.drawString(
mc.font,
valueStr,
x + PADDING,
barY + BAR_HEIGHT + 2,
GuiColors.TEXT_GRAY,
false
);
}
}

View File

@@ -0,0 +1,251 @@
package com.tiedup.remake.client.gui.overlays;
import com.tiedup.remake.client.gui.util.GuiColors;
import com.tiedup.remake.client.gui.util.GuiRenderUtil;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.state.PlayerBindState;
import com.tiedup.remake.tasks.PlayerStateTask;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.network.chat.Component;
import net.minecraft.world.entity.player.Player;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import net.minecraftforge.client.event.RenderGuiOverlayEvent;
import net.minecraftforge.client.gui.overlay.VanillaGuiOverlay;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
/**
* Overlay that shows a progress bar for tying/untying/struggling actions.
* Displayed above the hotbar when an action is in progress.
*
* Phase 16: GUI Revamp - Progress bar overlay
*/
@OnlyIn(Dist.CLIENT)
@Mod.EventBusSubscriber(modid = TiedUpMod.MOD_ID, value = Dist.CLIENT)
public class ProgressOverlay {
// Bar dimensions
private static final int BAR_WIDTH = 200;
private static final int BAR_HEIGHT = 12;
private static final int PADDING = 4;
// Animation
private static float smoothProgress = 0.0f;
private static final float SMOOTH_SPEED = 0.15f;
@SubscribeEvent
public static void onRenderOverlay(RenderGuiOverlayEvent.Post event) {
// Render after crosshair
if (event.getOverlay() != VanillaGuiOverlay.CROSSHAIR.type()) {
return;
}
Minecraft mc = Minecraft.getInstance();
Player player = mc.player;
if (player == null) {
return;
}
PlayerBindState state = PlayerBindState.getInstance(player);
if (state == null) {
return;
}
// Check for active actions
ProgressInfo info = getActiveProgress(state);
if (info == null) {
// Fade out smoothly
smoothProgress = Math.max(0, smoothProgress - SMOOTH_SPEED);
if (smoothProgress <= 0.01f) {
return;
}
} else {
// Smooth interpolation
smoothProgress += (info.progress - smoothProgress) * SMOOTH_SPEED;
}
if (info == null && smoothProgress <= 0.01f) {
return;
}
GuiGraphics graphics = event.getGuiGraphics();
int screenWidth = mc.getWindow().getGuiScaledWidth();
int screenHeight = mc.getWindow().getGuiScaledHeight();
// Center horizontally, above hotbar
int x = (screenWidth - BAR_WIDTH) / 2;
int y = screenHeight - 60;
// Background panel
int panelHeight = BAR_HEIGHT + PADDING * 2 + 12; // +12 for text
graphics.fill(
x - PADDING,
y - PADDING,
x + BAR_WIDTH + PADDING,
y + panelHeight,
GuiColors.withAlpha(GuiColors.BG_DARK, 200)
);
// Progress bar background
graphics.fill(x, y, x + BAR_WIDTH, y + BAR_HEIGHT, GuiColors.BG_LIGHT);
// Progress bar fill
int fillWidth = (int) (BAR_WIDTH * smoothProgress);
int fillColor = info != null ? info.color : GuiColors.ACCENT_TAN;
graphics.fill(x, y, x + fillWidth, y + BAR_HEIGHT, fillColor);
// Border
GuiRenderUtil.drawBorder(
graphics,
x,
y,
BAR_WIDTH,
BAR_HEIGHT,
GuiColors.BORDER_LIGHT
);
// Percentage text
String percent = String.format("%.0f%%", smoothProgress * 100);
int textWidth = mc.font.width(percent);
graphics.drawString(
mc.font,
percent,
x + (BAR_WIDTH - textWidth) / 2,
y + (BAR_HEIGHT - mc.font.lineHeight) / 2 + 1,
GuiColors.TEXT_WHITE,
false
);
// Description text
if (info != null) {
graphics.drawCenteredString(
mc.font,
info.description,
x + BAR_WIDTH / 2,
y + BAR_HEIGHT + PADDING,
GuiColors.TEXT_GRAY
);
}
}
/**
* Get active progress information.
*/
private static ProgressInfo getActiveProgress(PlayerBindState state) {
// Check client tying task
PlayerStateTask tyingTask = state.getClientTyingTask();
if (tyingTask != null && !tyingTask.isOutdated()) {
float progress = tyingTask.getProgress();
Component text = getTyingText(tyingTask);
return new ProgressInfo(progress, GuiColors.WARNING, text);
}
// Check client untying task
PlayerStateTask untyingTask = state.getClientUntyingTask();
if (untyingTask != null && !untyingTask.isOutdated()) {
float progress = untyingTask.getProgress();
Component text = getUntyingText(untyingTask);
return new ProgressInfo(progress, GuiColors.SUCCESS, text);
}
// Check client feeding task
PlayerStateTask feedingTask = state.getClientFeedingTask();
if (feedingTask != null && !feedingTask.isOutdated()) {
float progress = feedingTask.getProgress();
Component text = getFeedingText(feedingTask);
return new ProgressInfo(progress, GuiColors.ACCENT_TAN, text);
}
return null;
}
/**
* Get the appropriate text for tying progress based on role.
*/
private static Component getTyingText(PlayerStateTask task) {
String otherName = task.getOtherEntityName();
if (otherName == null || otherName.isEmpty()) {
otherName = "???";
}
if (task.isKidnapper()) {
// Kidnapper sees: "Tying [target]..."
return Component.translatable(
"gui.tiedup.action.tying_target",
otherName
);
} else {
// Victim sees: "[kidnapper] is tying you up!"
return Component.translatable(
"gui.tiedup.action.being_tied_by",
otherName
);
}
}
/**
* Get the appropriate text for untying progress based on role.
*/
private static Component getUntyingText(PlayerStateTask task) {
String otherName = task.getOtherEntityName();
if (otherName == null || otherName.isEmpty()) {
otherName = "???";
}
if (task.isKidnapper()) {
// Helper sees: "Untying [target]..."
return Component.translatable(
"gui.tiedup.action.untying_target",
otherName
);
} else {
// Victim sees: "[helper] is untying you!"
return Component.translatable(
"gui.tiedup.action.being_untied_by",
otherName
);
}
}
/**
* Get the appropriate text for feeding progress based on role.
*/
private static Component getFeedingText(PlayerStateTask task) {
String otherName = task.getOtherEntityName();
if (otherName == null || otherName.isEmpty()) {
otherName = "???";
}
if (task.isKidnapper()) {
// Feeder sees: "Feeding [target]..."
return Component.translatable(
"gui.tiedup.action.feeding_target",
otherName
);
} else {
// Target sees: "[feeder] is feeding you!"
return Component.translatable(
"gui.tiedup.action.being_fed_by",
otherName
);
}
}
/**
* Progress information container.
*/
private static class ProgressInfo {
final float progress;
final int color;
final Component description;
ProgressInfo(float progress, int color, Component description) {
this.progress = progress;
this.color = color;
this.description = description;
}
}
}

View File

@@ -0,0 +1,202 @@
package com.tiedup.remake.client.gui.overlays;
import com.tiedup.remake.client.gui.util.GuiColors;
import com.tiedup.remake.client.gui.util.GuiRenderUtil;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.state.PlayerBindState;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.world.entity.player.Player;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import net.minecraftforge.client.event.RenderGuiOverlayEvent;
import net.minecraftforge.client.gui.overlay.VanillaGuiOverlay;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
/**
* Overlay that shows status icons when player is restrained.
* Icons appear in top-left corner showing current bondage state.
*
* Phase 16: GUI Revamp - Status indicator overlay
*/
@OnlyIn(Dist.CLIENT)
@Mod.EventBusSubscriber(modid = TiedUpMod.MOD_ID, value = Dist.CLIENT)
public class StatusOverlay {
// Icon size and spacing
private static final int ICON_SIZE = 16;
private static final int PADDING = 2;
private static final int MARGIN = 5;
// Visibility toggle
private static boolean visible = true;
// Icon colors (use centralized GuiColors)
private static final int COLOR_BOUND = GuiColors.TYPE_BIND;
private static final int COLOR_GAGGED = GuiColors.TYPE_GAG;
private static final int COLOR_BLIND = GuiColors.TYPE_BLINDFOLD;
private static final int COLOR_DEAF = GuiColors.TYPE_EARPLUGS;
private static final int COLOR_COLLAR = GuiColors.TYPE_COLLAR;
private static final int COLOR_MITTENS = GuiColors.TYPE_MITTENS;
/**
* Toggle overlay visibility.
*/
public static void toggleVisibility() {
visible = !visible;
}
/**
* Set overlay visibility.
*/
public static void setVisible(boolean vis) {
visible = vis;
}
/**
* Check if overlay is visible.
*/
public static boolean isVisible() {
return visible;
}
@SubscribeEvent
public static void onRenderOverlay(RenderGuiOverlayEvent.Post event) {
// Only render after hotbar
if (event.getOverlay() != VanillaGuiOverlay.HOTBAR.type()) {
return;
}
if (!visible) {
return;
}
Minecraft mc = Minecraft.getInstance();
Player player = mc.player;
if (player == null) {
return;
}
PlayerBindState state = PlayerBindState.getInstance(player);
if (state == null) {
return;
}
// Don't show if not restrained at all
if (
!state.isTiedUp() &&
!state.isGagged() &&
!state.isBlindfolded() &&
!state.hasEarplugs() &&
!state.hasCollar() &&
!state.hasMittens()
) {
return;
}
GuiGraphics graphics = event.getGuiGraphics();
// Position: top-left corner
int x = MARGIN;
int y = MARGIN;
// Background panel
int iconCount = countActiveIcons(state);
if (iconCount > 0) {
int cols = Math.min(iconCount, 3);
int rows = (iconCount + 2) / 3;
int panelWidth = cols * (ICON_SIZE + PADDING) + PADDING;
int panelHeight = rows * (ICON_SIZE + PADDING) + PADDING;
// Semi-transparent background
graphics.fill(
x - PADDING,
y - PADDING,
x + panelWidth,
y + panelHeight,
GuiColors.withAlpha(GuiColors.BG_DARK, 180)
);
}
// Render icons in grid (max 3 per row)
int iconIndex = 0;
if (state.isTiedUp()) {
renderIcon(graphics, x, y, iconIndex++, COLOR_BOUND, "B");
}
if (state.isGagged()) {
renderIcon(graphics, x, y, iconIndex++, COLOR_GAGGED, "G");
}
if (state.isBlindfolded()) {
renderIcon(graphics, x, y, iconIndex++, COLOR_BLIND, "X");
}
if (state.hasEarplugs()) {
renderIcon(graphics, x, y, iconIndex++, COLOR_DEAF, "D");
}
if (state.hasCollar()) {
renderIcon(graphics, x, y, iconIndex++, COLOR_COLLAR, "C");
}
if (state.hasMittens()) {
renderIcon(graphics, x, y, iconIndex++, COLOR_MITTENS, "M");
}
}
/**
* Count active status icons.
*/
private static int countActiveIcons(PlayerBindState state) {
int count = 0;
if (state.isTiedUp()) count++;
if (state.isGagged()) count++;
if (state.isBlindfolded()) count++;
if (state.hasEarplugs()) count++;
if (state.hasCollar()) count++;
if (state.hasMittens()) count++;
return count;
}
/**
* Render a single status icon.
* Uses colored squares with letters as placeholders until proper textures are made.
*/
private static void renderIcon(
GuiGraphics graphics,
int baseX,
int baseY,
int index,
int color,
String letter
) {
int col = index % 3;
int row = index / 3;
int x = baseX + col * (ICON_SIZE + PADDING);
int y = baseY + row * (ICON_SIZE + PADDING);
// Icon background
graphics.fill(x, y, x + ICON_SIZE, y + ICON_SIZE, color);
// Border
GuiRenderUtil.drawBorder(
graphics,
x,
y,
ICON_SIZE,
ICON_SIZE,
GuiColors.darken(color, 0.3f)
);
// Letter (centered)
Minecraft mc = Minecraft.getInstance();
int textWidth = mc.font.width(letter);
graphics.drawString(
mc.font,
letter,
x + (ICON_SIZE - textWidth) / 2,
y + (ICON_SIZE - mc.font.lineHeight) / 2 + 1,
GuiColors.TEXT_WHITE,
false
);
}
}

View File

@@ -0,0 +1,176 @@
package com.tiedup.remake.client.gui.overlays;
import com.tiedup.remake.client.ModKeybindings;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.state.IBondageState;
import com.tiedup.remake.util.KidnappedHelper;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.phys.EntityHitResult;
import net.minecraft.world.phys.HitResult;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import net.minecraftforge.client.event.RenderGuiOverlayEvent;
import net.minecraftforge.client.gui.overlay.VanillaGuiOverlay;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
/**
* Overlay that shows a tooltip when looking at a tied player/NPC.
* Displayed below the crosshair when aiming at a restrainable entity.
*
* Shows contextual hints like:
* - "[Right-click] Untie" when looking at tied entity
* - "[Right-click] Remove gag" when looking at gagged entity
*/
@OnlyIn(Dist.CLIENT)
@Mod.EventBusSubscriber(modid = TiedUpMod.MOD_ID, value = Dist.CLIENT)
public class UntieTooltipOverlay {
/** Offset below the crosshair */
private static final int Y_OFFSET = 12;
/** Text color (white with slight transparency) */
private static final int TEXT_COLOR = 0xFFFFFFFF;
/** Shadow color */
private static final int SHADOW_COLOR = 0x80000000;
@SubscribeEvent
public static void onRenderOverlay(RenderGuiOverlayEvent.Post event) {
// Render after crosshair
if (event.getOverlay() != VanillaGuiOverlay.CROSSHAIR.type()) {
return;
}
Minecraft mc = Minecraft.getInstance();
if (mc.player == null) {
return;
}
// Check what we're looking at
HitResult hitResult = mc.hitResult;
if (!(hitResult instanceof EntityHitResult entityHit)) {
return;
}
Entity target = entityHit.getEntity();
if (!(target instanceof LivingEntity livingTarget)) {
return;
}
// Get the target's kidnapped state
IBondageState state = KidnappedHelper.getKidnappedState(livingTarget);
if (state == null) {
return;
}
// Determine what action is available
String tooltip = getTooltipText(state);
if (tooltip == null) {
return;
}
// Render the tooltip below the crosshair
GuiGraphics graphics = event.getGuiGraphics();
int screenWidth = mc.getWindow().getGuiScaledWidth();
int screenHeight = mc.getWindow().getGuiScaledHeight();
int centerX = screenWidth / 2;
int centerY = screenHeight / 2;
// Draw text centered below crosshair
int textWidth = mc.font.width(tooltip);
int textX = centerX - textWidth / 2;
int textY = centerY + Y_OFFSET;
// Draw shadow first for better readability
graphics.drawString(
mc.font,
tooltip,
textX + 1,
textY + 1,
SHADOW_COLOR,
false
);
graphics.drawString(mc.font, tooltip, textX, textY, TEXT_COLOR, false);
}
/**
* Get the appropriate tooltip text based on the target's state.
* Respects lock status - locked items show "Help struggle" instead.
*
* @param state The target's kidnapped state
* @return The tooltip text, or null if no action is available
*/
private static String getTooltipText(IBondageState state) {
// Priority order: untie > ungag > unblindfold > uncollar
// If item is locked, can only help struggle (not remove directly)
String struggleKey =
"[" +
ModKeybindings.STRUGGLE_KEY.getTranslatedKeyMessage().getString() +
"]";
if (state.isTiedUp()) {
ItemStack bind = state.getEquipment(BodyRegionV2.ARMS);
if (state.isLocked(bind, false)) {
return struggleKey + " Help struggle";
}
return "[Right-click] Untie";
}
if (state.isGagged()) {
// Check if player is holding food → force feeding prompt
Minecraft mc = Minecraft.getInstance();
if (
mc.player != null &&
mc.player.getMainHandItem().getItem().isEdible()
) {
return "[Right-click] Feed";
}
ItemStack gag = state.getEquipment(BodyRegionV2.MOUTH);
if (state.isLocked(gag, false)) {
return struggleKey + " Help struggle";
}
return "[Right-click] Remove gag";
}
if (state.isBlindfolded()) {
ItemStack blindfold = state.getEquipment(BodyRegionV2.EYES);
if (state.isLocked(blindfold, false)) {
return struggleKey + " Help struggle";
}
return "[Right-click] Remove blindfold";
}
if (state.hasCollar()) {
ItemStack collar = state.getEquipment(BodyRegionV2.NECK);
if (state.isLocked(collar, false)) {
return struggleKey + " Help struggle";
}
return "[Right-click] Remove collar";
}
if (state.hasEarplugs()) {
ItemStack earplugs = state.getEquipment(BodyRegionV2.EARS);
if (state.isLocked(earplugs, false)) {
return struggleKey + " Help struggle";
}
return "[Right-click] Remove earplugs";
}
if (state.hasMittens()) {
ItemStack mittens = state.getEquipment(BodyRegionV2.HANDS);
if (state.isLocked(mittens, false)) {
return struggleKey + " Help struggle";
}
return "[Right-click] Remove mittens";
}
return null;
}
}

View File

@@ -0,0 +1,87 @@
package com.tiedup.remake.client.gui.screens;
import com.tiedup.remake.network.ModNetwork;
import com.tiedup.remake.network.item.PacketAdjustItem;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.state.PlayerBindState;
import net.minecraft.client.Minecraft;
import net.minecraft.network.chat.Component;
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;
/**
* Screen for adjusting Y position of the player's own gags and blindfolds.
* Shows 3D preview of player with real-time adjustment.
*
* Phase 16b: GUI Refactoring - Simplified using BaseAdjustmentScreen
*/
@OnlyIn(Dist.CLIENT)
public class AdjustmentScreen extends BaseAdjustmentScreen {
public AdjustmentScreen() {
super(Component.translatable("gui.tiedup.adjust_position"));
}
// ==================== ABSTRACT IMPLEMENTATIONS ====================
@Override
protected LivingEntity getTargetEntity() {
return this.minecraft.player;
}
@Override
protected ItemStack getGag() {
Player player = this.minecraft.player;
if (player == null) return ItemStack.EMPTY;
PlayerBindState state = PlayerBindState.getInstance(player);
if (state == null) return ItemStack.EMPTY;
return state.getEquipment(BodyRegionV2.MOUTH);
}
@Override
protected ItemStack getBlindfold() {
Player player = this.minecraft.player;
if (player == null) return ItemStack.EMPTY;
PlayerBindState state = PlayerBindState.getInstance(player);
if (state == null) return ItemStack.EMPTY;
return state.getEquipment(BodyRegionV2.EYES);
}
@Override
protected void sendAdjustment(Mode mode, float value, float scale) {
BodyRegionV2 region = switch (mode) {
case GAG -> BodyRegionV2.MOUTH;
case BLINDFOLD -> BodyRegionV2.EYES;
case BOTH -> null; // Handled separately in applyAdjustment
};
if (region != null) {
ModNetwork.sendToServer(new PacketAdjustItem(region, value, scale));
}
}
// ==================== STATIC HELPERS ====================
/**
* Check if this screen should be openable (player has adjustable items).
*/
public static boolean canOpen() {
Minecraft mc = Minecraft.getInstance();
if (mc.player == null) return false;
PlayerBindState state = PlayerBindState.getInstance(mc.player);
if (state == null) return false;
return (
!state.getEquipment(BodyRegionV2.MOUTH).isEmpty() ||
!state.getEquipment(BodyRegionV2.EYES).isEmpty()
);
}
}

View File

@@ -0,0 +1,481 @@
package com.tiedup.remake.client.gui.screens;
import static com.tiedup.remake.client.gui.util.GuiLayoutConstants.*;
import com.tiedup.remake.client.gui.util.GuiColors;
import com.tiedup.remake.client.gui.widgets.AdjustmentSlider;
import com.tiedup.remake.client.gui.widgets.EntityPreviewWidget;
import com.tiedup.remake.items.base.AdjustmentHelper;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.components.Button;
import net.minecraft.network.chat.Component;
import net.minecraft.util.Mth;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.item.ItemStack;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
/**
* Base class for adjustment screens (player self and remote slave adjustment).
* Refactored to use new BaseScreen architecture.
*/
@OnlyIn(Dist.CLIENT)
public abstract class BaseAdjustmentScreen extends BaseScreen {
/**
* Adjustment mode - which item(s) to adjust.
*/
protected enum Mode {
GAG,
BLINDFOLD,
BOTH,
}
// Current state
protected Mode currentMode = Mode.GAG;
protected float currentValue = 0.0f;
protected float currentScaleValue = AdjustmentHelper.DEFAULT_SCALE;
// Widgets
protected EntityPreviewWidget preview;
protected AdjustmentSlider slider;
protected Button gagButton;
protected Button blindfoldButton;
protected Button bothButton;
protected Button resetButton;
protected Button decrementButton;
protected Button incrementButton;
protected Button scaleDecrementButton;
protected Button scaleIncrementButton;
protected Button scaleResetButton;
protected Button doneButton;
// Selected mode indicator colors
private static final int TAB_SELECTED = GuiColors.ACCENT_BROWN;
protected BaseAdjustmentScreen(Component title) {
super(title);
}
@Override
protected int getPreferredWidth() {
// Target: ~320px
return 320;
}
@Override
protected int getPreferredHeight() {
// Target: ~290px (extra row for scale controls)
return 290;
}
// ==================== ABSTRACT METHODS ====================
protected abstract LivingEntity getTargetEntity();
protected abstract ItemStack getGag();
protected abstract ItemStack getBlindfold();
protected abstract void sendAdjustment(Mode mode, float value, float scale);
protected String getExtraInfo() {
return null;
}
// ==================== INITIALIZATION ====================
@Override
protected void init() {
super.init();
// Determine initial mode based on what's equipped
if (getGag().isEmpty() && !getBlindfold().isEmpty()) {
currentMode = Mode.BLINDFOLD;
}
// Load current adjustment value
loadCurrentValue();
setupPreview();
setupSlider();
setupModeTabs();
setupActionButtons();
setupScaleButtons();
setupDoneButton();
updateButtonStates();
}
private void setupPreview() {
// Preview on left side of panel
int previewX = this.leftPos + MARGIN_L;
int previewY = this.topPos + TITLE_HEIGHT + MARGIN_M;
int previewWidth = 120; // Fixed width for preview area
int previewHeight = 140;
LivingEntity entity = getTargetEntity();
if (entity != null) {
preview = new EntityPreviewWidget(
previewX,
previewY,
previewWidth,
previewHeight,
entity
);
preview.setAutoRotate(false);
this.addRenderableWidget(preview);
}
}
private void setupSlider() {
// Slider on right side of panel
int sliderWidth = 40;
int sliderX =
this.leftPos + this.imageWidth - sliderWidth - MARGIN_L - 10;
int sliderY = this.topPos + TITLE_HEIGHT + MARGIN_M;
int sliderHeight = AdjustmentSlider.getRecommendedHeight(120);
slider = new AdjustmentSlider(
sliderX,
sliderY,
sliderWidth,
sliderHeight,
-4.0f,
4.0f,
currentValue,
this::onSliderChanged
);
this.addRenderableWidget(slider);
}
private void setupModeTabs() {
// Mode tabs below preview and slider area
int tabY = this.topPos + TITLE_HEIGHT + 140 + 10; // Below preview
int tabWidth = 60;
int totalTabsWidth = tabWidth * 3 + MARGIN_S * 2;
int tabStartX = this.leftPos + (this.imageWidth - totalTabsWidth) / 2;
gagButton = Button.builder(
Component.translatable("gui.tiedup.tab.gag"),
b -> setMode(Mode.GAG)
)
.bounds(tabStartX, tabY, tabWidth, BUTTON_HEIGHT)
.build();
this.addRenderableWidget(gagButton);
blindfoldButton = Button.builder(
Component.translatable("gui.tiedup.tab.blindfold"),
b -> setMode(Mode.BLINDFOLD)
)
.bounds(
tabStartX + tabWidth + MARGIN_S,
tabY,
tabWidth,
BUTTON_HEIGHT
)
.build();
this.addRenderableWidget(blindfoldButton);
bothButton = Button.builder(
Component.translatable("gui.tiedup.tab.both"),
b -> setMode(Mode.BOTH)
)
.bounds(
tabStartX + (tabWidth + MARGIN_S) * 2,
tabY,
tabWidth,
BUTTON_HEIGHT
)
.build();
this.addRenderableWidget(bothButton);
}
private void setupActionButtons() {
// Action buttons below tabs
int actionY = gagButton.getY() + BUTTON_HEIGHT + MARGIN_S;
int buttonWidth = 45;
int totalWidth = buttonWidth * 3 + MARGIN_S * 2;
int actionStartX = this.leftPos + (this.imageWidth - totalWidth) / 2;
resetButton = Button.builder(Component.literal("0"), b -> resetValue())
.bounds(actionStartX, actionY, buttonWidth, BUTTON_HEIGHT)
.build();
this.addRenderableWidget(resetButton);
decrementButton = Button.builder(Component.literal("-0.25"), b ->
slider.decrement()
)
.bounds(
actionStartX + buttonWidth + MARGIN_S,
actionY,
buttonWidth,
BUTTON_HEIGHT
)
.build();
this.addRenderableWidget(decrementButton);
incrementButton = Button.builder(Component.literal("+0.25"), b ->
slider.increment()
)
.bounds(
actionStartX + (buttonWidth + MARGIN_S) * 2,
actionY,
buttonWidth,
BUTTON_HEIGHT
)
.build();
this.addRenderableWidget(incrementButton);
}
private void setupScaleButtons() {
// Scale controls below position action buttons
int scaleY = incrementButton.getY() + BUTTON_HEIGHT + MARGIN_S;
int buttonWidth = 45;
int totalWidth = buttonWidth * 3 + MARGIN_S * 2;
int scaleStartX = this.leftPos + (this.imageWidth - totalWidth) / 2;
scaleResetButton = Button.builder(Component.literal("1x"), b ->
applyScale(AdjustmentHelper.DEFAULT_SCALE)
)
.bounds(scaleStartX, scaleY, buttonWidth, BUTTON_HEIGHT)
.build();
this.addRenderableWidget(scaleResetButton);
scaleDecrementButton = Button.builder(Component.literal("-0.1"), b ->
applyScale(currentScaleValue - AdjustmentHelper.SCALE_STEP)
)
.bounds(
scaleStartX + buttonWidth + MARGIN_S,
scaleY,
buttonWidth,
BUTTON_HEIGHT
)
.build();
this.addRenderableWidget(scaleDecrementButton);
scaleIncrementButton = Button.builder(Component.literal("+0.1"), b ->
applyScale(currentScaleValue + AdjustmentHelper.SCALE_STEP)
)
.bounds(
scaleStartX + (buttonWidth + MARGIN_S) * 2,
scaleY,
buttonWidth,
BUTTON_HEIGHT
)
.build();
this.addRenderableWidget(scaleIncrementButton);
}
private void setupDoneButton() {
// Done button at bottom
int doneX = this.leftPos + (this.imageWidth - BUTTON_WIDTH_L) / 2;
int doneY = this.topPos + this.imageHeight - BUTTON_HEIGHT - MARGIN_M;
doneButton = Button.builder(
Component.translatable("gui.tiedup.done"),
b -> onClose()
)
.bounds(doneX, doneY, BUTTON_WIDTH_L, BUTTON_HEIGHT)
.build();
this.addRenderableWidget(doneButton);
}
// ==================== LOGIC ====================
protected void loadCurrentValue() {
ItemStack stack = switch (currentMode) {
case GAG -> getGag();
case BLINDFOLD -> getBlindfold();
case BOTH -> {
ItemStack gag = getGag();
yield gag.isEmpty() ? getBlindfold() : gag;
}
};
if (!stack.isEmpty()) {
currentValue = AdjustmentHelper.getAdjustment(stack);
currentScaleValue = AdjustmentHelper.getScale(stack);
} else {
currentValue = 0.0f;
currentScaleValue = AdjustmentHelper.DEFAULT_SCALE;
}
if (slider != null) {
slider.setValue(currentValue);
}
}
private void onSliderChanged(float newValue) {
this.currentValue = newValue;
applyAdjustment(newValue);
if (preview != null) {
preview.refresh();
}
}
protected void applyAdjustment(float value) {
switch (currentMode) {
case GAG -> {
ItemStack gag = getGag();
if (!gag.isEmpty()) {
AdjustmentHelper.setAdjustment(gag, value);
AdjustmentHelper.setScale(gag, currentScaleValue);
sendAdjustment(Mode.GAG, value, currentScaleValue);
}
}
case BLINDFOLD -> {
ItemStack blind = getBlindfold();
if (!blind.isEmpty()) {
AdjustmentHelper.setAdjustment(blind, value);
AdjustmentHelper.setScale(blind, currentScaleValue);
sendAdjustment(Mode.BLINDFOLD, value, currentScaleValue);
}
}
case BOTH -> {
ItemStack gag = getGag();
ItemStack blind = getBlindfold();
if (!gag.isEmpty()) {
AdjustmentHelper.setAdjustment(gag, value);
AdjustmentHelper.setScale(gag, currentScaleValue);
sendAdjustment(Mode.GAG, value, currentScaleValue);
}
if (!blind.isEmpty()) {
AdjustmentHelper.setAdjustment(blind, value);
AdjustmentHelper.setScale(blind, currentScaleValue);
sendAdjustment(Mode.BLINDFOLD, value, currentScaleValue);
}
}
}
}
protected void applyScale(float scale) {
this.currentScaleValue = Mth.clamp(
scale,
AdjustmentHelper.MIN_SCALE,
AdjustmentHelper.MAX_SCALE
);
// Re-apply both values together (scale changed, position stays)
applyAdjustment(currentValue);
if (preview != null) {
preview.refresh();
}
}
protected void setMode(Mode mode) {
this.currentMode = mode;
loadCurrentValue();
updateButtonStates();
}
protected void resetValue() {
if (slider != null) {
slider.setValue(0.0f);
}
}
protected void updateButtonStates() {
boolean hasGag = !getGag().isEmpty();
boolean hasBlind = !getBlindfold().isEmpty();
if (gagButton != null) gagButton.active = hasGag;
if (blindfoldButton != null) blindfoldButton.active = hasBlind;
if (bothButton != null) bothButton.active = hasGag || hasBlind;
}
// ==================== RENDERING ====================
@Override
public void render(
GuiGraphics graphics,
int mouseX,
int mouseY,
float partialTick
) {
super.render(graphics, mouseX, mouseY, partialTick);
// Draw selected mode indicator
renderModeIndicator(graphics);
// Extra info (e.g., slave name)
String extraInfo = getExtraInfo();
if (extraInfo != null) {
graphics.drawCenteredString(
this.font,
extraInfo,
this.leftPos + this.imageWidth / 2,
this.topPos + TITLE_HEIGHT - 2,
GuiColors.ACCENT_TAN
);
}
// Scale label above scale buttons
if (scaleResetButton != null) {
String scaleLabel =
Component.translatable(
"gui.tiedup.adjustment.scale"
).getString() +
": " +
String.format("%.1fx", currentScaleValue);
graphics.drawCenteredString(
this.font,
scaleLabel,
this.leftPos + this.imageWidth / 2,
scaleResetButton.getY() - 10,
GuiColors.TEXT_GRAY
);
}
// Current item info
String itemInfo = getCurrentItemInfo();
graphics.drawCenteredString(
this.font,
itemInfo,
this.leftPos + this.imageWidth / 2,
this.topPos + this.imageHeight - BUTTON_HEIGHT - MARGIN_L - 10,
GuiColors.TEXT_GRAY
);
}
private void renderModeIndicator(GuiGraphics graphics) {
Button selectedButton = switch (currentMode) {
case GAG -> gagButton;
case BLINDFOLD -> blindfoldButton;
case BOTH -> bothButton;
};
if (selectedButton != null) {
int indicatorY = selectedButton.getY() + selectedButton.getHeight();
graphics.fill(
selectedButton.getX() + 2,
indicatorY,
selectedButton.getX() + selectedButton.getWidth() - 2,
indicatorY + 2,
TAB_SELECTED
);
}
}
protected String getCurrentItemInfo() {
return switch (currentMode) {
case GAG -> {
ItemStack gag = getGag();
yield gag.isEmpty()
? Component.translatable(
"gui.tiedup.adjustment.no_gag"
).getString()
: gag.getHoverName().getString();
}
case BLINDFOLD -> {
ItemStack blind = getBlindfold();
yield blind.isEmpty()
? Component.translatable(
"gui.tiedup.adjustment.no_blindfold"
).getString()
: blind.getHoverName().getString();
}
case BOTH -> Component.translatable(
"gui.tiedup.adjustment.both"
).getString();
};
}
}

View File

@@ -0,0 +1,128 @@
package com.tiedup.remake.client.gui.screens;
import com.tiedup.remake.client.gui.util.GuiTextureHelper;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
/**
* Base class for interaction screens (Command Wand, Dialogue, Conversation, Pet Request).
* Provides common layout functionality and reduces code duplication.
*/
@OnlyIn(Dist.CLIENT)
public abstract class BaseInteractionScreen extends Screen {
protected int leftPos;
protected int topPos;
protected final int panelWidth;
protected final int panelHeight;
protected BaseInteractionScreen(
Component title,
int panelWidth,
int panelHeight
) {
super(title);
this.panelWidth = panelWidth;
this.panelHeight = panelHeight;
}
@Override
protected void init() {
super.init();
// Center the panel on screen
this.leftPos = (this.width - panelWidth) / 2;
this.topPos = (this.height - panelHeight) / 2;
}
@Override
public void render(
GuiGraphics graphics,
int mouseX,
int mouseY,
float partialTick
) {
// Dark background
this.renderBackground(graphics);
// Main panel with vanilla-style bevel
GuiTextureHelper.renderBeveledPanel(
graphics,
leftPos,
topPos,
panelWidth,
panelHeight
);
// Let subclasses render their content
renderContent(graphics, mouseX, mouseY, partialTick);
// Render widgets (buttons) on top
super.render(graphics, mouseX, mouseY, partialTick);
}
/**
* Render the screen's content. Called after the panel background is drawn.
*
* @param graphics Graphics context
* @param mouseX Mouse X position
* @param mouseY Mouse Y position
* @param partialTick Partial tick for interpolation
*/
protected abstract void renderContent(
GuiGraphics graphics,
int mouseX,
int mouseY,
float partialTick
);
/**
* Get the X position for content with the given margin.
*
* @param margin Margin from the left edge
* @return Content X position
*/
protected int getContentX(int margin) {
return leftPos + margin;
}
/**
* Get the width available for content with the given margin.
*
* @param margin Margin on both sides
* @return Content width
*/
protected int getContentWidth(int margin) {
return panelWidth - margin * 2;
}
/**
* Render a centered title at the given Y position.
*
* @param graphics Graphics context
* @param title Title component
* @param y Y position
* @param color Text color
*/
protected void renderTitle(
GuiGraphics graphics,
Component title,
int y,
int color
) {
graphics.drawCenteredString(
this.font,
title,
leftPos + panelWidth / 2,
y,
color
);
}
@Override
public boolean isPauseScreen() {
return false;
}
}

View File

@@ -0,0 +1,158 @@
package com.tiedup.remake.client.gui.screens;
import com.tiedup.remake.client.gui.util.GuiColors;
import com.tiedup.remake.client.gui.util.GuiLayoutConstants;
import com.tiedup.remake.client.gui.util.GuiRenderUtil;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.network.chat.Component;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
/**
* Base abstract class for TiedUp! GUI screens.
* Handles responsive layout, background rendering, and common utilities.
*
* Refactored for cleaner architecture.
*/
@OnlyIn(Dist.CLIENT)
public abstract class BaseScreen extends Screen {
// Content area bounds
protected int leftPos;
protected int topPos;
protected int imageWidth;
protected int imageHeight;
protected BaseScreen(Component title) {
super(title);
}
/**
* Defines the preferred percentage width of the screen (0.0 to 1.0)
* or a fixed pixel width if context requires.
* Default implementation uses a responsive approach.
*/
protected int getPreferredWidth() {
// Default: 60% of screen, min 300px, max 450px
return GuiLayoutConstants.getResponsiveWidth(
this.width,
0.6f,
300,
450
);
}
/**
* Defines the preferred percentage height of the screen.
*/
protected int getPreferredHeight() {
// Default: 70% of screen, min 220px, max 400px
return GuiLayoutConstants.getResponsiveHeight(
this.height,
0.7f,
220,
400
);
}
@Override
protected void init() {
super.init();
// Calculate dimensions
this.imageWidth = getPreferredWidth();
this.imageHeight = getPreferredHeight();
// Center the panel
this.leftPos = (this.width - this.imageWidth) / 2;
this.topPos = (this.height - this.imageHeight) / 2;
}
@Override
public void render(
GuiGraphics graphics,
int mouseX,
int mouseY,
float partialTick
) {
this.renderBackground(graphics);
// Draw Main Panel Background
graphics.fill(
leftPos,
topPos,
leftPos + imageWidth,
topPos + imageHeight,
GuiColors.BG_MEDIUM
);
// Draw Borders
GuiRenderUtil.drawBorder(
graphics,
leftPos,
topPos,
imageWidth,
imageHeight,
GuiColors.BORDER_LIGHT
);
// Title
graphics.drawCenteredString(
this.font,
this.title,
this.width / 2,
this.topPos + GuiLayoutConstants.MARGIN_M,
GuiColors.TEXT_WHITE
);
// Template method: subclasses render custom content between background and widgets
renderContent(graphics, mouseX, mouseY, partialTick);
super.render(graphics, mouseX, mouseY, partialTick);
}
/**
* Template method for subclasses to render custom content between the
* background/title and the widgets. Called after background, panel fill,
* border, and title are drawn, but before {@code super.render()} draws
* widgets and tooltips.
*
* <p>Default implementation is a no-op so existing subclasses that
* override {@code render()} directly are unaffected.</p>
*
* <p><b>Important:</b> Subclasses should use EITHER this method OR a
* {@code render()} override, not both — combining them would render
* custom content twice.</p>
*/
protected void renderContent(
GuiGraphics graphics,
int mouseX,
int mouseY,
float partialTick
) {
// No-op by default — subclasses override to render custom content
}
@Override
public boolean isPauseScreen() {
return false;
}
/**
* Truncate text with ellipsis if too long.
*/
protected String truncateText(String text, int maxWidth) {
if (this.font.width(text) <= maxWidth) {
return text;
}
String ellipsis = "...";
int ellipsisWidth = this.font.width(ellipsis);
int availableWidth = maxWidth - ellipsisWidth;
if (availableWidth <= 0) {
return ellipsis;
}
// Basic truncation
return this.font.plainSubstrByWidth(text, availableWidth) + ellipsis;
}
}

View File

@@ -0,0 +1,256 @@
package com.tiedup.remake.client.gui.screens;
import com.tiedup.remake.bounty.Bounty;
import com.tiedup.remake.client.gui.util.GuiColors;
import com.tiedup.remake.client.gui.util.GuiLayoutConstants;
import com.tiedup.remake.client.gui.widgets.BountyEntryWidget;
import com.tiedup.remake.network.ModNetwork;
import com.tiedup.remake.network.bounty.PacketDeleteBounty;
import java.util.List;
import java.util.UUID;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.ContainerObjectSelectionList;
import net.minecraft.network.chat.Component;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
/**
* Screen displaying all active bounties.
* Refactored to use standard ContainerObjectSelectionList.
*/
@OnlyIn(Dist.CLIENT)
public class BountyListScreen extends BaseScreen {
private final List<Bounty> bounties;
private final boolean isAdmin;
private BountyList bountyList;
private Button closeButton;
private Button deleteButton;
public BountyListScreen(List<Bounty> bounties, boolean isAdmin) {
super(Component.translatable("gui.tiedup.bounties.title"));
this.bounties = bounties;
this.isAdmin = isAdmin;
}
@Override
protected int getPreferredWidth() {
return GuiLayoutConstants.getResponsiveWidth(
this.width,
0.7f,
350,
500
);
}
@Override
protected int getPreferredHeight() {
return GuiLayoutConstants.getResponsiveHeight(
this.height,
0.8f,
250,
400
);
}
@Override
protected void init() {
super.init();
int listTop = this.topPos + GuiLayoutConstants.TITLE_HEIGHT + 20;
int listBottom =
this.topPos +
this.imageHeight -
GuiLayoutConstants.BUTTON_HEIGHT * 2 -
20;
int listHeight = listBottom - listTop;
// Initialize List
bountyList = new BountyList(
minecraft,
this.imageWidth - 20,
listHeight,
listTop,
listBottom
);
bountyList.setLeftPos(this.leftPos + 10);
// Populate List
for (Bounty bounty : bounties) {
bountyList.addEntryPublic(
new BountyEntryWidget(bounty, this::onEntrySelected)
);
}
this.addRenderableWidget(bountyList);
// Buttons
int btnWidth = GuiLayoutConstants.BUTTON_WIDTH_XL;
int btnX = this.leftPos + (this.imageWidth - btnWidth) / 2;
int btnY = listBottom + 10;
deleteButton = Button.builder(
Component.translatable("gui.tiedup.bounties.delete"),
b -> onDeleteClicked()
)
.bounds(btnX, btnY, btnWidth, GuiLayoutConstants.BUTTON_HEIGHT)
.build();
deleteButton.active = false;
this.addRenderableWidget(deleteButton);
closeButton = Button.builder(
Component.translatable("gui.tiedup.close"),
b -> onClose()
)
.bounds(
btnX,
btnY + GuiLayoutConstants.BUTTON_HEIGHT + 4,
btnWidth,
GuiLayoutConstants.BUTTON_HEIGHT
)
.build();
this.addRenderableWidget(closeButton);
}
private void onEntrySelected(BountyEntryWidget entry) {
bountyList.setSelected(entry);
updateDeleteButton();
}
private void updateDeleteButton() {
BountyEntryWidget selected = bountyList.getSelected();
if (selected == null) {
deleteButton.active = false;
return;
}
Bounty bounty = selected.getBounty();
if (minecraft.player == null) {
deleteButton.active = false;
return;
}
UUID playerId = minecraft.player.getUUID();
// Can delete if admin or bounty client
deleteButton.active = isAdmin || bounty.isClient(playerId);
// Update visual selection state in all widgets
for (BountyEntryWidget w : bountyList.children()) {
w.setSelected(w == selected);
}
}
private void onDeleteClicked() {
BountyEntryWidget selected = bountyList.getSelected();
if (selected == null) return;
Bounty bounty = selected.getBounty();
ModNetwork.sendToServer(new PacketDeleteBounty(bounty.getId()));
// Remove from list
bounties.remove(bounty);
bountyList.removeEntryPublic(selected);
// Reset selection
bountyList.setSelected(null);
updateDeleteButton();
}
@Override
public void render(
GuiGraphics graphics,
int mouseX,
int mouseY,
float partialTick
) {
super.render(graphics, mouseX, mouseY, partialTick);
// Bounty count
String countText =
bounties.size() + " bounti" + (bounties.size() != 1 ? "es" : "y");
graphics.drawString(
this.font,
countText,
this.leftPos + GuiLayoutConstants.MARGIN_M,
this.topPos + GuiLayoutConstants.TITLE_HEIGHT + 2,
GuiColors.TEXT_GRAY
);
// Empty state
if (bounties.isEmpty()) {
graphics.drawCenteredString(
this.font,
Component.translatable("gui.tiedup.bounties.noEntries"),
this.leftPos + this.imageWidth / 2,
this.topPos + this.imageHeight / 2,
GuiColors.TEXT_DISABLED
);
}
// Render tooltip for hovered entry
BountyEntryWidget hovered = bountyList.getEntryAtPositionPublic(
mouseX,
mouseY
);
if (hovered != null) {
graphics.renderTooltip(
this.font,
hovered.getBounty().getReward(),
mouseX,
mouseY
);
}
}
// ==================== INNER CLASS: LIST ====================
class BountyList extends ContainerObjectSelectionList<BountyEntryWidget> {
public BountyList(
Minecraft mc,
int width,
int height,
int top,
int bottom
) {
super(mc, width, height, top, bottom, 55); // 55 = item height
this.centerListVertically = false;
this.setRenderBackground(false);
this.setRenderTopAndBottom(false);
}
public void addEntryPublic(BountyEntryWidget entry) {
this.addEntry(entry);
}
public void removeEntryPublic(BountyEntryWidget entry) {
this.removeEntry(entry);
}
public BountyEntryWidget getEntryAtPositionPublic(double x, double y) {
return super.getEntryAtPosition(x, y);
}
@Override
public int getRowWidth() {
return this.width - 20;
}
@Override
protected int getScrollbarPosition() {
return this.getLeft() + this.width - 6;
}
public void setLeftPos(int left) {
this.x0 = left;
this.x1 = left + this.width;
}
@Override
public int getRowLeft() {
return BountyListScreen.this.leftPos + 10;
}
}
}

View File

@@ -0,0 +1,405 @@
package com.tiedup.remake.client.gui.screens;
import com.tiedup.remake.client.gui.util.GuiColors;
import com.tiedup.remake.client.gui.util.GuiRenderUtil;
import com.tiedup.remake.network.ModNetwork;
import com.tiedup.remake.network.cell.PacketCoreMenuAction;
import com.tiedup.remake.network.cell.PacketRenameCell;
import java.util.UUID;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.Component;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
/**
* Compact Cell Core menu GUI.
* Fixed-size panel (~180x220px) with action buttons and cell info.
*/
@OnlyIn(Dist.CLIENT)
public class CellCoreScreen extends Screen {
private static final int PANEL_WIDTH = 180;
private static final int PANEL_HEIGHT = 243;
private static final int BUTTON_WIDTH = 160;
private static final int BUTTON_HEIGHT = 20;
private static final int MARGIN = 8;
private final BlockPos corePos;
private final UUID cellId;
private final String cellName;
private final String stateName;
private final int interiorVolume;
private final int wallCount;
private final int breachCount;
private final int prisonerCount;
private final int bedCount;
private final int doorCount;
private final int anchorCount;
private final boolean hasSpawn;
private final boolean hasDelivery;
private final boolean hasDisguise;
private boolean showInfo = false;
private boolean renameMode = false;
private EditBox renameBox;
private int leftPos;
private int topPos;
public CellCoreScreen(
BlockPos corePos,
UUID cellId,
String cellName,
String stateName,
int interiorVolume,
int wallCount,
int breachCount,
int prisonerCount,
int bedCount,
int doorCount,
int anchorCount,
boolean hasSpawn,
boolean hasDelivery,
boolean hasDisguise
) {
super(Component.translatable("gui.tiedup.cell_core"));
this.corePos = corePos;
this.cellId = cellId;
this.cellName =
cellName != null && !cellName.isEmpty() ? cellName : "Cell Core";
this.stateName = stateName;
this.interiorVolume = interiorVolume;
this.wallCount = wallCount;
this.breachCount = breachCount;
this.prisonerCount = prisonerCount;
this.bedCount = bedCount;
this.doorCount = doorCount;
this.anchorCount = anchorCount;
this.hasSpawn = hasSpawn;
this.hasDelivery = hasDelivery;
this.hasDisguise = hasDisguise;
}
@Override
protected void init() {
super.init();
int panelHeight = showInfo ? PANEL_HEIGHT + 90 : PANEL_HEIGHT;
this.leftPos = (this.width - PANEL_WIDTH) / 2;
this.topPos = (this.height - panelHeight) / 2;
int btnX = leftPos + (PANEL_WIDTH - BUTTON_WIDTH) / 2;
int currentY = topPos + 24;
// Set Spawn
addRenderableWidget(
Button.builder(
Component.translatable("gui.tiedup.cell_core.set_spawn"),
b -> {
ModNetwork.sendToServer(
new PacketCoreMenuAction(
corePos,
PacketCoreMenuAction.Action.SET_SPAWN
)
);
onClose();
}
)
.bounds(btnX, currentY, BUTTON_WIDTH, BUTTON_HEIGHT)
.build()
);
currentY += BUTTON_HEIGHT + 3;
// Set Delivery
addRenderableWidget(
Button.builder(
Component.translatable("gui.tiedup.cell_core.set_delivery"),
b -> {
ModNetwork.sendToServer(
new PacketCoreMenuAction(
corePos,
PacketCoreMenuAction.Action.SET_DELIVERY
)
);
onClose();
}
)
.bounds(btnX, currentY, BUTTON_WIDTH, BUTTON_HEIGHT)
.build()
);
currentY += BUTTON_HEIGHT + 3;
// Set Disguise
addRenderableWidget(
Button.builder(
Component.translatable("gui.tiedup.cell_core.set_disguise"),
b -> {
ModNetwork.sendToServer(
new PacketCoreMenuAction(
corePos,
PacketCoreMenuAction.Action.SET_DISGUISE
)
);
onClose();
}
)
.bounds(btnX, currentY, BUTTON_WIDTH, BUTTON_HEIGHT)
.build()
);
currentY += BUTTON_HEIGHT + 3;
// Rename
if (renameMode) {
renameBox = new EditBox(
this.font,
btnX,
currentY,
BUTTON_WIDTH - 50,
BUTTON_HEIGHT,
Component.translatable("gui.tiedup.cell_core.cell_name")
);
renameBox.setMaxLength(32);
String currentName = cellName.equals("Cell Core") ? "" : cellName;
renameBox.setValue(currentName);
addRenderableWidget(renameBox);
renameBox.setFocused(true);
setFocused(renameBox);
addRenderableWidget(
Button.builder(
Component.translatable("gui.tiedup.cell_core.button.ok"),
b -> {
String newName = renameBox.getValue().trim();
ModNetwork.sendToServer(
new PacketRenameCell(cellId, newName)
);
renameMode = false;
onClose();
}
)
.bounds(
btnX + BUTTON_WIDTH - 46,
currentY,
46,
BUTTON_HEIGHT
)
.build()
);
currentY += BUTTON_HEIGHT + 3;
} else {
addRenderableWidget(
Button.builder(
Component.translatable("gui.tiedup.cell_core.rename"),
b -> {
renameMode = true;
rebuildWidgets();
}
)
.bounds(btnX, currentY, BUTTON_WIDTH, BUTTON_HEIGHT)
.build()
);
currentY += BUTTON_HEIGHT + 3;
}
// Re-scan
addRenderableWidget(
Button.builder(
Component.translatable("gui.tiedup.cell_core.rescan"),
b -> {
ModNetwork.sendToServer(
new PacketCoreMenuAction(
corePos,
PacketCoreMenuAction.Action.RESCAN
)
);
}
)
.bounds(btnX, currentY, BUTTON_WIDTH, BUTTON_HEIGHT)
.build()
);
currentY += BUTTON_HEIGHT + 3;
// Info toggle
addRenderableWidget(
Button.builder(
Component.translatable("gui.tiedup.cell_core.info"),
b -> {
showInfo = !showInfo;
rebuildWidgets();
}
)
.bounds(btnX, currentY, BUTTON_WIDTH, BUTTON_HEIGHT)
.build()
);
currentY += BUTTON_HEIGHT + 6;
// Close
addRenderableWidget(
Button.builder(
Component.translatable("gui.tiedup.cell_core.close"),
b -> onClose()
)
.bounds(btnX, currentY, BUTTON_WIDTH, BUTTON_HEIGHT)
.build()
);
}
@Override
public void render(
GuiGraphics graphics,
int mouseX,
int mouseY,
float partialTick
) {
this.renderBackground(graphics);
int panelHeight = showInfo ? PANEL_HEIGHT + 90 : PANEL_HEIGHT;
int panelTopPos = (this.height - panelHeight) / 2;
// Panel background
graphics.fill(
leftPos,
panelTopPos,
leftPos + PANEL_WIDTH,
panelTopPos + panelHeight,
GuiColors.BG_MEDIUM
);
GuiRenderUtil.drawBorder(
graphics,
leftPos,
panelTopPos,
PANEL_WIDTH,
panelHeight,
GuiColors.BORDER_LIGHT
);
// Title
graphics.drawCenteredString(
this.font,
cellName,
leftPos + PANEL_WIDTH / 2,
panelTopPos + MARGIN,
GuiColors.TEXT_WHITE
);
// Info section (below buttons)
if (showInfo) {
int infoY = panelTopPos + PANEL_HEIGHT - 10;
int infoX = leftPos + MARGIN;
int lineH = 10;
graphics.drawString(
this.font,
Component.translatable(
"gui.tiedup.cell_core.info.state",
stateName
).getString(),
infoX,
infoY,
getStateColor()
);
infoY += lineH;
graphics.drawString(
this.font,
Component.translatable(
"gui.tiedup.cell_core.info.interior",
interiorVolume
).getString(),
infoX,
infoY,
GuiColors.TEXT_GRAY
);
infoY += lineH;
String wallStr =
breachCount > 0
? Component.translatable(
"gui.tiedup.cell_core.info.walls_breached",
wallCount,
breachCount
).getString()
: Component.translatable(
"gui.tiedup.cell_core.info.walls",
wallCount
).getString();
graphics.drawString(
this.font,
wallStr,
infoX,
infoY,
breachCount > 0 ? GuiColors.WARNING : GuiColors.TEXT_GRAY
);
infoY += lineH;
graphics.drawString(
this.font,
Component.translatable(
"gui.tiedup.cell_core.info.prisoners",
prisonerCount
).getString(),
infoX,
infoY,
GuiColors.TEXT_GRAY
);
infoY += lineH;
graphics.drawString(
this.font,
Component.translatable(
"gui.tiedup.cell_core.info.beds_doors_anchors",
bedCount,
doorCount,
anchorCount
).getString(),
infoX,
infoY,
GuiColors.TEXT_GRAY
);
infoY += lineH;
String features = "";
if (hasSpawn) features +=
Component.translatable(
"gui.tiedup.cell_core.info.feature_spawn"
).getString() +
" ";
if (hasDelivery) features +=
Component.translatable(
"gui.tiedup.cell_core.info.feature_delivery"
).getString() +
" ";
if (hasDisguise) features += Component.translatable(
"gui.tiedup.cell_core.info.feature_disguise"
).getString();
if (features.isEmpty()) features = Component.translatable(
"gui.tiedup.cell_core.info.none_set"
).getString();
graphics.drawString(
this.font,
Component.translatable(
"gui.tiedup.cell_core.info.set",
features.trim()
).getString(),
infoX,
infoY,
GuiColors.TEXT_GRAY
);
}
super.render(graphics, mouseX, mouseY, partialTick);
}
private int getStateColor() {
return switch (stateName) {
case "intact" -> GuiColors.SUCCESS;
case "breached" -> GuiColors.WARNING;
case "compromised" -> GuiColors.ERROR;
default -> GuiColors.TEXT_GRAY;
};
}
@Override
public boolean isPauseScreen() {
return false;
}
}

View File

@@ -0,0 +1,684 @@
package com.tiedup.remake.client.gui.screens;
import com.tiedup.remake.client.gui.util.GuiColors;
import com.tiedup.remake.client.gui.util.GuiLayoutConstants;
import com.tiedup.remake.client.gui.widgets.CellListRenderer;
import com.tiedup.remake.network.ModNetwork;
import com.tiedup.remake.network.cell.PacketCellAction;
import com.tiedup.remake.network.cell.PacketOpenCellManager.CellSyncData;
import com.tiedup.remake.network.cell.PacketOpenCellManager.PrisonerInfo;
import com.tiedup.remake.network.cell.PacketRenameCell;
import java.util.List;
import org.jetbrains.annotations.Nullable;
import net.minecraft.ChatFormatting;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.network.chat.Component;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
/**
* Screen for managing player-owned cells.
* Shows list of cells with prisoners and management actions.
*
* Operators (OP) can see and manage ALL cells.
* Non-operators can only manage their own cells.
*/
@OnlyIn(Dist.CLIENT)
public class CellManagerScreen extends BaseScreen {
private final List<CellSyncData> cells;
private final boolean isOperator;
// Selection state
@Nullable
private CellSyncData selectedCell;
@Nullable
private PrisonerInfo selectedPrisoner;
// UI state
private boolean renameMode = false;
private EditBox renameBox;
// Layout
private int listStartY;
private int listHeight;
private int cellEntryHeight = 24;
private int prisonerEntryHeight = 18;
private int scrollOffset = 0;
// Buttons
private Button closeButton;
private Button renameButton;
private Button deleteButton;
private Button releaseButton;
private Button teleportButton;
/**
* Create the cell manager screen.
*
* @param cells List of cells to display
* @param isOperator True if the viewing player is an operator (can manage all cells)
*/
public CellManagerScreen(List<CellSyncData> cells, boolean isOperator) {
super(Component.translatable("gui.tiedup.cell_manager"));
this.cells = cells;
this.isOperator = isOperator;
}
/**
* Legacy constructor (assumes not operator).
*/
public CellManagerScreen(List<CellSyncData> cells) {
this(cells, false);
}
@Override
protected int getPreferredWidth() {
return GuiLayoutConstants.getResponsiveWidth(
this.width,
0.7f,
400,
550
);
}
@Override
protected int getPreferredHeight() {
return GuiLayoutConstants.getResponsiveHeight(
this.height,
0.8f,
350,
500
);
}
@Override
protected void init() {
super.init();
int contentTop =
this.topPos +
GuiLayoutConstants.TITLE_HEIGHT +
GuiLayoutConstants.MARGIN_M;
listStartY = contentTop;
// Calculate list height (leave room for buttons at bottom)
int buttonsAreaHeight =
GuiLayoutConstants.BUTTON_HEIGHT * 2 +
GuiLayoutConstants.MARGIN_L +
GuiLayoutConstants.MARGIN_M;
listHeight =
this.imageHeight - (listStartY - this.topPos) - buttonsAreaHeight;
// === Action Buttons (bottom) ===
int buttonY =
this.topPos +
this.imageHeight -
GuiLayoutConstants.BUTTON_HEIGHT * 2 -
GuiLayoutConstants.MARGIN_L;
int buttonWidth = 80;
int buttonSpacing = 6;
int buttonsStartX = this.leftPos + GuiLayoutConstants.MARGIN_M;
// Row 1: Rename, Delete
renameButton = Button.builder(
Component.translatable("gui.tiedup.cell_manager.button.rename"),
b -> toggleRenameMode()
)
.bounds(
buttonsStartX,
buttonY,
buttonWidth,
GuiLayoutConstants.BUTTON_HEIGHT
)
.build();
this.addRenderableWidget(renameButton);
deleteButton = Button.builder(
Component.translatable(
"gui.tiedup.cell_manager.button.delete"
).withStyle(ChatFormatting.RED),
b -> deleteSelectedCell()
)
.bounds(
buttonsStartX + buttonWidth + buttonSpacing,
buttonY,
buttonWidth,
GuiLayoutConstants.BUTTON_HEIGHT
)
.build();
this.addRenderableWidget(deleteButton);
// Row 2: Release, Teleport
int row2Y =
buttonY +
GuiLayoutConstants.BUTTON_HEIGHT +
GuiLayoutConstants.MARGIN_S;
releaseButton = Button.builder(
Component.translatable("gui.tiedup.cell_manager.button.release"),
b -> releaseSelectedPrisoner()
)
.bounds(
buttonsStartX,
row2Y,
buttonWidth,
GuiLayoutConstants.BUTTON_HEIGHT
)
.build();
this.addRenderableWidget(releaseButton);
teleportButton = Button.builder(
Component.translatable("gui.tiedup.cell_manager.button.teleport"),
b -> teleportSelectedPrisoner()
)
.bounds(
buttonsStartX + buttonWidth + buttonSpacing,
row2Y,
buttonWidth,
GuiLayoutConstants.BUTTON_HEIGHT
)
.build();
this.addRenderableWidget(teleportButton);
// Close button (right side)
closeButton = Button.builder(
Component.translatable("gui.tiedup.close"),
b -> onClose()
)
.bounds(
this.leftPos +
this.imageWidth -
GuiLayoutConstants.BUTTON_WIDTH_M -
GuiLayoutConstants.MARGIN_M,
row2Y,
GuiLayoutConstants.BUTTON_WIDTH_M,
GuiLayoutConstants.BUTTON_HEIGHT
)
.build();
this.addRenderableWidget(closeButton);
// Rename edit box (hidden by default)
renameBox = new EditBox(
this.font,
this.leftPos + GuiLayoutConstants.MARGIN_M,
buttonY - 25,
this.imageWidth - GuiLayoutConstants.MARGIN_M * 2 - 60,
20,
Component.translatable("gui.tiedup.cell_manager.cell_name")
);
renameBox.setMaxLength(32);
renameBox.setVisible(false);
this.addRenderableWidget(renameBox);
updateButtonStates();
}
private void updateButtonStates() {
boolean hasCellSelected = selectedCell != null;
boolean hasPrisonerSelected = selectedPrisoner != null;
// Determine if player can manage the selected cell
// OP can manage any cell, non-OP can only manage their owned cells
boolean canManageCell =
hasCellSelected && (isOperator || selectedCell.isOwned);
renameButton.active = canManageCell && !renameMode;
deleteButton.active = canManageCell && !renameMode;
releaseButton.active =
canManageCell && hasPrisonerSelected && !renameMode;
teleportButton.active =
canManageCell && hasPrisonerSelected && !renameMode;
}
private void toggleRenameMode() {
if (selectedCell == null) return;
renameMode = !renameMode;
renameBox.setVisible(renameMode);
if (renameMode) {
String currentName =
selectedCell.name != null ? selectedCell.name : "";
renameBox.setValue(currentName);
renameBox.setFocused(true);
renameButton.setMessage(
Component.translatable(
"gui.tiedup.cell_manager.button.save"
).withStyle(ChatFormatting.GREEN)
);
} else {
// Save the name
String newName = renameBox.getValue().trim();
ModNetwork.sendToServer(
new PacketRenameCell(selectedCell.cellId, newName)
);
renameButton.setMessage(
Component.translatable("gui.tiedup.cell_manager.button.rename")
);
}
updateButtonStates();
}
private void deleteSelectedCell() {
if (selectedCell == null) return;
ModNetwork.sendToServer(
new PacketCellAction(
PacketCellAction.Action.DELETE_CELL,
selectedCell.cellId,
null,
null
)
);
// Remove from local list and deselect
cells.remove(selectedCell);
selectedCell = null;
selectedPrisoner = null;
updateButtonStates();
}
private void releaseSelectedPrisoner() {
if (selectedCell == null || selectedPrisoner == null) return;
ModNetwork.sendToServer(
new PacketCellAction(
PacketCellAction.Action.RELEASE,
selectedCell.cellId,
selectedPrisoner.prisonerId,
null
)
);
// Remove prisoner from local state
selectedCell.prisoners.remove(selectedPrisoner);
selectedPrisoner = null;
updateButtonStates();
}
private void teleportSelectedPrisoner() {
if (selectedCell == null || selectedPrisoner == null) return;
ModNetwork.sendToServer(
new PacketCellAction(
PacketCellAction.Action.TELEPORT,
selectedCell.cellId,
selectedPrisoner.prisonerId,
null
)
);
}
@Override
protected void renderContent(
GuiGraphics graphics,
int mouseX,
int mouseY,
float partialTick
) {
// Cell count subtitle (show OP mode indicator if operator)
Component subtitle = Component.translatable(
"gui.tiedup.cell_manager.label.cell_count",
cells.size()
);
if (isOperator) {
subtitle = subtitle
.copy()
.append(" ")
.append(
Component.translatable(
"gui.tiedup.cell_manager.label.op_mode"
)
);
}
graphics.drawCenteredString(
this.font,
subtitle
.copy()
.withStyle(
isOperator ? ChatFormatting.GOLD : ChatFormatting.GRAY
),
this.leftPos + this.imageWidth / 2,
this.topPos + GuiLayoutConstants.MARGIN_M + 12,
isOperator ? GuiColors.WARNING : GuiColors.TEXT_GRAY
);
// Render cell list
renderCellList(graphics, mouseX, mouseY);
// Render rename mode hint
if (renameMode) {
graphics.drawString(
this.font,
Component.translatable(
"gui.tiedup.cell_manager.status.rename_hint"
).getString(),
this.leftPos + GuiLayoutConstants.MARGIN_M,
renameBox.getY() - 12,
GuiColors.TEXT_GRAY
);
}
}
/**
* Calculate the total pixel height of all cell entries.
*/
private int getTotalContentHeight() {
int total = 0;
for (CellSyncData cell : cells) {
total += cellEntryHeight;
int prisoners = cell.prisoners.isEmpty()
? 1
: cell.prisoners.size();
total += prisoners * prisonerEntryHeight;
total += 4; // spacing
}
return total;
}
private int getMaxScrollOffset() {
int totalHeight = getTotalContentHeight();
return Math.max(0, totalHeight - listHeight);
}
@Override
public boolean mouseScrolled(double mouseX, double mouseY, double delta) {
int maxScroll = getMaxScrollOffset();
if (maxScroll > 0) {
int scrollAmount = (int) (delta * 20);
scrollOffset = Math.max(
0,
Math.min(maxScroll, scrollOffset - scrollAmount)
);
return true;
}
return super.mouseScrolled(mouseX, mouseY, delta);
}
private void renderCellList(GuiGraphics graphics, int mouseX, int mouseY) {
int listX = this.leftPos + GuiLayoutConstants.MARGIN_M;
int listWidth = this.imageWidth - GuiLayoutConstants.MARGIN_M * 2;
// Apply scroll offset to starting Y
int currentY = listStartY - scrollOffset;
for (CellSyncData cell : cells) {
if (currentY > listStartY + listHeight) break;
if (currentY + cellEntryHeight < listStartY) {
currentY +=
cellEntryHeight +
cell.prisoners.size() * prisonerEntryHeight;
continue;
}
// Cell header background
boolean cellSelected = cell == selectedCell;
boolean cellHovered =
mouseX >= listX &&
mouseX < listX + listWidth &&
mouseY >= currentY &&
mouseY < currentY + cellEntryHeight;
int bgColor;
if (cellSelected) {
bgColor = GuiColors.SLOT_SELECTED;
} else if (cellHovered) {
bgColor = GuiColors.SLOT_HOVER;
} else {
bgColor = GuiColors.BG_LIGHT;
}
graphics.fill(
listX,
currentY,
listX + listWidth,
currentY + cellEntryHeight,
bgColor
);
// Cell icon - different color for owned vs not-owned cells
int iconColor = cell.isOwned
? GuiColors.TYPE_COLLAR
: GuiColors.TEXT_DISABLED;
graphics.fill(
listX + 4,
currentY + 4,
listX + 18,
currentY + 18,
iconColor
);
// Lock icon overlay if not owned and not OP
if (!cell.isOwned && !isOperator) {
graphics.drawString(
this.font,
"\uD83D\uDD12",
listX + 5,
currentY + 5,
GuiColors.TEXT_DISABLED
); // Lock emoji
}
// Cell name
String displayName = cell.getDisplayName();
int nameColor = cell.isOwned
? GuiColors.TEXT_WHITE
: GuiColors.TEXT_GRAY;
graphics.drawString(
this.font,
displayName,
listX + 24,
currentY + 4,
nameColor
);
// Prisoner count badge
CellListRenderer.renderCountBadge(
graphics,
this.font,
cell.prisonerCount,
cell.maxPrisoners,
listX + listWidth,
currentY + 4
);
// Location + owner info for non-owned cells
String locStr = "@ " + cell.spawnPoint.toShortString();
if (!cell.isOwned && cell.ownerName != null) {
locStr +=
" (" +
Component.translatable(
"gui.tiedup.cell_manager.label.owner",
cell.ownerName
).getString() +
")";
}
graphics.drawString(
this.font,
locStr,
listX + 24,
currentY + 14,
GuiColors.TEXT_DISABLED
);
currentY += cellEntryHeight;
// Render prisoners under this cell
for (PrisonerInfo prisoner : cell.prisoners) {
if (currentY > listStartY + listHeight) break;
boolean prisonerSelected =
prisoner == selectedPrisoner && cell == selectedCell;
boolean prisonerHovered =
mouseX >= listX + 20 &&
mouseX < listX + listWidth &&
mouseY >= currentY &&
mouseY < currentY + prisonerEntryHeight;
int prisonerBg;
if (prisonerSelected) {
prisonerBg = GuiColors.lighten(
GuiColors.SLOT_SELECTED,
0.2f
);
} else if (prisonerHovered) {
prisonerBg = GuiColors.SLOT_HOVER;
} else {
prisonerBg = GuiColors.BG_DARK;
}
graphics.fill(
listX + 20,
currentY,
listX + listWidth,
currentY + prisonerEntryHeight,
prisonerBg
);
// Prisoner indicator
graphics.drawString(
this.font,
" \u2514\u2500 ",
listX,
currentY + 2,
GuiColors.TEXT_DISABLED
);
// Prisoner name
graphics.drawString(
this.font,
prisoner.prisonerName,
listX + 40,
currentY + 4,
GuiColors.TEXT_WHITE
);
currentY += prisonerEntryHeight;
}
// If no prisoners, show "(empty)"
if (cell.prisoners.isEmpty()) {
graphics.drawString(
this.font,
Component.literal(" \u2514\u2500 ")
.append(
Component.translatable(
"gui.tiedup.cell_manager.label.empty"
)
)
.withStyle(ChatFormatting.ITALIC),
listX,
currentY + 2,
GuiColors.TEXT_DISABLED
);
currentY += prisonerEntryHeight;
}
// Spacing between cells
currentY += 4;
}
// If no cells
if (cells.isEmpty()) {
CellListRenderer.renderEmptyState(
graphics,
this.font,
this.leftPos + this.imageWidth / 2,
listStartY,
"gui.tiedup.cell_manager.status.no_cells",
"gui.tiedup.cell_manager.status.use_cellwand"
);
}
}
@Override
public boolean mouseClicked(double mouseX, double mouseY, int button) {
if (button == 0 && !renameMode) {
// Only handle clicks within the visible list area
if (mouseY < listStartY || mouseY > listStartY + listHeight) {
return super.mouseClicked(mouseX, mouseY, button);
}
// Handle cell/prisoner selection
int listX = this.leftPos + GuiLayoutConstants.MARGIN_M;
int listWidth = this.imageWidth - GuiLayoutConstants.MARGIN_M * 2;
int currentY = listStartY - scrollOffset;
for (CellSyncData cell : cells) {
if (currentY > listStartY + listHeight) break;
// Check cell header click
if (
mouseX >= listX &&
mouseX < listX + listWidth &&
mouseY >= currentY &&
mouseY < currentY + cellEntryHeight
) {
selectedCell = cell;
selectedPrisoner = null;
updateButtonStates();
return true;
}
currentY += cellEntryHeight;
// Check prisoner clicks
for (PrisonerInfo prisoner : cell.prisoners) {
if (currentY > listStartY + listHeight) break;
if (
mouseX >= listX + 20 &&
mouseX < listX + listWidth &&
mouseY >= currentY &&
mouseY < currentY + prisonerEntryHeight
) {
selectedCell = cell;
selectedPrisoner = prisoner;
updateButtonStates();
return true;
}
currentY += prisonerEntryHeight;
}
// Empty cell placeholder height
if (cell.prisoners.isEmpty()) {
currentY += prisonerEntryHeight;
}
currentY += 4; // Spacing
}
}
return super.mouseClicked(mouseX, mouseY, button);
}
@Override
public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
if (renameMode) {
if (keyCode == 257) {
// Enter
toggleRenameMode(); // Save
return true;
} else if (keyCode == 256) {
// Escape
renameMode = false;
renameBox.setVisible(false);
renameButton.setMessage(
Component.translatable(
"gui.tiedup.cell_manager.button.rename"
)
);
updateButtonStates();
return true;
}
}
return super.keyPressed(keyCode, scanCode, modifiers);
}
}

View File

@@ -0,0 +1,321 @@
package com.tiedup.remake.client.gui.screens;
import com.tiedup.remake.client.gui.util.GuiColors;
import com.tiedup.remake.client.gui.util.GuiLayoutConstants;
import com.tiedup.remake.client.gui.widgets.CellListRenderer;
import com.tiedup.remake.network.ModNetwork;
import com.tiedup.remake.network.cell.PacketAssignCellToCollar;
import com.tiedup.remake.network.cell.PacketOpenCellSelector.CellOption;
import java.util.List;
import java.util.UUID;
import org.jetbrains.annotations.Nullable;
import net.minecraft.ChatFormatting;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.components.Button;
import net.minecraft.network.chat.Component;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
/**
* Modal screen for selecting a cell to assign to a collar.
* Opens from SlaveItemManagementScreen when clicking "Cell" button.
*/
@OnlyIn(Dist.CLIENT)
public class CellSelectorScreen extends BaseScreen {
private final UUID targetEntityUUID;
private final List<CellOption> cells;
@Nullable
private CellOption selectedCell;
// Layout
private int listStartY;
private int listHeight;
private int entryHeight = 28;
// Buttons
private Button confirmButton;
private Button clearButton;
private Button backButton;
public CellSelectorScreen(UUID targetEntityUUID, List<CellOption> cells) {
super(Component.translatable("gui.tiedup.select_cell"));
this.targetEntityUUID = targetEntityUUID;
this.cells = cells;
}
@Override
protected int getPreferredWidth() {
return GuiLayoutConstants.getResponsiveWidth(
this.width,
0.5f,
280,
380
);
}
@Override
protected int getPreferredHeight() {
return GuiLayoutConstants.getResponsiveHeight(
this.height,
0.6f,
250,
350
);
}
@Override
protected void init() {
super.init();
int contentTop =
this.topPos +
GuiLayoutConstants.TITLE_HEIGHT +
GuiLayoutConstants.MARGIN_M;
listStartY = contentTop;
// Calculate list height
int buttonsAreaHeight =
GuiLayoutConstants.BUTTON_HEIGHT + GuiLayoutConstants.MARGIN_L;
listHeight =
this.imageHeight - (listStartY - this.topPos) - buttonsAreaHeight;
// === Buttons at bottom ===
int buttonY =
this.topPos +
this.imageHeight -
GuiLayoutConstants.BUTTON_HEIGHT -
GuiLayoutConstants.MARGIN_M;
int buttonWidth = 70;
int buttonSpacing = 8;
int totalButtonsWidth = buttonWidth * 3 + buttonSpacing * 2;
int buttonsStartX =
this.leftPos + (this.imageWidth - totalButtonsWidth) / 2;
confirmButton = Button.builder(
Component.translatable(
"gui.tiedup.cell_selector.button.confirm"
).withStyle(ChatFormatting.GREEN),
b -> confirmSelection()
)
.bounds(
buttonsStartX,
buttonY,
buttonWidth,
GuiLayoutConstants.BUTTON_HEIGHT
)
.build();
this.addRenderableWidget(confirmButton);
clearButton = Button.builder(
Component.translatable(
"gui.tiedup.cell_selector.button.clear"
).withStyle(ChatFormatting.YELLOW),
b -> clearSelection()
)
.bounds(
buttonsStartX + buttonWidth + buttonSpacing,
buttonY,
buttonWidth,
GuiLayoutConstants.BUTTON_HEIGHT
)
.build();
this.addRenderableWidget(clearButton);
backButton = Button.builder(
Component.translatable("gui.tiedup.cell_selector.button.back"),
b -> onClose()
)
.bounds(
buttonsStartX + (buttonWidth + buttonSpacing) * 2,
buttonY,
buttonWidth,
GuiLayoutConstants.BUTTON_HEIGHT
)
.build();
this.addRenderableWidget(backButton);
updateButtonStates();
}
private void updateButtonStates() {
confirmButton.active = selectedCell != null;
}
private void confirmSelection() {
if (selectedCell == null) return;
// Send packet to assign cell
ModNetwork.sendToServer(
new PacketAssignCellToCollar(targetEntityUUID, selectedCell.cellId)
);
onClose();
}
private void clearSelection() {
// Send packet to clear cell (null cellId)
ModNetwork.sendToServer(
new PacketAssignCellToCollar(targetEntityUUID, null)
);
onClose();
}
@Override
protected void renderContent(
GuiGraphics graphics,
int mouseX,
int mouseY,
float partialTick
) {
// Render cell list
renderCellList(graphics, mouseX, mouseY);
}
private void renderCellList(GuiGraphics graphics, int mouseX, int mouseY) {
int listX = this.leftPos + GuiLayoutConstants.MARGIN_M;
int listWidth = this.imageWidth - GuiLayoutConstants.MARGIN_M * 2;
int currentY = listStartY;
for (int i = 0; i < cells.size(); i++) {
CellOption cell = cells.get(i);
if (currentY + entryHeight > listStartY + listHeight) break;
boolean isSelected = cell == selectedCell;
boolean isHovered =
mouseX >= listX &&
mouseX < listX + listWidth &&
mouseY >= currentY &&
mouseY < currentY + entryHeight;
// Background
int bgColor;
if (isSelected) {
bgColor = GuiColors.SLOT_SELECTED;
} else if (isHovered) {
bgColor = GuiColors.SLOT_HOVER;
} else {
bgColor = (i % 2 == 0)
? GuiColors.SLOT_EMPTY
: GuiColors.BG_LIGHT;
}
graphics.fill(
listX,
currentY,
listX + listWidth,
currentY + entryHeight - 2,
bgColor
);
// Radio button indicator
int radioX = listX + 8;
int radioY = currentY + entryHeight / 2 - 4;
if (isSelected) {
// Filled circle
graphics.fill(
radioX,
radioY,
radioX + 8,
radioY + 8,
GuiColors.SUCCESS
);
} else {
// Empty circle (border only)
graphics.fill(
radioX,
radioY,
radioX + 8,
radioY + 8,
GuiColors.TEXT_GRAY
);
graphics.fill(
radioX + 1,
radioY + 1,
radioX + 7,
radioY + 7,
bgColor
);
}
// Cell name
graphics.drawString(
this.font,
cell.displayName,
listX + 24,
currentY + 4,
GuiColors.TEXT_WHITE
);
// Prisoner count badge
CellListRenderer.renderCountBadge(
graphics,
this.font,
cell.prisonerCount,
cell.maxPrisoners,
listX + listWidth,
currentY + 4
);
// Full indicator
if (cell.prisonerCount >= cell.maxPrisoners) {
graphics.drawString(
this.font,
Component.translatable(
"gui.tiedup.cell_selector.status.full"
).withStyle(ChatFormatting.RED),
listX + 24,
currentY + 16,
GuiColors.ERROR
);
}
currentY += entryHeight;
}
// If no cells
if (cells.isEmpty()) {
CellListRenderer.renderEmptyState(
graphics,
this.font,
this.leftPos + this.imageWidth / 2,
listStartY,
"gui.tiedup.cell_selector.status.no_cells",
"gui.tiedup.cell_selector.status.use_cellwand"
);
}
}
@Override
public boolean mouseClicked(double mouseX, double mouseY, int button) {
if (button == 0) {
int listX = this.leftPos + GuiLayoutConstants.MARGIN_M;
int listWidth = this.imageWidth - GuiLayoutConstants.MARGIN_M * 2;
int currentY = listStartY;
for (CellOption cell : cells) {
if (currentY + entryHeight > listStartY + listHeight) break;
if (
mouseX >= listX &&
mouseX < listX + listWidth &&
mouseY >= currentY &&
mouseY < currentY + entryHeight
) {
// Don't allow selecting full cells
if (cell.prisonerCount < cell.maxPrisoners) {
selectedCell = cell;
updateButtonStates();
}
return true;
}
currentY += entryHeight;
}
}
return super.mouseClicked(mouseX, mouseY, button);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,544 @@
package com.tiedup.remake.client.gui.screens;
import com.mojang.blaze3d.platform.InputConstants;
import com.tiedup.remake.client.ModKeybindings;
import com.tiedup.remake.client.gui.util.GuiColors;
import com.tiedup.remake.client.gui.util.GuiLayoutConstants;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.network.ModNetwork;
import com.tiedup.remake.network.minigame.PacketContinuousStruggleHold;
import com.tiedup.remake.network.minigame.PacketContinuousStruggleStop;
import java.util.UUID;
import net.minecraft.ChatFormatting;
import net.minecraft.client.KeyMapping;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.network.chat.Component;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import org.lwjgl.glfw.GLFW;
/**
* Client-side GUI for the Continuous Struggle mini-game.
*
* Layout:
* ┌─────────────────────────────────────┐
* │ STRUGGLING... │
* │ │
* │ ┌─────┐ │
* │ │ ↑ │ ← Arrow │
* │ │ W │ direction │
* │ └─────┘ │
* │ │
* │ HOLD [W] to struggle! │
* │ │
* │ ████████████░░░░░░░░░ 45/100 │ ← Progress bar
* │ │
* │ Press ESC to stop │
* └─────────────────────────────────────┘
*/
@OnlyIn(Dist.CLIENT)
public class ContinuousStruggleMiniGameScreen extends BaseScreen {
private UUID sessionId;
private int currentDirection;
private int currentResistance;
private int maxResistance;
private boolean isLocked;
// Visual state
private float animatedResistance;
private int flashTicks;
private boolean showDirectionChangeFlash;
private boolean showShockEffect;
private int shockEffectTicks;
private boolean gameComplete;
private boolean gameSuccess;
private int completionDelayTicks;
// Input state
private int heldDirection = -1;
private int ticksSinceLastUpdate = 0;
// Colors
private static final int ARROW_BG_NORMAL = 0xFF444444;
private static final int ARROW_BG_HOLDING = 0xFF006600;
private static final int ARROW_BG_WRONG = 0xFF664400;
private static final int ARROW_BG_SHOCKED = 0xFF660000;
private static final int ARROW_TEXT_COLOR = 0xFFFFFFFF;
private static final int PROGRESS_BAR_BG = 0xFF442222;
private static final int PROGRESS_BAR_FILL = 0xFF44AA44;
private static final int PROGRESS_BAR_EMPTY = 0xFFAA4444;
// Update interval (every 5 ticks = 4 times per second)
private static final int UPDATE_INTERVAL_TICKS = 5;
public ContinuousStruggleMiniGameScreen(
UUID sessionId,
int currentDirection,
int currentResistance,
int maxResistance,
boolean isLocked
) {
super(Component.translatable("gui.tiedup.continuous_struggle"));
this.sessionId = sessionId;
this.currentDirection = currentDirection;
this.currentResistance = currentResistance;
this.maxResistance = maxResistance;
this.isLocked = isLocked;
this.animatedResistance = currentResistance;
}
@Override
protected int getPreferredWidth() {
return GuiLayoutConstants.getResponsiveWidth(
this.width,
0.5f,
280,
350
);
}
@Override
protected int getPreferredHeight() {
return GuiLayoutConstants.getResponsiveHeight(
this.height,
0.45f,
200,
280
);
}
@Override
protected void init() {
super.init();
TiedUpMod.LOGGER.info(
"[ContinuousStruggleMiniGameScreen] Screen initialized: dir={}, resistance={}/{}, locked={}",
currentDirection,
currentResistance,
maxResistance,
isLocked
);
}
// ==================== STATE UPDATES FROM SERVER ====================
/**
* Called when direction changes.
*/
public void onDirectionChange(int newDirection) {
this.currentDirection = newDirection;
this.showDirectionChangeFlash = true;
this.flashTicks = 15;
TiedUpMod.LOGGER.debug(
"[ContinuousStruggleMiniGameScreen] Direction changed to {}",
getDirectionKeyName(newDirection)
);
}
/**
* Called when resistance updates.
*/
public void onResistanceUpdate(int newResistance) {
this.currentResistance = newResistance;
}
/**
* Called when shock collar triggers.
*/
public void onShock() {
this.showShockEffect = true;
this.shockEffectTicks = 20;
}
/**
* Called when player escapes successfully.
*/
public void onEscape() {
this.gameComplete = true;
this.gameSuccess = true;
this.completionDelayTicks = 40; // 2 seconds
}
/**
* Called when session ends.
*/
public void onEnd() {
this.gameComplete = true;
this.gameSuccess = false;
this.onClose();
}
// ==================== TICK AND RENDER ====================
@Override
public void tick() {
super.tick();
// Animate resistance bar
animatedResistance = lerp(animatedResistance, currentResistance, 0.15f);
// Flash timer
if (flashTicks > 0) {
flashTicks--;
if (flashTicks == 0) {
showDirectionChangeFlash = false;
}
}
// Shock effect timer
if (shockEffectTicks > 0) {
shockEffectTicks--;
if (shockEffectTicks == 0) {
showShockEffect = false;
}
}
// Completion delay
if (gameComplete && completionDelayTicks > 0) {
completionDelayTicks--;
if (completionDelayTicks <= 0) {
this.onClose();
}
return; // Don't send updates after game complete
}
// Check held keys and send updates to server
if (!gameComplete) {
updateHeldDirection();
ticksSinceLastUpdate++;
if (ticksSinceLastUpdate >= UPDATE_INTERVAL_TICKS) {
ticksSinceLastUpdate = 0;
sendHoldUpdate();
}
}
}
/**
* Check which direction key is currently held.
* Uses InputConstants.isKeyDown() directly because keyMapping.isDown()
* doesn't work properly when a Screen is open.
*/
private void updateHeldDirection() {
if (this.minecraft == null) return;
long windowHandle = this.minecraft.getWindow().getWindow();
int newHeldDirection = -1;
// Check each direction key using direct GLFW input
for (int i = 0; i < 4; i++) {
KeyMapping keyMapping = ModKeybindings.getStruggleDirectionKey(i);
if (keyMapping != null) {
// Get the key code from the KeyMapping
InputConstants.Key key = keyMapping.getKey();
if (key.getType() == InputConstants.Type.KEYSYM) {
if (
InputConstants.isKeyDown(windowHandle, key.getValue())
) {
newHeldDirection = i;
break;
}
}
}
}
this.heldDirection = newHeldDirection;
}
/**
* Send held direction update to server.
*/
private void sendHoldUpdate() {
boolean isHolding = heldDirection >= 0;
ModNetwork.sendToServer(
new PacketContinuousStruggleHold(
sessionId,
heldDirection,
isHolding
)
);
}
@Override
public void render(
GuiGraphics graphics,
int mouseX,
int mouseY,
float partialTick
) {
try {
super.render(graphics, mouseX, mouseY, partialTick);
int centerX = this.width / 2;
int y = this.topPos + GuiLayoutConstants.TITLE_HEIGHT + 15;
// Direction arrow
renderDirectionArrow(graphics, centerX, y);
y += 80;
// Hold instruction
renderHoldInstruction(graphics, centerX, y);
y += 25;
// Progress bar
renderProgressBar(graphics, y);
y += 40;
// Lock indicator
if (isLocked) {
Component lockText = Component.translatable(
"gui.tiedup.continuous_struggle.status.locked"
).withStyle(ChatFormatting.RED, ChatFormatting.BOLD);
int lockWidth = this.font.width(lockText);
graphics.drawString(
this.font,
lockText,
centerX - lockWidth / 2,
y,
0xFFFFFFFF
);
y += 18;
}
// Status / instructions
renderStatus(graphics, centerX, y);
// Shock overlay
if (showShockEffect) {
renderShockOverlay(graphics);
}
} catch (Exception e) {
TiedUpMod.LOGGER.error(
"[ContinuousStruggleMiniGameScreen] Render error: ",
e
);
}
}
private void renderDirectionArrow(
GuiGraphics graphics,
int centerX,
int y
) {
int arrowSize = 60;
int arrowX = centerX - arrowSize / 2;
// Determine background color
int bgColor;
if (showShockEffect) {
bgColor = ARROW_BG_SHOCKED;
} else if (showDirectionChangeFlash) {
bgColor = (flashTicks % 4 < 2) ? 0xFF666600 : ARROW_BG_NORMAL;
} else if (heldDirection == currentDirection) {
bgColor = ARROW_BG_HOLDING;
} else if (heldDirection >= 0 && heldDirection != currentDirection) {
bgColor = ARROW_BG_WRONG;
} else {
bgColor = ARROW_BG_NORMAL;
}
// Draw arrow box
graphics.fill(arrowX, y, arrowX + arrowSize, y + arrowSize, bgColor);
graphics.renderOutline(arrowX, y, arrowSize, arrowSize, 0xFFAAAAAA);
// Draw arrow symbol
String arrowSymbol = getDirectionArrow(currentDirection);
int arrowWidth = this.font.width(arrowSymbol);
graphics.drawString(
this.font,
arrowSymbol,
centerX - arrowWidth / 2,
y + 12,
ARROW_TEXT_COLOR
);
// Draw key name
String keyName = getDirectionKeyName(currentDirection);
int keyWidth = this.font.width(keyName);
graphics.drawString(
this.font,
keyName,
centerX - keyWidth / 2,
y + arrowSize - 20,
ARROW_TEXT_COLOR
);
}
private void renderHoldInstruction(
GuiGraphics graphics,
int centerX,
int y
) {
String keyName = getDirectionKeyName(currentDirection);
Component instruction;
if (showShockEffect) {
instruction = Component.translatable(
"gui.tiedup.continuous_struggle.status.shocked"
).withStyle(ChatFormatting.RED, ChatFormatting.BOLD);
} else if (heldDirection == currentDirection) {
instruction = Component.translatable(
"gui.tiedup.continuous_struggle.status.struggling"
).withStyle(ChatFormatting.GREEN);
} else {
instruction = Component.translatable(
"gui.tiedup.continuous_struggle.hold_key",
keyName
).withStyle(ChatFormatting.YELLOW);
}
int instructionWidth = this.font.width(instruction);
graphics.drawString(
this.font,
instruction,
centerX - instructionWidth / 2,
y,
0xFFFFFFFF
);
}
private void renderProgressBar(GuiGraphics graphics, int y) {
int barWidth = 200;
int barHeight = 18;
int barX = (this.width - barWidth) / 2;
// Calculate progress (inverted: 0 resistance = full progress)
float progress =
maxResistance > 0
? 1.0f - (animatedResistance / maxResistance)
: 0.0f;
progress = Math.max(0.0f, Math.min(1.0f, progress));
// Background (empty portion)
graphics.fill(barX, y, barX + barWidth, y + barHeight, PROGRESS_BAR_BG);
// Fill (progress portion)
int fillWidth = (int) (barWidth * progress);
if (fillWidth > 0) {
graphics.fill(
barX,
y,
barX + fillWidth,
y + barHeight,
PROGRESS_BAR_FILL
);
}
// Border
graphics.renderOutline(barX, y, barWidth, barHeight, 0xFF888888);
// Text showing resistance
String progressText = String.format(
"%d/%d",
(int) animatedResistance,
maxResistance
);
int textWidth = this.font.width(progressText);
graphics.drawString(
this.font,
progressText,
barX + barWidth + 8,
y + 5,
GuiColors.TEXT_WHITE
);
// Label above bar
Component label = Component.translatable(
"gui.tiedup.continuous_struggle.label.resistance"
).withStyle(ChatFormatting.GRAY);
graphics.drawString(
this.font,
label,
barX,
y - 12,
GuiColors.TEXT_WHITE
);
}
private void renderStatus(GuiGraphics graphics, int centerX, int y) {
Component status;
if (gameComplete && gameSuccess) {
status = Component.translatable(
"gui.tiedup.continuous_struggle.status.escaped"
).withStyle(ChatFormatting.GREEN, ChatFormatting.BOLD);
} else if (gameComplete) {
status = Component.translatable(
"gui.tiedup.continuous_struggle.status.stopped"
).withStyle(ChatFormatting.GRAY);
} else {
status = Component.translatable(
"gui.tiedup.continuous_struggle.status.press_esc"
).withStyle(ChatFormatting.DARK_GRAY);
}
int statusWidth = this.font.width(status);
graphics.drawString(
this.font,
status,
centerX - statusWidth / 2,
y,
0xFFFFFFFF
);
}
private void renderShockOverlay(GuiGraphics graphics) {
// Red flash overlay
int alpha = (int) (100 * ((float) shockEffectTicks / 20.0f));
int overlayColor = (alpha << 24) | 0xFF0000;
graphics.fill(0, 0, this.width, this.height, overlayColor);
}
// ==================== INPUT HANDLING ====================
@Override
public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
// ESC to stop — onClose() handles sending the stop packet
if (keyCode == GLFW.GLFW_KEY_ESCAPE) {
this.onClose();
return true;
}
return super.keyPressed(keyCode, scanCode, modifiers);
}
@Override
public void onClose() {
// Send stop packet if not already complete
if (!gameComplete) {
ModNetwork.sendToServer(
new PacketContinuousStruggleStop(sessionId)
);
}
super.onClose();
}
@Override
public boolean isPauseScreen() {
return false;
}
// ==================== HELPERS ====================
private String getDirectionArrow(int direction) {
return switch (direction) {
case 0 -> "\u2191"; // ↑
case 1 -> "\u2190"; // ←
case 2 -> "\u2193"; // ↓
case 3 -> "\u2192"; // →
default -> "?";
};
}
private String getDirectionKeyName(int direction) {
return ModKeybindings.getStruggleDirectionKeyName(direction);
}
private float lerp(float current, float target, float speed) {
if (Math.abs(current - target) < 0.5f) {
return target;
}
return current + (target - current) * speed;
}
}

View File

@@ -0,0 +1,274 @@
package com.tiedup.remake.client.gui.screens;
import com.tiedup.remake.dialogue.conversation.ConversationTopic;
import com.tiedup.remake.network.ModNetwork;
import com.tiedup.remake.network.conversation.PacketEndConversationC2S;
import com.tiedup.remake.network.conversation.PacketSelectTopic;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.components.Button;
import net.minecraft.network.chat.Component;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
/**
* GUI screen for interactive conversations with NPCs.
* Displays available conversation topics with effectiveness indicators.
*
* Phase 5: Enhanced Conversation System
* Phase 2: Refactored to extend BaseInteractionScreen
*
* DISABLED: Conversation system not in use. Kept because PacketEndConversationS2C
* references this class in an instanceof check.
*/
@OnlyIn(Dist.CLIENT)
public class ConversationScreen extends BaseInteractionScreen {
// Layout constants
private static final int PANEL_WIDTH = 260;
private static final int PANEL_HEIGHT = 220;
private static final int MARGIN = 12;
private static final int BUTTON_HEIGHT = 22;
private static final int BUTTON_SPACING = 4;
private static final int CATEGORY_SPACING = 8;
// Color constants
private static final int TITLE_COLOR = 0xFFFFD700;
private static final int CATEGORY_COLOR = 0xFF88AAFF;
private static final int TEXT_WHITE = 0xFFFFFFFF;
private static final int TEXT_DIM = 0xFFAAAAAA;
// Effectiveness colors (green -> yellow -> orange -> red)
private static final int EFF_100 = 0xFF55FF55; // Green (100%)
private static final int EFF_80 = 0xFFAAFF55; // Yellow-green (80%)
private static final int EFF_60 = 0xFFFFFF55; // Yellow (60%)
private static final int EFF_40 = 0xFFFFAA55; // Orange (40%)
private static final int EFF_20 = 0xFFFF5555; // Red (20%)
// Data
private final int entityId;
private final String npcName;
private final List<ConversationTopic> availableTopics;
private final Map<
ConversationTopic.Category,
List<ConversationTopic>
> topicsByCategory;
// Effectiveness tracking (sent from server or estimated client-side)
private final Map<ConversationTopic, Float> topicEffectiveness;
// Rapport level (0-100 display)
private int rapportLevel = 50;
public ConversationScreen(
int entityId,
String npcName,
List<ConversationTopic> topics
) {
super(
Component.translatable("gui.tiedup.conversation.title", npcName),
PANEL_WIDTH,
PANEL_HEIGHT
);
this.entityId = entityId;
this.npcName = npcName;
this.availableTopics = topics;
this.topicEffectiveness = new LinkedHashMap<>();
// Initialize all topics as 100% effective (will be updated from server)
for (ConversationTopic topic : topics) {
topicEffectiveness.put(topic, 1.0f);
}
// Organize topics by category
this.topicsByCategory = new LinkedHashMap<>();
for (ConversationTopic.Category category : ConversationTopic.Category.values()) {
topicsByCategory.put(category, new ArrayList<>());
}
for (ConversationTopic topic : topics) {
topicsByCategory.get(topic.getCategory()).add(topic);
}
// Remove empty categories
topicsByCategory
.entrySet()
.removeIf(entry -> entry.getValue().isEmpty());
}
/**
* Update topic effectiveness (called from packet handler).
*
* @param topic The topic
* @param effectiveness Effectiveness value (0.2 to 1.0)
*/
public void updateEffectiveness(
ConversationTopic topic,
float effectiveness
) {
topicEffectiveness.put(topic, effectiveness);
}
/**
* Update rapport level (called from packet handler).
*
* @param rapport Rapport value (-100 to 100)
*/
public void updateRapport(int rapport) {
this.rapportLevel = rapport;
}
@Override
protected void init() {
super.init(); // Centers the panel (sets leftPos and topPos)
rebuildButtons();
}
private void rebuildButtons() {
this.clearWidgets();
int contentX = getContentX(MARGIN);
int contentWidth = getContentWidth(MARGIN);
int y = topPos + 40; // After title and WIP badge
// WIP placeholder buttons
Button wipBtn1 = Button.builder(
Component.translatable(
"gui.tiedup.conversation.wip.small_talk"
).withStyle(net.minecraft.ChatFormatting.GRAY),
b -> {}
)
.bounds(contentX, y, contentWidth, BUTTON_HEIGHT)
.build();
wipBtn1.active = false;
addRenderableWidget(wipBtn1);
y += BUTTON_HEIGHT + BUTTON_SPACING;
Button wipBtn2 = Button.builder(
Component.translatable(
"gui.tiedup.conversation.wip.deep_topics"
).withStyle(net.minecraft.ChatFormatting.GRAY),
b -> {}
)
.bounds(contentX, y, contentWidth, BUTTON_HEIGHT)
.build();
wipBtn2.active = false;
addRenderableWidget(wipBtn2);
y += BUTTON_HEIGHT + BUTTON_SPACING;
Button wipBtn3 = Button.builder(
Component.translatable(
"gui.tiedup.conversation.wip.flirting"
).withStyle(net.minecraft.ChatFormatting.GRAY),
b -> {}
)
.bounds(contentX, y, contentWidth, BUTTON_HEIGHT)
.build();
wipBtn3.active = false;
addRenderableWidget(wipBtn3);
y += BUTTON_HEIGHT + BUTTON_SPACING;
Button wipBtn4 = Button.builder(
Component.translatable(
"gui.tiedup.conversation.wip.requests"
).withStyle(net.minecraft.ChatFormatting.GRAY),
b -> {}
)
.bounds(contentX, y, contentWidth, BUTTON_HEIGHT)
.build();
wipBtn4.active = false;
addRenderableWidget(wipBtn4);
// Close button at bottom
int closeBtnY = topPos + panelHeight - 28;
addRenderableWidget(
Button.builder(Component.translatable("gui.tiedup.close"), btn ->
onClose()
)
.bounds(leftPos + panelWidth / 2 - 40, closeBtnY, 80, 20)
.build()
);
}
private int getEffectivenessColor(float effectiveness) {
if (effectiveness >= 1.0f) return EFF_100;
if (effectiveness >= 0.8f) return EFF_80;
if (effectiveness >= 0.6f) return EFF_60;
if (effectiveness >= 0.4f) return EFF_40;
return EFF_20;
}
private String getEffectivenessSymbol(float effectiveness) {
return (int) (effectiveness * 100) + "%";
}
private void selectTopic(ConversationTopic topic) {
// Send packet to server
ModNetwork.sendToServer(new PacketSelectTopic(entityId, topic));
// Decrease local effectiveness estimate (will be corrected by server)
float current = topicEffectiveness.getOrDefault(topic, 1.0f);
float next = Math.max(0.2f, current - 0.2f);
topicEffectiveness.put(topic, next);
// Don't rebuild buttons immediately - wait for server response
}
@Override
protected void renderContent(
GuiGraphics graphics,
int mouseX,
int mouseY,
float partialTick
) {
// Draw simple title with WIP badge
renderTitle(
graphics,
Component.translatable("gui.tiedup.conversation.heading"),
topPos + 8,
TITLE_COLOR
);
// WIP badge
Component wipBadge = Component.translatable(
"gui.tiedup.conversation.wip_badge"
).withStyle(
net.minecraft.ChatFormatting.YELLOW,
net.minecraft.ChatFormatting.BOLD
);
graphics.drawCenteredString(
font,
wipBadge,
leftPos + panelWidth / 2,
topPos + 18,
0xFFFFAA00
);
// Info text
Component infoText = Component.translatable(
"gui.tiedup.conversation.status.coming_soon"
).withStyle(
net.minecraft.ChatFormatting.GRAY,
net.minecraft.ChatFormatting.ITALIC
);
graphics.drawCenteredString(
font,
infoText,
leftPos + panelWidth / 2,
topPos + 30,
TEXT_DIM
);
}
@Override
public void onClose() {
// Notify server that conversation ended
ModNetwork.sendToServer(new PacketEndConversationC2S(entityId));
super.onClose();
}
public int getEntityId() {
return entityId;
}
}

View File

@@ -0,0 +1,511 @@
package com.tiedup.remake.client.gui.screens;
import com.tiedup.remake.client.gui.util.GuiColors;
import com.tiedup.remake.client.gui.util.GuiLayoutConstants;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.network.ModNetwork;
import com.tiedup.remake.network.minigame.PacketLockpickAttempt;
import com.tiedup.remake.network.minigame.PacketLockpickMiniGameMove;
import java.util.UUID;
import net.minecraft.ChatFormatting;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.network.chat.Component;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import org.lwjgl.glfw.GLFW;
/**
* Phase 2.5: Client-side GUI for Lockpick mini-game (Skyrim-style).
*
* Features:
* - Sweet spot is HIDDEN (uniform gray bar)
* - NO feedback during movement (A/D)
* - Feedback ONLY when testing (SPACE): tension bar animation
* - Tension bar fills up based on proximity, then bounces back if miss
* - Success = tension bar fills to 100% and stays
*
* Visual layout:
* ┌─────────────────────────────────────────┐
* │ LOCKPICKING │
* │ │
* │ Position: │
* │ ┌─────────────────────────────────┐ │
* │ │░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│ │ ← Uniform gray (sweet spot hidden)
* │ └─────────────────────────────────┘ │
* │ ▲ │
* │ │
* │ Tension: │
* │ ┌─────────────────────────────────┐ │
* │ │████████████░░░░░░░░░░░░░░░░░░░░░│ │ ← Animates on test
* │ └─────────────────────────────────┘ │
* │ │
* │ [A/D] Move | [SPACE] Test | ◉◉◉◎◎ │
* └─────────────────────────────────────────┘
*/
@OnlyIn(Dist.CLIENT)
public class LockpickMiniGameScreen extends BaseScreen {
private UUID sessionId;
private float sweetSpotCenter;
private float sweetSpotWidth;
private float currentPosition;
private int remainingUses;
private int maxUses;
// Visual state
private boolean gameComplete;
private boolean gameSuccess;
private int completionDelayTicks;
// Animation
private float animatedPosition;
// Movement
private static final float MOVE_SPEED = 0.015f;
private boolean movingLeft;
private boolean movingRight;
// Sync throttle
private long lastSyncTime;
private static final long SYNC_INTERVAL_MS = 50; // 20 updates per second max
// ==================== TENSION BAR ANIMATION ====================
/** Whether a tension animation is currently playing */
private boolean isTesting = false;
/** Target fill level for tension bar (0.0-1.0) */
private float tensionFillTarget = 0f;
/** Current fill level of tension bar (0.0-1.0) */
private float tensionCurrent = 0f;
/** Is the tension bar rising or falling (bouncing) */
private boolean tensionRising = true;
/** Speed of tension bar fill */
private static final float FILL_SPEED = 0.025f;
/** Speed of tension bar bounce-back (slower than fill) */
private static final float BOUNCE_SPEED = 0.015f;
/** Color for tension bar based on fill target */
private int tensionColor = 0xFFAA0000; // Red by default
public LockpickMiniGameScreen(
UUID sessionId,
float sweetSpotCenter,
float sweetSpotWidth,
float currentPosition,
int remainingUses
) {
super(Component.translatable("gui.tiedup.lockpick_minigame"));
this.sessionId = sessionId;
this.sweetSpotCenter = sweetSpotCenter;
this.sweetSpotWidth = sweetSpotWidth;
this.currentPosition = currentPosition;
this.animatedPosition = currentPosition;
this.remainingUses = remainingUses;
this.maxUses = remainingUses;
this.gameComplete = false;
this.gameSuccess = false;
}
@Override
protected int getPreferredWidth() {
return GuiLayoutConstants.getResponsiveWidth(
this.width,
0.5f,
300,
380
);
}
@Override
protected int getPreferredHeight() {
return GuiLayoutConstants.getResponsiveHeight(
this.height,
0.45f,
200,
260
);
}
@Override
protected void init() {
super.init();
}
@Override
public void tick() {
super.tick();
// Handle continuous movement
if (!gameComplete && !isTesting) {
if (movingLeft && !movingRight) {
movePosition(-MOVE_SPEED);
} else if (movingRight && !movingLeft) {
movePosition(MOVE_SPEED);
}
}
// Animate position indicator
animatedPosition = lerp(animatedPosition, currentPosition, 0.3f);
// Animate tension bar
if (isTesting) {
if (tensionRising) {
tensionCurrent += FILL_SPEED;
if (tensionCurrent >= tensionFillTarget) {
tensionCurrent = tensionFillTarget;
if (tensionFillTarget < 1.0f) {
// Not success - start bouncing back
tensionRising = false;
}
// If 100%, keep it (success animation handled separately)
}
} else {
// Bouncing back
tensionCurrent -= BOUNCE_SPEED;
if (tensionCurrent <= 0) {
tensionCurrent = 0;
isTesting = false; // Animation complete
}
}
}
// Completion delay
if (gameComplete && completionDelayTicks > 0) {
completionDelayTicks--;
if (completionDelayTicks <= 0) {
this.onClose();
}
}
}
private void movePosition(float delta) {
float newPos = Math.max(0.0f, Math.min(1.0f, currentPosition + delta));
if (newPos != currentPosition) {
currentPosition = newPos;
syncPositionToServer();
}
}
private void syncPositionToServer() {
long now = System.currentTimeMillis();
if (now - lastSyncTime >= SYNC_INTERVAL_MS) {
ModNetwork.sendToServer(
new PacketLockpickMiniGameMove(sessionId, currentPosition)
);
lastSyncTime = now;
}
}
/**
* Called when the lock was successfully picked.
* Triggers success animation (tension bar fills to 100% and stays).
*/
public void onSuccess() {
this.gameComplete = true;
this.gameSuccess = true;
this.completionDelayTicks = 60; // Wait for animation
// Success animation: fill to 100%
triggerSuccessAnimation();
}
/**
* Called when the player missed the sweet spot.
* Triggers tension bar animation based on distance.
*/
public void onMissed(int newRemainingUses, float distance) {
this.remainingUses = newRemainingUses;
// Trigger tension animation based on distance
triggerTensionAnimation(distance);
}
/**
* Called when the player ran out of lockpicks.
*/
public void onOutOfPicks() {
this.gameComplete = true;
this.gameSuccess = false;
this.remainingUses = 0;
this.completionDelayTicks = 40;
// Final failed animation
tensionFillTarget = 0.1f;
tensionCurrent = 0f;
tensionRising = true;
tensionColor = 0xFFAA0000; // Red
isTesting = true;
}
/**
* Called when the session was cancelled.
*/
public void onCancelled() {
this.onClose();
}
/**
* Trigger tension bar animation for a MISS based on distance.
* Closer = fills more (but still bounces back).
*/
private void triggerTensionAnimation(float distance) {
isTesting = true;
tensionRising = true;
tensionCurrent = 0f;
// Calculate fill target based on distance (sweet spot = 3% = 0.015 radius)
if (distance < 0.05f) {
tensionFillTarget = 0.85f; // Very close - fills a lot
tensionColor = 0xFF00AA00; // Green
} else if (distance < 0.10f) {
tensionFillTarget = 0.60f; // Close
tensionColor = 0xFFAAAA00; // Yellow
} else if (distance < 0.20f) {
tensionFillTarget = 0.35f; // Medium
tensionColor = 0xFFFF8800; // Orange
} else {
tensionFillTarget = 0.15f; // Far - fills little
tensionColor = 0xFFAA0000; // Red
}
TiedUpMod.LOGGER.debug(
"[LockpickMiniGameScreen] Tension animation: distance={}, fillTarget={}",
distance,
tensionFillTarget
);
}
/**
* Trigger success animation - fills to 100% and stays.
*/
private void triggerSuccessAnimation() {
isTesting = true;
tensionRising = true;
tensionCurrent = 0f;
tensionFillTarget = 1.0f; // Full!
tensionColor = 0xFF00FF00; // Bright green
}
@Override
public void render(
GuiGraphics graphics,
int mouseX,
int mouseY,
float partialTick
) {
super.render(graphics, mouseX, mouseY, partialTick);
int centerX = this.width / 2;
int y = this.topPos + GuiLayoutConstants.TITLE_HEIGHT + 15;
// Position bar (uniform - sweet spot hidden)
Component posLabel = Component.translatable(
"gui.tiedup.lockpick_minigame.label.position"
).withStyle(ChatFormatting.GRAY);
graphics.drawString(this.font, posLabel, centerX - 120, y, 0xFFFFFFFF);
y += 12;
renderPositionBar(graphics, y);
y += 55;
// Tension bar
Component tensionLabel = Component.translatable(
"gui.tiedup.lockpick_minigame.label.tension"
).withStyle(ChatFormatting.GRAY);
graphics.drawString(
this.font,
tensionLabel,
centerX - 120,
y,
0xFFFFFFFF
);
y += 12;
renderTensionBar(graphics, y);
y += 35;
// Uses indicator
renderUsesIndicator(graphics, y);
y += 25;
// Instructions or result
if (gameComplete) {
Component result = gameSuccess
? Component.translatable(
"gui.tiedup.lockpick_minigame.status.unlocked"
).withStyle(ChatFormatting.GREEN, ChatFormatting.BOLD)
: Component.translatable(
"gui.tiedup.lockpick_minigame.status.out_of_picks"
).withStyle(ChatFormatting.RED, ChatFormatting.BOLD);
int resultWidth = this.font.width(result);
graphics.drawString(
this.font,
result,
centerX - resultWidth / 2,
y,
0xFFFFFFFF
);
} else {
Component hint = Component.translatable(
"gui.tiedup.lockpick_minigame.hint"
).withStyle(ChatFormatting.GRAY);
int hintWidth = this.font.width(hint);
graphics.drawString(
this.font,
hint,
centerX - hintWidth / 2,
y,
0xFFFFFFFF
);
}
}
/**
* Render the position bar (uniform gray - sweet spot hidden).
*/
private void renderPositionBar(GuiGraphics graphics, int y) {
int barWidth = 240;
int barHeight = 30;
int barX = (this.width - barWidth) / 2;
// Background - uniform dark gray (sweet spot is INVISIBLE)
graphics.fill(barX, y, barX + barWidth, y + barHeight, 0xFF333333);
// Border
graphics.renderOutline(barX, y, barWidth, barHeight, 0xFF666666);
// Lockpick indicator (position marker)
int pickX = barX + (int) (animatedPosition * barWidth);
int pickColor = 0xFFFFFF00; // Yellow
// Draw lockpick as a triangle pointing down
int pickWidth = 10;
int pickHeight = 14;
graphics.fill(
pickX - pickWidth / 2,
y - pickHeight,
pickX + pickWidth / 2,
y - 2,
pickColor
);
graphics.fill(pickX - 1, y - 2, pickX + 1, y + 4, pickColor);
// Draw pick position line
graphics.fill(pickX - 1, y, pickX + 1, y + barHeight, 0xAAFFFFFF);
}
/**
* Render the tension bar (animates during test).
*/
private void renderTensionBar(GuiGraphics graphics, int y) {
int barWidth = 240;
int barHeight = 20;
int barX = (this.width - barWidth) / 2;
// Background
graphics.fill(barX, y, barX + barWidth, y + barHeight, 0xFF222222);
// Fill based on current tension
if (tensionCurrent > 0) {
int fillWidth = (int) (barWidth * tensionCurrent);
graphics.fill(
barX,
y,
barX + fillWidth,
y + barHeight,
tensionColor
);
}
// Border
graphics.renderOutline(barX, y, barWidth, barHeight, 0xFF555555);
// Target line (100% marker)
int targetX = barX + barWidth - 2;
graphics.fill(targetX, y, targetX + 2, y + barHeight, 0xFFFFFFFF);
}
private void renderUsesIndicator(GuiGraphics graphics, int y) {
int centerX = this.width / 2;
// Draw use pips centered
int totalPipWidth = maxUses * 14;
int pipX = centerX - totalPipWidth / 2;
for (int i = 0; i < maxUses; i++) {
int pipColor = i < remainingUses ? 0xFF00AA00 : 0xFF333333;
graphics.fill(
pipX + i * 14,
y,
pipX + i * 14 + 10,
y + 10,
pipColor
);
graphics.renderOutline(pipX + i * 14, y, 10, 10, 0xFF666666);
}
}
@Override
public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
// ESC to cancel
if (keyCode == GLFW.GLFW_KEY_ESCAPE) {
this.onClose();
return true;
}
if (gameComplete || isTesting) {
return super.keyPressed(keyCode, scanCode, modifiers);
}
// Movement keys
if (keyCode == GLFW.GLFW_KEY_A || keyCode == GLFW.GLFW_KEY_LEFT) {
movingLeft = true;
return true;
}
if (keyCode == GLFW.GLFW_KEY_D || keyCode == GLFW.GLFW_KEY_RIGHT) {
movingRight = true;
return true;
}
// Test key
if (keyCode == GLFW.GLFW_KEY_SPACE) {
// Final sync before test
ModNetwork.sendToServer(
new PacketLockpickMiniGameMove(sessionId, currentPosition)
);
ModNetwork.sendToServer(new PacketLockpickAttempt(sessionId));
return true;
}
return super.keyPressed(keyCode, scanCode, modifiers);
}
@Override
public boolean keyReleased(int keyCode, int scanCode, int modifiers) {
if (keyCode == GLFW.GLFW_KEY_A || keyCode == GLFW.GLFW_KEY_LEFT) {
movingLeft = false;
return true;
}
if (keyCode == GLFW.GLFW_KEY_D || keyCode == GLFW.GLFW_KEY_RIGHT) {
movingRight = false;
return true;
}
return super.keyReleased(keyCode, scanCode, modifiers);
}
@Override
public boolean isPauseScreen() {
return false;
}
private float lerp(float current, float target, float speed) {
if (Math.abs(current - target) < 0.001f) {
return target;
}
return current + (target - current) * speed;
}
}

View File

@@ -0,0 +1,426 @@
package com.tiedup.remake.client.gui.screens;
import com.tiedup.remake.client.gui.util.GuiColors;
import com.tiedup.remake.client.gui.util.GuiLayoutConstants;
import com.tiedup.remake.entities.MerchantTrade;
import com.tiedup.remake.network.ModNetwork;
import com.tiedup.remake.network.merchant.PacketPurchaseTrade;
import java.util.List;
import java.util.UUID;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.components.Button;
import net.minecraft.network.chat.Component;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.Items;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
/**
* Trading screen for EntityKidnapperMerchant.
* Displays available trades and allows purchasing items for gold.
*/
@OnlyIn(Dist.CLIENT)
public class MerchantTradingScreen extends BaseScreen {
private final UUID merchantUUID;
private final List<MerchantTrade> trades;
private int selectedTradeIndex = -1;
private Button buyButton;
private Button cancelButton;
private static final int TRADE_BUTTON_HEIGHT = 30;
private static final int TRADE_BUTTON_SPACING = 4;
// Scroll state
private int scrollOffset = 0;
private int maxScrollOffset = 0;
private int tradeListStartY;
private int tradeListHeight;
public MerchantTradingScreen(
UUID merchantUUID,
List<MerchantTrade> trades
) {
super(Component.translatable("gui.tiedup.merchant.title"));
this.merchantUUID = merchantUUID;
this.trades = trades;
}
@Override
protected int getPreferredWidth() {
// 60% width, min 320px, max 500px
return GuiLayoutConstants.getResponsiveWidth(
this.width,
0.6f,
320,
500
);
}
@Override
protected int getPreferredHeight() {
// 70% height, min 300px, max 450px
return GuiLayoutConstants.getResponsiveHeight(
this.height,
0.7f,
300,
450
);
}
@Override
protected void init() {
super.init();
// Gold count area (just below title)
int goldDisplayY = this.topPos + 25;
// Trade list area (minimal spacing - only 8px gap)
this.tradeListStartY = goldDisplayY + 18;
this.tradeListHeight = this.imageHeight - 80; // Maximum space for trades
// Calculate max scroll offset
int totalTradeListHeight =
trades.size() * (TRADE_BUTTON_HEIGHT + TRADE_BUTTON_SPACING);
this.maxScrollOffset = Math.max(
0,
totalTradeListHeight - tradeListHeight
);
// Create trade buttons
int tradeButtonWidth = this.imageWidth - 40;
int tradeButtonX = this.leftPos + 20;
for (int i = 0; i < trades.size(); i++) {
final int tradeIndex = i;
MerchantTrade trade = trades.get(i);
TradeButton button = new TradeButton(
tradeButtonX,
0,
tradeButtonWidth,
TRADE_BUTTON_HEIGHT,
trade,
tradeIndex,
btn -> onTradeClicked(tradeIndex)
);
this.addRenderableWidget(button);
}
// Bottom buttons
int buttonY =
this.topPos +
this.imageHeight -
GuiLayoutConstants.BUTTON_HEIGHT -
10;
int buttonSpacing = 10;
int totalButtonWidth =
GuiLayoutConstants.BUTTON_WIDTH_L * 2 + buttonSpacing;
int buttonStartX =
this.leftPos + (this.imageWidth - totalButtonWidth) / 2;
// Buy button (disabled by default)
buyButton = Button.builder(
Component.translatable("gui.tiedup.merchant.buy"),
b -> onBuyClicked()
)
.bounds(
buttonStartX,
buttonY,
GuiLayoutConstants.BUTTON_WIDTH_L,
GuiLayoutConstants.BUTTON_HEIGHT
)
.build();
buyButton.active = false;
this.addRenderableWidget(buyButton);
// Cancel button
cancelButton = Button.builder(
Component.translatable("gui.tiedup.cancel"),
b -> onClose()
)
.bounds(
buttonStartX +
GuiLayoutConstants.BUTTON_WIDTH_L +
buttonSpacing,
buttonY,
GuiLayoutConstants.BUTTON_WIDTH_L,
GuiLayoutConstants.BUTTON_HEIGHT
)
.build();
this.addRenderableWidget(cancelButton);
}
private void onTradeClicked(int index) {
selectedTradeIndex = index;
buyButton.active = true;
}
private void onBuyClicked() {
if (selectedTradeIndex >= 0 && selectedTradeIndex < trades.size()) {
ModNetwork.sendToServer(
new PacketPurchaseTrade(merchantUUID, selectedTradeIndex)
);
onClose();
}
}
@Override
public void onClose() {
// Notify server that we closed the trading screen
ModNetwork.sendToServer(
new com.tiedup.remake.network.merchant.PacketCloseMerchantScreen(
merchantUUID
)
);
super.onClose();
}
@Override
public boolean mouseScrolled(double mouseX, double mouseY, double delta) {
if (maxScrollOffset > 0) {
// Scroll by 20 pixels per tick
int scrollAmount = (int) (delta * 20);
scrollOffset = Math.max(
0,
Math.min(maxScrollOffset, scrollOffset - scrollAmount)
);
return true;
}
return super.mouseScrolled(mouseX, mouseY, delta);
}
@Override
public void render(
GuiGraphics graphics,
int mouseX,
int mouseY,
float partialTick
) {
super.render(graphics, mouseX, mouseY, partialTick);
// Title (blue color for merchant)
graphics.drawCenteredString(
this.font,
this.title,
this.leftPos + this.imageWidth / 2,
this.topPos + GuiLayoutConstants.MARGIN_M,
GuiColors.INFO
);
// Gold display (matches goldDisplayY from init)
Player player = Minecraft.getInstance().player;
if (player != null) {
int goldIngots = countItemInInventory(player, Items.GOLD_INGOT);
int goldNuggets = countItemInInventory(player, Items.GOLD_NUGGET);
Component goldText = Component.literal("Your Gold: ")
.append(
Component.literal(goldIngots + "x ").withStyle(style ->
style.withColor(0xFFFFD700)
)
)
.append(
Component.literal("").withStyle(style ->
style.withColor(0xFFFFD700)
)
)
.append(
Component.literal("+ " + goldNuggets + "x ").withStyle(
style -> style.withColor(0xFFFFA500)
)
)
.append(
Component.literal("").withStyle(style ->
style.withColor(0xFFFFA500)
)
);
graphics.drawCenteredString(
this.font,
goldText,
this.leftPos + this.imageWidth / 2,
this.topPos + 25,
GuiColors.TEXT_WHITE
);
}
// Update trade button positions based on scroll
updateTradeButtonPositions();
}
/**
* Update trade button positions based on scroll offset.
*/
private void updateTradeButtonPositions() {
int tradeButtonWidth = this.imageWidth - 40;
int tradeButtonX = this.leftPos + 20;
// Get all renderable widgets (includes our trade buttons)
for (int i = 0; i < this.renderables.size(); i++) {
if (this.renderables.get(i) instanceof TradeButton tradeButton) {
int tradeIndex = tradeButton.getTradeIndex();
// Calculate Y position with scroll offset
// tradeListStartY already includes topPos, don't add it twice!
int y =
this.tradeListStartY +
tradeIndex * (TRADE_BUTTON_HEIGHT + TRADE_BUTTON_SPACING) -
scrollOffset;
// Update button position
tradeButton.setPosition(tradeButtonX, y);
// Check if button is visible in the scroll area
boolean isVisible =
y >= this.tradeListStartY &&
y + TRADE_BUTTON_HEIGHT <=
this.tradeListStartY + this.tradeListHeight;
tradeButton.visible = isVisible;
}
}
}
/**
* Count how many of a specific item are in the player's inventory.
*/
private int countItemInInventory(
Player player,
net.minecraft.world.item.Item item
) {
int count = 0;
for (net.minecraft.world.item.ItemStack stack : player.getInventory().items) {
if (stack.is(item)) {
count += stack.getCount();
}
}
return count;
}
/**
* Custom button for displaying a trade.
*/
private class TradeButton extends Button {
private final MerchantTrade trade;
private final int tradeIndex;
public TradeButton(
int x,
int y,
int width,
int height,
MerchantTrade trade,
int tradeIndex,
OnPress onPress
) {
super(
x,
y,
width,
height,
Component.empty(),
onPress,
DEFAULT_NARRATION
);
this.trade = trade;
this.tradeIndex = tradeIndex;
}
public int getTradeIndex() {
return this.tradeIndex;
}
@Override
public void renderWidget(
GuiGraphics graphics,
int mouseX,
int mouseY,
float partialTick
) {
boolean selected = (tradeIndex == selectedTradeIndex);
boolean hovered = this.isHovered();
// Background color
int bgColor;
if (selected) {
bgColor = GuiColors.SLOT_SELECTED; // Brown for selected
} else if (hovered) {
bgColor = GuiColors.SLOT_HOVER;
} else {
bgColor = GuiColors.BG_LIGHT;
}
graphics.fill(
getX(),
getY(),
getX() + width,
getY() + height,
bgColor
);
// Border
int borderColor = selected
? GuiColors.ACCENT_TAN
: GuiColors.BORDER_LIGHT;
graphics.fill(
getX(),
getY(),
getX() + width,
getY() + 1,
borderColor
);
graphics.fill(
getX(),
getY() + height - 1,
getX() + width,
getY() + height,
borderColor
);
graphics.fill(
getX(),
getY(),
getX() + 1,
getY() + height,
borderColor
);
graphics.fill(
getX() + width - 1,
getY(),
getX() + width,
getY() + height,
borderColor
);
// Item preview (left side)
net.minecraft.world.item.ItemStack itemStack = trade.getItem();
graphics.renderItem(itemStack, getX() + 5, getY() + 7);
// Item name (after item preview)
Component itemName = trade.getItemName();
String itemText = font.plainSubstrByWidth(
itemName.getString(),
width - 140
);
graphics.drawString(
font,
itemText,
getX() + 28,
getY() + 5,
GuiColors.TEXT_WHITE
);
// Price (after item name, below item preview)
Component priceText = trade.getPriceDisplay();
graphics.drawString(
font,
priceText,
getX() + 28,
getY() + 17,
GuiColors.TEXT_GRAY
);
}
}
}

View File

@@ -0,0 +1,222 @@
package com.tiedup.remake.client.gui.screens;
import com.tiedup.remake.client.gui.util.GuiTextureHelper;
import com.tiedup.remake.client.gui.widgets.EntityPreviewWidget;
import com.tiedup.remake.entities.EntityDamsel;
import com.tiedup.remake.entities.NpcInventoryMenu;
import org.jetbrains.annotations.Nullable;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen;
import net.minecraft.network.chat.Component;
import net.minecraft.world.entity.player.Inventory;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
/**
* Screen for viewing and managing NPC inventory.
* Uses vanilla chest texture for consistent Minecraft look.
*
* Layout:
* - Left: Vanilla chest-style container (176px wide)
* - Right: Equipment panel (armor + main hand, 26px wide)
*/
@OnlyIn(Dist.CLIENT)
public class NpcInventoryScreen
extends AbstractContainerScreen<NpcInventoryMenu>
{
/** Equipment panel width (slot 18px + 4px padding each side) */
private static final int EQUIP_PANEL_WIDTH = 28;
/** Gap between main container and equipment panel */
private static final int EQUIP_PANEL_GAP = 4;
/** Equipment panel height (5 slots + header + padding) */
private static final int EQUIP_PANEL_HEIGHT = 5 * 18 + 18; // 108px
/** Entity preview widget */
@Nullable
private EntityPreviewWidget preview;
/** Number of NPC inventory rows */
private final int npcRows;
/**
* Create NPC inventory screen.
*
* @param menu The container menu
* @param playerInventory Player's inventory
* @param title Screen title (will be replaced with NPC name)
*/
public NpcInventoryScreen(
NpcInventoryMenu menu,
Inventory playerInventory,
Component title
) {
super(menu, playerInventory, Component.literal(menu.getNpcName()));
// Calculate rows from NPC inventory size
this.npcRows = (menu.getNpcSlotCount() + 8) / 9;
// Standard vanilla chest dimensions
this.imageWidth = GuiTextureHelper.CHEST_WIDTH;
this.imageHeight = GuiTextureHelper.getChestHeight(npcRows);
// Player inventory label position (relative to container)
// In vanilla chest, player inv label is at y = header + rows*18 + 3
this.inventoryLabelY =
GuiTextureHelper.CHEST_HEADER_HEIGHT +
npcRows * GuiTextureHelper.SLOT_SIZE +
3;
}
@Override
protected void init() {
super.init();
// Add entity preview if NPC available and there's room
EntityDamsel npc = this.menu.getNpc();
if (npc != null) {
int previewSize = 50;
int previewX = this.leftPos - previewSize - 10;
int previewY = this.topPos + 10;
if (previewX > 0) {
preview = new EntityPreviewWidget(
previewX,
previewY,
previewSize,
previewSize,
npc
);
preview.setAutoRotate(true);
preview.setAutoRotateSpeed(0.5f);
this.addRenderableWidget(preview);
}
}
}
@Override
public void render(
GuiGraphics graphics,
int mouseX,
int mouseY,
float partialTick
) {
this.renderBackground(graphics);
super.render(graphics, mouseX, mouseY, partialTick);
this.renderTooltip(graphics, mouseX, mouseY);
}
@Override
protected void renderBg(
GuiGraphics graphics,
float partialTick,
int mouseX,
int mouseY
) {
// Render main container with vanilla chest texture
GuiTextureHelper.renderChestBackground(
graphics,
this.leftPos,
this.topPos,
this.npcRows
);
// Render equipment panel on the right
renderEquipmentPanel(graphics);
}
/**
* Render the equipment panel (armor + main hand) on the right side.
*/
private void renderEquipmentPanel(GuiGraphics graphics) {
int panelX =
this.leftPos + GuiTextureHelper.CHEST_WIDTH + EQUIP_PANEL_GAP;
int panelY = this.topPos + GuiTextureHelper.CHEST_HEADER_HEIGHT;
// Panel background (vanilla inventory style)
graphics.fill(
panelX - 1,
panelY - 1,
panelX + EQUIP_PANEL_WIDTH + 1,
panelY + EQUIP_PANEL_HEIGHT + 1,
0xFF000000
);
graphics.fill(
panelX,
panelY,
panelX + EQUIP_PANEL_WIDTH,
panelY + EQUIP_PANEL_HEIGHT,
0xFFC6C6C6
);
// Title centered
String title = "Gear";
int titleWidth = this.font.width(title);
graphics.drawString(
this.font,
title,
panelX + (EQUIP_PANEL_WIDTH - titleWidth) / 2,
panelY + 3,
0x404040,
false
);
// Slot backgrounds (4 armor + 1 main hand)
// Position slots to match menu: slotX = panelX + 5, first slot at y = panelY + 12
int slotX = panelX + 5;
int slotStartY = panelY + 12;
for (int i = 0; i < 4; i++) {
renderSlot(
graphics,
slotX,
slotStartY + i * GuiTextureHelper.SLOT_SIZE
);
}
// Main hand slot (with small gap after armor)
int handY = slotStartY + 4 * GuiTextureHelper.SLOT_SIZE + 4;
renderSlot(graphics, slotX, handY);
}
/**
* Render a single slot background in vanilla style.
*/
private void renderSlot(GuiGraphics graphics, int x, int y) {
// Vanilla slot style: dark top-left border, light bottom-right
graphics.fill(x, y, x + 18, y + 18, 0xFF8B8B8B); // Base gray
graphics.fill(x, y, x + 17, y + 1, 0xFF373737); // Top dark
graphics.fill(x, y, x + 1, y + 17, 0xFF373737); // Left dark
graphics.fill(x + 1, y + 17, x + 18, y + 18, 0xFFFFFFFF); // Bottom light
graphics.fill(x + 17, y + 1, x + 18, y + 18, 0xFFFFFFFF); // Right light
graphics.fill(x + 1, y + 1, x + 17, y + 17, 0xFF8B8B8B); // Inner
}
@Override
protected void renderLabels(GuiGraphics graphics, int mouseX, int mouseY) {
// NPC name in title area
graphics.drawString(
this.font,
this.title,
this.titleLabelX,
this.titleLabelY,
0x404040,
false
);
// Player inventory label
graphics.drawString(
this.font,
this.playerInventoryTitle,
this.inventoryLabelX,
this.inventoryLabelY,
0x404040,
false
);
}
@Override
public boolean isPauseScreen() {
return false;
}
}

View File

@@ -0,0 +1,232 @@
package com.tiedup.remake.client.gui.screens;
import com.tiedup.remake.dialogue.conversation.PetRequest;
import com.tiedup.remake.network.ModNetwork;
import com.tiedup.remake.network.master.PacketPetRequest;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.Tooltip;
import net.minecraft.network.chat.Component;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
/**
* Screen for pet players to make requests to their Master.
*
* Displays 7 request options:
* - Ask for food
* - Ask to rest
* - Request walk (you lead)
* - Request walk (Master leads)
* - Ask to be tied
* - Ask to be untied
* - End conversation
*
* Phase 2: Refactored to extend BaseInteractionScreen
*/
@OnlyIn(Dist.CLIENT)
public class PetRequestScreen extends BaseInteractionScreen {
private final int entityId;
private final String masterName;
// Layout constants
private static final int PANEL_WIDTH = 200;
private static final int PANEL_HEIGHT = 220;
private static final int MARGIN = 10;
private static final int BUTTON_HEIGHT = 20;
private static final int BUTTON_SPACING = 4;
// Color constants
private static final int COLOR_WHITE = 0xFFFFFF;
private static final int COLOR_TITLE = 0x8B008B; // Dark purple (Master color)
public PetRequestScreen(int entityId, String masterName) {
super(
Component.translatable("gui.tiedup.pet_request.title", masterName),
PANEL_WIDTH,
PANEL_HEIGHT
);
this.entityId = entityId;
this.masterName = masterName;
}
@Override
protected void init() {
super.init(); // Center the panel (sets leftPos and topPos)
int contentX = getContentX(MARGIN);
int contentWidth = getContentWidth(MARGIN);
int btnY = topPos + 35;
// === Request Buttons ===
// Row 1: Ask for food
addRequestButton(
contentX,
btnY,
contentWidth,
PetRequest.REQUEST_FOOD,
"gui.tiedup.pet_request.food",
"gui.tiedup.pet_request.food.tooltip"
);
btnY += BUTTON_HEIGHT + BUTTON_SPACING;
// Row 2: Ask to rest
addRequestButton(
contentX,
btnY,
contentWidth,
PetRequest.REQUEST_SLEEP,
"gui.tiedup.pet_request.sleep",
"gui.tiedup.pet_request.sleep.tooltip"
);
btnY += BUTTON_HEIGHT + BUTTON_SPACING;
// Row 3: Walk options (side by side)
int halfWidth = (contentWidth - BUTTON_SPACING) / 2;
// Walk (you lead)
Button walkPassiveBtn = Button.builder(
Component.translatable("gui.tiedup.pet_request.walk_passive"),
b -> sendRequest(PetRequest.REQUEST_WALK_PASSIVE)
)
.bounds(contentX, btnY, halfWidth, BUTTON_HEIGHT)
.tooltip(
Tooltip.create(
Component.translatable(
"gui.tiedup.pet_request.walk_passive.tooltip"
)
)
)
.build();
this.addRenderableWidget(walkPassiveBtn);
// Walk (Master leads)
Button walkActiveBtn = Button.builder(
Component.translatable("gui.tiedup.pet_request.walk_active"),
b -> sendRequest(PetRequest.REQUEST_WALK_ACTIVE)
)
.bounds(
contentX + halfWidth + BUTTON_SPACING,
btnY,
halfWidth,
BUTTON_HEIGHT
)
.tooltip(
Tooltip.create(
Component.translatable(
"gui.tiedup.pet_request.walk_active.tooltip"
)
)
)
.build();
this.addRenderableWidget(walkActiveBtn);
btnY += BUTTON_HEIGHT + BUTTON_SPACING;
// Row 4: Tie/Untie options (side by side)
Button tieBtn = Button.builder(
Component.translatable("gui.tiedup.pet_request.tie"),
b -> sendRequest(PetRequest.REQUEST_TIE)
)
.bounds(contentX, btnY, halfWidth, BUTTON_HEIGHT)
.tooltip(
Tooltip.create(
Component.translatable("gui.tiedup.pet_request.tie.tooltip")
)
)
.build();
this.addRenderableWidget(tieBtn);
Button untieBtn = Button.builder(
Component.translatable("gui.tiedup.pet_request.untie"),
b -> sendRequest(PetRequest.REQUEST_UNTIE)
)
.bounds(
contentX + halfWidth + BUTTON_SPACING,
btnY,
halfWidth,
BUTTON_HEIGHT
)
.tooltip(
Tooltip.create(
Component.translatable(
"gui.tiedup.pet_request.untie.tooltip"
)
)
)
.build();
this.addRenderableWidget(untieBtn);
btnY += BUTTON_HEIGHT + BUTTON_SPACING + 10;
// Row 5: End conversation
addRequestButton(
contentX,
btnY,
contentWidth,
PetRequest.END_CONVERSATION,
"gui.tiedup.pet_request.end",
"gui.tiedup.pet_request.end.tooltip"
);
btnY += BUTTON_HEIGHT + BUTTON_SPACING + 10;
// Row 6: Cancel button
Button cancelBtn = Button.builder(
Component.translatable("gui.tiedup.pet_request.cancel"),
b -> onClose()
)
.bounds(contentX, btnY, contentWidth, BUTTON_HEIGHT)
.build();
this.addRenderableWidget(cancelBtn);
}
/**
* Add a request button to the screen.
*/
private void addRequestButton(
int x,
int y,
int width,
PetRequest request,
String translationKey,
String tooltipKey
) {
Button btn = Button.builder(Component.translatable(translationKey), b ->
sendRequest(request)
)
.bounds(x, y, width, BUTTON_HEIGHT)
.tooltip(Tooltip.create(Component.translatable(tooltipKey)))
.build();
this.addRenderableWidget(btn);
}
@Override
protected void renderContent(
GuiGraphics graphics,
int mouseX,
int mouseY,
float partialTick
) {
// Title
renderTitle(graphics, this.title, topPos + 10, COLOR_TITLE);
// Subtitle
graphics.drawCenteredString(
this.font,
Component.translatable("gui.tiedup.pet_request.subtitle"),
leftPos + panelWidth / 2,
topPos + 22,
COLOR_WHITE
);
}
/**
* Send a request to the server.
*/
private void sendRequest(PetRequest request) {
ModNetwork.sendToServer(new PacketPetRequest(entityId, request));
// Close screen after sending request
onClose();
}
}

View File

@@ -0,0 +1,74 @@
package com.tiedup.remake.client.gui.screens;
import com.tiedup.remake.network.ModNetwork;
import com.tiedup.remake.network.item.PacketAdjustRemote;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.state.IBondageState;
import java.util.UUID;
import net.minecraft.network.chat.Component;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.item.ItemStack;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
/**
* Screen for remotely adjusting Y position of a slave's gags and blindfolds.
* Similar to AdjustmentScreen but operates on a slave entity.
*
* Phase 16b: GUI Refactoring - Simplified using BaseAdjustmentScreen
*/
@OnlyIn(Dist.CLIENT)
public class RemoteAdjustmentScreen extends BaseAdjustmentScreen {
// Target slave
private final IBondageState slave;
private final UUID slaveId;
public RemoteAdjustmentScreen(IBondageState slave, UUID slaveId) {
super(Component.translatable("gui.tiedup.adjust_position"));
this.slave = slave;
this.slaveId = slaveId;
}
// ==================== ABSTRACT IMPLEMENTATIONS ====================
@Override
protected LivingEntity getTargetEntity() {
return slave.asLivingEntity();
}
@Override
protected ItemStack getGag() {
return slave.getEquipment(BodyRegionV2.MOUTH);
}
@Override
protected ItemStack getBlindfold() {
return slave.getEquipment(BodyRegionV2.EYES);
}
@Override
protected void sendAdjustment(Mode mode, float value, float scale) {
BodyRegionV2 region = switch (mode) {
case GAG -> BodyRegionV2.MOUTH;
case BLINDFOLD -> BodyRegionV2.EYES;
case BOTH -> null; // Handled separately in applyAdjustment
};
if (region != null) {
ModNetwork.sendToServer(
new PacketAdjustRemote(slaveId, region, value, scale)
);
}
}
@Override
protected String getExtraInfo() {
return "Adjusting: " + slave.getKidnappedName();
}
@Override
public void onClose() {
super.onClose();
}
}

View File

@@ -0,0 +1,321 @@
package com.tiedup.remake.client.gui.screens;
import com.tiedup.remake.client.gui.util.GuiColors;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.client.gui.util.GuiLayoutConstants;
import com.tiedup.remake.client.gui.widgets.SlaveEntryWidget;
import com.tiedup.remake.items.base.ItemCollar;
import com.tiedup.remake.network.ModNetwork;
import com.tiedup.remake.network.slave.PacketSlaveAction;
import com.tiedup.remake.state.IBondageState;
import com.tiedup.remake.state.PlayerBindState;
import com.tiedup.remake.state.PlayerCaptorManager;
import com.tiedup.remake.util.KidnappedHelper;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.ContainerObjectSelectionList;
import net.minecraft.network.chat.Component;
import net.minecraft.world.entity.LivingEntity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.phys.AABB;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
/**
* Dashboard screen for masters to manage all their slaves.
* Refactored to use standard ContainerObjectSelectionList.
*/
@OnlyIn(Dist.CLIENT)
public class SlaveManagementScreen extends BaseScreen {
private SlaveList slaveList;
private Button closeButton;
public SlaveManagementScreen() {
super(Component.translatable("gui.tiedup.slave_management"));
}
@Override
protected int getPreferredWidth() {
return GuiLayoutConstants.getResponsiveWidth(
this.width,
0.7f,
350,
500
);
}
@Override
protected int getPreferredHeight() {
return GuiLayoutConstants.getResponsiveHeight(
this.height,
0.8f,
250,
400
);
}
@Override
protected void init() {
super.init();
int listLeft = this.leftPos + 10;
int listWidth = this.imageWidth - 20;
int listTop = this.topPos + GuiLayoutConstants.TITLE_HEIGHT + 20; // Title + count
int listBottom =
this.topPos +
this.imageHeight -
GuiLayoutConstants.BUTTON_HEIGHT -
10;
// Initialize the list with proper bounds
slaveList = new SlaveList(
minecraft,
listWidth,
listTop,
listBottom,
listLeft
);
refreshSlaveList();
this.addRenderableWidget(slaveList);
// Close button
int btnWidth = GuiLayoutConstants.BUTTON_WIDTH_XL;
closeButton = Button.builder(
Component.translatable("gui.tiedup.close"),
b -> onClose()
)
.bounds(
this.leftPos + (this.imageWidth - btnWidth) / 2,
this.topPos +
this.imageHeight -
GuiLayoutConstants.BUTTON_HEIGHT -
6,
btnWidth,
GuiLayoutConstants.BUTTON_HEIGHT
)
.build();
this.addRenderableWidget(closeButton);
}
public static boolean shouldShow() {
Minecraft mc = Minecraft.getInstance();
return mc.player != null;
}
private void refreshSlaveList() {
slaveList.clearEntriesPublic();
if (this.minecraft == null || this.minecraft.player == null) return;
Player player = this.minecraft.player;
Set<UUID> addedUUIDs = new HashSet<>();
// 1. Add captives from PlayerCaptorManager
PlayerBindState state = PlayerBindState.getInstance(player);
if (state != null) {
PlayerCaptorManager manager = state.getCaptorManager();
if (manager != null) {
for (IBondageState captive : manager.getCaptives()) {
addSlaveEntry(captive);
LivingEntity entity = captive.asLivingEntity();
if (entity != null) {
addedUUIDs.add(entity.getUUID());
}
}
}
}
// 2. Add nearby collar-linked entities (50-block radius matches server validation)
AABB searchBox = player.getBoundingBox().inflate(50);
for (LivingEntity entity : player
.level()
.getEntitiesOfClass(LivingEntity.class, searchBox)) {
if (entity == player) continue;
if (addedUUIDs.contains(entity.getUUID())) continue;
IBondageState kidnapped = KidnappedHelper.getKidnappedState(entity);
if (kidnapped != null && kidnapped.hasCollar()) {
ItemStack collarStack = kidnapped.getEquipment(BodyRegionV2.NECK);
if (collarStack.getItem() instanceof ItemCollar collar) {
if (collar.isOwner(collarStack, player)) {
addSlaveEntry(kidnapped);
addedUUIDs.add(entity.getUUID());
}
}
}
}
}
private void addSlaveEntry(IBondageState slave) {
slaveList.addEntryPublic(
new SlaveEntryWidget(
slave,
this::onAdjustClicked,
this::onShockClicked,
this::onLocateClicked,
this::onFreeClicked
)
);
}
// ==================== ACTIONS ====================
private void onAdjustClicked(IBondageState slave) {
LivingEntity entity = slave.asLivingEntity();
if (entity != null) {
this.minecraft.setScreen(
new RemoteAdjustmentScreen(slave, entity.getUUID())
);
}
}
private void onShockClicked(IBondageState slave) {
LivingEntity entity = slave.asLivingEntity();
if (entity != null) {
ModNetwork.sendToServer(
new PacketSlaveAction(
entity.getUUID(),
PacketSlaveAction.Action.SHOCK
)
);
}
}
private void onLocateClicked(IBondageState slave) {
LivingEntity entity = slave.asLivingEntity();
if (entity != null) {
ModNetwork.sendToServer(
new PacketSlaveAction(
entity.getUUID(),
PacketSlaveAction.Action.LOCATE
)
);
}
}
private void onFreeClicked(IBondageState slave) {
LivingEntity entity = slave.asLivingEntity();
if (entity != null) {
ModNetwork.sendToServer(
new PacketSlaveAction(
entity.getUUID(),
PacketSlaveAction.Action.FREE
)
);
// Refresh list after a short delay or immediately (server sync latency might require delay)
// For now, immediate refresh to remove from UI
refreshSlaveList();
}
}
// ==================== RENDERING ====================
@Override
public void render(
GuiGraphics graphics,
int mouseX,
int mouseY,
float partialTick
) {
super.render(graphics, mouseX, mouseY, partialTick);
// Slave count text
String countText =
slaveList.children().size() +
" slave" +
(slaveList.children().size() != 1 ? "s" : "");
graphics.drawString(
this.font,
countText,
this.leftPos + GuiLayoutConstants.MARGIN_M,
this.topPos + GuiLayoutConstants.TITLE_HEIGHT + 2,
GuiColors.TEXT_GRAY
);
// Empty state
if (slaveList.children().isEmpty()) {
graphics.drawCenteredString(
this.font,
Component.translatable("gui.tiedup.slave_management.no_slaves"),
this.leftPos + this.imageWidth / 2,
this.topPos + this.imageHeight / 2,
GuiColors.TEXT_DISABLED
);
}
}
// ==================== INNER CLASS: LIST ====================
class SlaveList extends ContainerObjectSelectionList<SlaveEntryWidget> {
private final int listLeft;
private final int listWidth;
public SlaveList(
Minecraft mc,
int width,
int top,
int bottom,
int left
) {
super(
mc,
width,
bottom - top,
top,
bottom,
GuiLayoutConstants.ENTRY_HEIGHT
);
this.listLeft = left;
this.listWidth = width;
this.centerListVertically = false;
this.setRenderBackground(false);
this.setRenderTopAndBottom(false);
// Set horizontal bounds directly (x0/x1 are protected in AbstractSelectionList)
this.x0 = left;
this.x1 = left + width;
}
public void addEntryPublic(SlaveEntryWidget entry) {
this.addEntry(entry);
}
public void clearEntriesPublic() {
this.clearEntries();
}
@Override
public int getRowWidth() {
return this.listWidth - 12; // Leave space for scrollbar
}
@Override
protected int getScrollbarPosition() {
return this.listLeft + this.listWidth - 6;
}
@Override
public int getRowLeft() {
return this.listLeft;
}
}
public static boolean canOpen() {
Minecraft mc = Minecraft.getInstance();
if (mc.player == null) return false;
PlayerBindState state = PlayerBindState.getInstance(mc.player);
return (
state != null &&
state.getCaptorManager() != null &&
state.getCaptorManager().hasCaptives()
);
}
}

View File

@@ -0,0 +1,242 @@
package com.tiedup.remake.client.gui.screens;
import com.tiedup.remake.client.gui.util.GuiColors;
import com.tiedup.remake.client.gui.util.GuiLayoutConstants;
import com.tiedup.remake.core.TiedUpMod;
import com.tiedup.remake.network.ModNetwork;
import com.tiedup.remake.network.trader.PacketBuyCaptive;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import net.minecraft.ChatFormatting;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.client.gui.components.Button;
import net.minecraft.network.chat.Component;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
/**
* Trading screen for the SlaveTrader.
*
* Displays list of captives available for purchase and allows buying.
*
* Layout:
* ┌─────────────────────────────────────────┐
* │ SLAVE TRADER │
* │ │
* │ [Name] - [Price] [BUY] │
* │ [Name] - [Price] [BUY] │
* │ [Name] - [Price] [BUY] │
* │ │
* │ [Close] │
* └─────────────────────────────────────────┘
*/
@OnlyIn(Dist.CLIENT)
public class SlaveTraderScreen extends BaseScreen {
private final int traderEntityId;
private final String traderName;
private final List<CaptiveOffer> offers;
private static final int ROW_HEIGHT = 28;
private static final int BUY_BUTTON_WIDTH = 50;
private static final int BUY_BUTTON_HEIGHT = 18;
/**
* Data class representing a captive available for purchase.
*/
public static class CaptiveOffer {
public final UUID captiveId;
public final String captiveName;
public final String priceDescription;
public final int priceAmount;
public final String priceItemId;
public CaptiveOffer(
UUID captiveId,
String captiveName,
String priceDescription,
int priceAmount,
String priceItemId
) {
this.captiveId = captiveId;
this.captiveName = captiveName;
this.priceDescription = priceDescription;
this.priceAmount = priceAmount;
this.priceItemId = priceItemId;
}
}
public SlaveTraderScreen(
int traderEntityId,
String traderName,
List<CaptiveOffer> offers
) {
super(Component.translatable("gui.tiedup.slave_trader"));
this.traderEntityId = traderEntityId;
this.traderName = traderName;
this.offers = offers != null ? offers : new ArrayList<>();
}
@Override
protected int getPreferredWidth() {
return GuiLayoutConstants.getResponsiveWidth(
this.width,
0.5f,
320,
400
);
}
@Override
protected int getPreferredHeight() {
int offerHeight = Math.max(1, offers.size()) * ROW_HEIGHT;
int baseHeight =
GuiLayoutConstants.TITLE_HEIGHT +
50 +
GuiLayoutConstants.BUTTON_HEIGHT +
30;
return GuiLayoutConstants.getResponsiveHeight(
this.height,
0.8f,
180,
Math.min(400, baseHeight + offerHeight)
);
}
@Override
protected void init() {
super.init();
int contentTop = this.topPos + GuiLayoutConstants.TITLE_HEIGHT + 15;
int centerX = this.leftPos + this.imageWidth / 2;
// Add buy button for each offer
int y = contentTop;
for (int i = 0; i < offers.size(); i++) {
CaptiveOffer offer = offers.get(i);
int buttonX =
this.leftPos + this.imageWidth - BUY_BUTTON_WIDTH - 15;
int buttonY = y + (ROW_HEIGHT - BUY_BUTTON_HEIGHT) / 2;
final int index = i;
Button buyBtn = Button.builder(
Component.translatable("gui.tiedup.buy"),
b -> onBuy(index)
)
.bounds(buttonX, buttonY, BUY_BUTTON_WIDTH, BUY_BUTTON_HEIGHT)
.build();
this.addRenderableWidget(buyBtn);
y += ROW_HEIGHT;
}
// Close button at bottom
int closeY =
this.topPos +
this.imageHeight -
GuiLayoutConstants.BUTTON_HEIGHT -
10;
Button closeBtn = Button.builder(
Component.translatable("gui.tiedup.close"),
b -> onClose()
)
.bounds(centerX - 40, closeY, 80, GuiLayoutConstants.BUTTON_HEIGHT)
.build();
this.addRenderableWidget(closeBtn);
}
private void onBuy(int offerIndex) {
if (offerIndex < 0 || offerIndex >= offers.size()) {
return;
}
CaptiveOffer offer = offers.get(offerIndex);
TiedUpMod.LOGGER.info(
"[SlaveTraderScreen] Attempting to buy captive {} from trader {}",
offer.captiveId.toString().substring(0, 8),
traderEntityId
);
// Send buy request to server
ModNetwork.sendToServer(
new PacketBuyCaptive(traderEntityId, offer.captiveId)
);
// Close screen after purchase attempt
onClose();
}
@Override
public void render(
GuiGraphics graphics,
int mouseX,
int mouseY,
float partialTick
) {
super.render(graphics, mouseX, mouseY, partialTick);
int contentTop = this.topPos + GuiLayoutConstants.TITLE_HEIGHT + 15;
int leftX = this.leftPos + 15;
// Render trader name
graphics.drawString(
this.font,
traderName,
leftX,
this.topPos + GuiLayoutConstants.TITLE_HEIGHT + 2,
GuiColors.TEXT_WHITE
);
// Render offers
if (offers.isEmpty()) {
graphics.drawString(
this.font,
Component.translatable(
"gui.tiedup.no_captives_available"
).withStyle(ChatFormatting.GRAY),
leftX,
contentTop + 10,
GuiColors.TEXT_GRAY
);
} else {
int y = contentTop;
for (CaptiveOffer offer : offers) {
renderOfferRow(graphics, offer, leftX, y);
y += ROW_HEIGHT;
}
}
}
private void renderOfferRow(
GuiGraphics graphics,
CaptiveOffer offer,
int x,
int y
) {
// Captive name
graphics.drawString(
this.font,
offer.captiveName,
x,
y + 2,
GuiColors.TEXT_WHITE
);
// Price
graphics.drawString(
this.font,
offer.priceDescription,
x,
y + 14,
GuiColors.TEXT_GRAY
);
}
@Override
public boolean isPauseScreen() {
return false;
}
}

View File

@@ -0,0 +1,375 @@
package com.tiedup.remake.client.gui.screens;
import com.tiedup.remake.client.gui.util.GuiLayoutConstants;
import com.tiedup.remake.client.gui.util.GuiRenderUtil;
import com.tiedup.remake.client.gui.widgets.*;
import com.tiedup.remake.items.ItemKey;
import com.tiedup.remake.items.ModItems;
import com.tiedup.remake.network.ModNetwork;
import com.tiedup.remake.network.cell.PacketRequestCellList;
import com.tiedup.remake.network.slave.PacketMasterEquip;
import com.tiedup.remake.v2.BodyRegionV2;
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
import com.tiedup.remake.v2.bondage.network.PacketV2SelfEquip;
import java.util.UUID;
import net.minecraft.client.gui.GuiGraphics;
import net.minecraft.network.chat.Component;
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;
/**
* Unified bondage equipment screen replacing BondageInventoryScreen,
* SlaveItemManagementScreen, and StruggleChoiceScreen.
*/
@OnlyIn(Dist.CLIENT)
public class UnifiedBondageScreen extends BaseScreen {
// Visual theme colors (vanilla MC style)
private static final int TITLE_COLOR = 0xFF404040;
private static final int MODE_SELF_BG = 0xFF8B8B8B;
private static final int MODE_MASTER_BG = 0xFF707070;
// Full-body view for all tabs (zoom per-tab was too finicky to get right)
private static final float[] TAB_SCALES = { 1.0f, 1.0f, 1.0f, 1.0f, 1.0f };
private static final float[] TAB_OFFSETS = { 0.0f, 0.0f, 0.0f, 0.0f, 0.0f };
private final ActionPanel.ScreenMode mode;
private final LivingEntity targetEntity;
private final UUID targetEntityUUID;
// Widgets
private RegionTabBar tabBar;
private EntityPreviewWidget preview;
private RegionSlotWidget[] currentSlots;
private ActionPanel actionPanel;
private ItemPickerOverlay pickerOverlay;
private StatusBarWidget statusBar;
// State
private RegionSlotWidget selectedSlot;
private int refreshCountdown = -1;
// Key info (for master mode)
private UUID keyUUID;
private boolean isMasterKey;
/**
* Open in SELF mode.
*/
public UnifiedBondageScreen() {
this(ActionPanel.ScreenMode.SELF, null);
}
/**
* Open in MASTER mode targeting a specific entity.
*/
public UnifiedBondageScreen(LivingEntity target) {
this(ActionPanel.ScreenMode.MASTER, target);
}
private UnifiedBondageScreen(ActionPanel.ScreenMode mode, LivingEntity target) {
super(Component.translatable("gui.tiedup.unified_bondage"));
this.mode = mode;
this.targetEntity = target;
this.targetEntityUUID = (target != null) ? target.getUUID() : null;
}
private LivingEntity getTarget() {
return (mode == ActionPanel.ScreenMode.SELF) ? minecraft.player : targetEntity;
}
@Override
protected int getPreferredWidth() {
return GuiLayoutConstants.getResponsiveWidth(this.width, 0.65f, 420, 600);
}
@Override
protected int getPreferredHeight() {
return GuiLayoutConstants.getResponsiveHeight(this.height, 0.75f, 350, 500);
}
@Override
protected void init() {
super.init();
// Resolve key info for master mode by iterating player inventory
if (mode == ActionPanel.ScreenMode.MASTER && minecraft.player != null) {
ItemStack keyStack = findFirstKey(minecraft.player);
if (!keyStack.isEmpty()) {
if (keyStack.getItem() instanceof ItemKey key) {
this.keyUUID = key.getKeyUUID(keyStack);
this.isMasterKey = false;
} else if (keyStack.is(ModItems.MASTER_KEY.get())) {
this.keyUUID = null;
this.isMasterKey = true;
}
}
}
int contentTop = topPos + GuiLayoutConstants.TITLE_HEIGHT + GuiLayoutConstants.MARGIN_M;
// === Tab Bar ===
tabBar = new RegionTabBar(leftPos + 2, contentTop, imageWidth - 4);
tabBar.setTargetEntity(getTarget());
tabBar.setOnTabChanged(this::onTabChanged);
addRenderableWidget(tabBar);
int belowTabs = contentTop + 30;
int statusBarHeight = 46;
int mainContentHeight = imageHeight - (belowTabs - topPos) - statusBarHeight - GuiLayoutConstants.MARGIN_S;
// === Preview (Left, 40%) ===
int previewWidth = (int)((imageWidth - GuiLayoutConstants.MARGIN_M * 3) * 0.40f);
LivingEntity target = getTarget();
if (target != null) {
preview = new EntityPreviewWidget(
leftPos + GuiLayoutConstants.MARGIN_M, belowTabs,
previewWidth, mainContentHeight, target
);
preview.setAutoRotate(true);
preview.setAutoRotateSpeed(0.3f);
preview.setZoomTarget(TAB_SCALES[0], TAB_OFFSETS[0]);
addRenderableWidget(preview);
}
// === Right panel area ===
int rightX = leftPos + GuiLayoutConstants.MARGIN_M + previewWidth + GuiLayoutConstants.MARGIN_M;
int rightWidth = imageWidth - (rightX - leftPos) - GuiLayoutConstants.MARGIN_M;
// Action panel height
int actionPanelHeight = 84;
int slotsHeight = mainContentHeight - actionPanelHeight - GuiLayoutConstants.MARGIN_S;
// === Region Slots ===
buildSlots(rightX, belowTabs, rightWidth, slotsHeight);
// === Action Panel ===
actionPanel = new ActionPanel(rightX, belowTabs + slotsHeight + GuiLayoutConstants.MARGIN_S,
rightWidth, actionPanelHeight);
actionPanel.setMode(mode);
actionPanel.setTargetEntity(getTarget());
actionPanel.setKeyInfo(keyUUID, isMasterKey);
actionPanel.setOnAdjustRequested(region -> {
if (AdjustmentScreen.canOpen()) minecraft.setScreen(new AdjustmentScreen());
});
actionPanel.setOnEquipRequested(this::openPicker);
actionPanel.setOnCellAssignRequested(() -> {
if (targetEntityUUID != null) ModNetwork.sendToServer(new PacketRequestCellList(targetEntityUUID));
});
actionPanel.setOnCloseRequested(this::onClose);
actionPanel.clearContext();
addRenderableWidget(actionPanel);
// === Status Bar ===
int statusY = topPos + imageHeight - statusBarHeight;
statusBar = new StatusBarWidget(leftPos, statusY, imageWidth, statusBarHeight);
statusBar.setMode(mode);
statusBar.setTargetEntity(getTarget());
statusBar.setOnCloseClicked(this::onClose);
addRenderableWidget(statusBar);
// === Picker Overlay (created but hidden) ===
pickerOverlay = new ItemPickerOverlay();
pickerOverlay.setOnItemSelected(this::onPickerItemSelected);
pickerOverlay.setOnCancelled(() -> {}); // No-op, just close
addRenderableWidget(pickerOverlay);
// Auto-select first occupied slot
autoSelectFirstOccupied();
}
/**
* Find the first key (ItemKey or ItemMasterKey) in the player's inventory.
* Regular ItemKey is preferred; falls back to master key if none found.
*/
private static ItemStack findFirstKey(Player player) {
ItemStack masterKeyStack = ItemStack.EMPTY;
for (int i = 0; i < player.getInventory().getContainerSize(); i++) {
ItemStack stack = player.getInventory().getItem(i);
if (stack.isEmpty()) continue;
if (stack.getItem() instanceof ItemKey) {
return stack; // Regular key takes priority
}
if (masterKeyStack.isEmpty() && stack.is(ModItems.MASTER_KEY.get())) {
masterKeyStack = stack; // Remember master key as fallback
}
}
return masterKeyStack; // Empty or master key
}
private void buildSlots(int x, int y, int width, int totalHeight) {
RegionTabBar.BodyTab tab = tabBar.getActiveTab();
BodyRegionV2[] regions = tab.getRegions().toArray(new BodyRegionV2[0]);
currentSlots = new RegionSlotWidget[regions.length];
int slotHeight = Math.min(52, (totalHeight - 4) / regions.length);
LivingEntity target = getTarget();
for (int i = 0; i < regions.length; i++) {
BodyRegionV2 region = regions[i];
int slotY = y + i * slotHeight;
RegionSlotWidget slot = new RegionSlotWidget(x, slotY, width, slotHeight - 2,
region, () -> target != null ? V2EquipmentHelper.getInRegion(target, region) : ItemStack.EMPTY);
slot.setOnClick(this::onSlotClicked);
slot.setShowEquipButton(true);
slot.setOnEquipClick(s -> openPicker(s.getRegion()));
// Adjust button for MOUTH/EYES
if (region == BodyRegionV2.MOUTH || region == BodyRegionV2.EYES) {
slot.setShowAdjustButton(true);
}
currentSlots[i] = slot;
addRenderableWidget(slot);
}
}
private void onTabChanged(RegionTabBar.BodyTab tab) {
// Update preview zoom
int tabIdx = tab.ordinal();
if (preview != null) {
preview.setZoomTarget(TAB_SCALES[tabIdx], TAB_OFFSETS[tabIdx]);
}
// Rebuild slots — clear old ones and re-init
if (currentSlots != null) {
for (RegionSlotWidget slot : currentSlots) {
removeWidget(slot);
}
}
selectedSlot = null;
actionPanel.clearContext();
// Recalculate layout for slots
int contentTop = topPos + GuiLayoutConstants.TITLE_HEIGHT + GuiLayoutConstants.MARGIN_M;
int belowTabs = contentTop + 30;
int statusBarHeight = 46;
int mainContentHeight = imageHeight - (belowTabs - topPos) - statusBarHeight - GuiLayoutConstants.MARGIN_S;
int previewWidth = (int)((imageWidth - GuiLayoutConstants.MARGIN_M * 3) * 0.40f);
int rightX = leftPos + GuiLayoutConstants.MARGIN_M + previewWidth + GuiLayoutConstants.MARGIN_M;
int rightWidth = imageWidth - (rightX - leftPos) - GuiLayoutConstants.MARGIN_M;
int actionPanelHeight = 84;
int slotsHeight = mainContentHeight - actionPanelHeight - GuiLayoutConstants.MARGIN_S;
buildSlots(rightX, belowTabs, rightWidth, slotsHeight);
autoSelectFirstOccupied();
}
private void onSlotClicked(RegionSlotWidget slot) {
// Deselect previous
if (selectedSlot != null) selectedSlot.setSelected(false);
// Select new
selectedSlot = slot;
slot.setSelected(true);
// Update action panel
actionPanel.setContext(slot.getRegion(), slot.getItem());
}
private void autoSelectFirstOccupied() {
if (currentSlots == null) return;
for (RegionSlotWidget slot : currentSlots) {
if (!slot.getItem().isEmpty()) {
onSlotClicked(slot);
return;
}
}
// No occupied slots — select first slot anyway for equip
if (currentSlots.length > 0) {
onSlotClicked(currentSlots[0]);
}
}
private void openPicker(BodyRegionV2 region) {
pickerOverlay.open(region, mode == ActionPanel.ScreenMode.SELF, this.width, this.height);
}
private void onPickerItemSelected(BodyRegionV2 region, int inventorySlot) {
if (mode == ActionPanel.ScreenMode.SELF) {
ModNetwork.sendToServer(new PacketV2SelfEquip(region, inventorySlot));
} else {
ModNetwork.sendToServer(new PacketMasterEquip(targetEntityUUID, region, inventorySlot));
}
refreshCountdown = 10; // Refresh after server processes
}
@Override
public void tick() {
super.tick();
if (preview != null) preview.tickZoom();
if (refreshCountdown > 0) {
refreshCountdown--;
} else if (refreshCountdown == 0) {
refreshCountdown = -1;
rebuildCurrentTab();
}
}
private void rebuildCurrentTab() {
onTabChanged(tabBar.getActiveTab());
}
@Override
public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) {
this.renderBackground(graphics);
// MC-style raised panel
GuiRenderUtil.drawMCPanel(graphics, leftPos, topPos, imageWidth, imageHeight);
// Title (dark text, vanilla style)
String titleText = this.title.getString();
if (mode == ActionPanel.ScreenMode.MASTER && targetEntity != null) {
titleText += " \u2014 " + targetEntity.getName().getString();
}
GuiRenderUtil.drawCenteredStringNoShadow(graphics, font, titleText,
leftPos + imageWidth / 2, topPos + GuiLayoutConstants.MARGIN_M, TITLE_COLOR);
// Mode badge (top-right) — sober gray badge
int badgeWidth = 90;
int badgeX = leftPos + imageWidth - badgeWidth - GuiLayoutConstants.MARGIN_M;
int badgeY = topPos + GuiLayoutConstants.MARGIN_S;
int badgeBg = mode == ActionPanel.ScreenMode.MASTER ? MODE_MASTER_BG : MODE_SELF_BG;
graphics.fill(badgeX, badgeY, badgeX + badgeWidth, badgeY + 16, badgeBg);
String badgeText = mode == ActionPanel.ScreenMode.MASTER
? Component.translatable("gui.tiedup.mode.master").getString()
: Component.translatable("gui.tiedup.mode.self").getString();
GuiRenderUtil.drawCenteredStringNoShadow(graphics, font, badgeText, badgeX + badgeWidth / 2, badgeY + 4, GuiRenderUtil.MC_TEXT_DARK);
// Render all widgets
super.render(graphics, mouseX, mouseY, partialTick);
// Picker overlay renders on top of everything
if (pickerOverlay.isOverlayVisible()) {
pickerOverlay.render(graphics, mouseX, mouseY, partialTick);
}
}
@Override
public boolean mouseClicked(double mouseX, double mouseY, int button) {
// Picker overlay captures all clicks when visible
if (pickerOverlay.isOverlayVisible()) {
return pickerOverlay.mouseClicked(mouseX, mouseY, button);
}
return super.mouseClicked(mouseX, mouseY, button);
}
@Override
public boolean keyPressed(int keyCode, int scanCode, int modifiers) {
if (pickerOverlay.isOverlayVisible()) {
return pickerOverlay.keyPressed(keyCode, scanCode, modifiers);
}
return super.keyPressed(keyCode, scanCode, modifiers);
}
@Override
public boolean mouseScrolled(double mouseX, double mouseY, double delta) {
if (pickerOverlay.isOverlayVisible()) {
return pickerOverlay.mouseScrolled(mouseX, mouseY, delta);
}
return super.mouseScrolled(mouseX, mouseY, delta);
}
}

View File

@@ -0,0 +1,154 @@
package com.tiedup.remake.client.gui.util;
import com.tiedup.remake.v2.BodyRegionV2;
/**
* Color constants for TiedUp! GUI elements.
* All colors are in ARGB format (0xAARRGGBB).
*
* Phase 16: GUI Revamp
*/
public class GuiColors {
// === Backgrounds ===
public static final int BG_DARK = 0xFF1A1A1A; // #1A1A1A
public static final int BG_MEDIUM = 0xFF2D2D2D; // #2D2D2D
public static final int BG_LIGHT = 0xFF3D3D3D; // #3D3D3D
// === Accents (leather/rope theme) ===
public static final int ACCENT_BROWN = 0xFF8B4513; // #8B4513 SaddleBrown
public static final int ACCENT_TAN = 0xFFCD853F; // #CD853F Peru
public static final int ACCENT_ROPE = 0xFFD2691E; // #D2691E Chocolate
// === Text ===
public static final int TEXT_WHITE = 0xFFFFFFFF;
public static final int TEXT_GRAY = 0xFFAAAAAA;
public static final int TEXT_DISABLED = 0xFF666666;
// === States ===
public static final int SUCCESS = 0xFF4CAF50; // Green
public static final int WARNING = 0xFFFF9800; // Orange
public static final int ERROR = 0xFFF44336; // Red
public static final int INFO = 0xFF2196F3; // Blue
// === Borders ===
public static final int BORDER_DARK = 0xFF0A0A0A;
public static final int BORDER_LIGHT = 0xFF4A4A4A;
// === Slot states ===
public static final int SLOT_EMPTY = 0xFF555555;
public static final int SLOT_FILLED = 0xFF666666;
public static final int SLOT_HOVER = 0xFF777777;
public static final int SLOT_SELECTED = 0xFF8B4513;
// === Bondage Type Colors ===
public static final int TYPE_BIND = 0xFF8B4513; // Brown (rope)
public static final int TYPE_GAG = 0xFFFF6B6B; // Red
public static final int TYPE_BLINDFOLD = 0xFF333333; // Dark gray
public static final int TYPE_EARPLUGS = 0xFFFFD93D; // Yellow
public static final int TYPE_COLLAR = 0xFF6BCB77; // Green
public static final int TYPE_CLOTHES = 0xFF4D96FF; // Blue
public static final int TYPE_MITTENS = 0xFFFF9F43; // Orange
// === Action Hover Colors ===
public static final int HOVER_REMOVE = 0xFF5D2020; // Dark red
public static final int HOVER_LOCK = 0xFF5D4520; // Dark orange
public static final int HOVER_UNLOCK = 0xFF205D20; // Dark green
public static final int BUTTON_REMOVE = 0xFFCC4444;
public static final int BUTTON_REMOVE_HOVER = 0xFFFF6B6B;
/**
* Get the color for a V2 body region.
*
* @param region The body region
* @return The corresponding color
*/
public static int getRegionColor(BodyRegionV2 region) {
return switch (region) {
case HEAD -> 0xFF9C27B0; // Purple
case EYES -> TYPE_BLINDFOLD;
case EARS -> TYPE_EARPLUGS;
case MOUTH -> TYPE_GAG;
case NECK -> TYPE_COLLAR;
case TORSO -> TYPE_CLOTHES;
case ARMS -> TYPE_BIND;
case HANDS -> TYPE_MITTENS;
case FINGERS -> 0xFFFFAB91; // Light orange
case WAIST -> 0xFF795548; // Brown
case LEGS -> 0xFF607D8B; // Blue-gray
case FEET -> 0xFF78909C; // Light blue-gray
case TAIL -> 0xFFCE93D8; // Light purple
case WINGS -> 0xFF80DEEA; // Light cyan
};
}
/**
* Get the color for a bondage item type.
*
* @param type The bondage item type name (lowercase)
* @return The corresponding color
*/
public static int getTypeColor(String type) {
return switch (type.toLowerCase()) {
case "bind" -> TYPE_BIND;
case "gag" -> TYPE_GAG;
case "blindfold" -> TYPE_BLINDFOLD;
case "earplugs" -> TYPE_EARPLUGS;
case "collar" -> TYPE_COLLAR;
case "clothes" -> TYPE_CLOTHES;
case "mittens" -> TYPE_MITTENS;
default -> TEXT_WHITE;
};
}
/**
* Create a color with custom alpha.
*
* @param color Base color (ARGB)
* @param alpha Alpha value (0-255)
* @return Color with new alpha
*/
public static int withAlpha(int color, int alpha) {
return (color & 0x00FFFFFF) | (alpha << 24);
}
/**
* Lighten a color by a factor.
*
* @param color Base color (ARGB)
* @param factor Factor (0.0 = no change, 1.0 = white)
* @return Lightened color
*/
public static int lighten(int color, float factor) {
int a = (color >> 24) & 0xFF;
int r = (color >> 16) & 0xFF;
int g = (color >> 8) & 0xFF;
int b = color & 0xFF;
r = (int) (r + (255 - r) * factor);
g = (int) (g + (255 - g) * factor);
b = (int) (b + (255 - b) * factor);
return (a << 24) | (r << 16) | (g << 8) | b;
}
/**
* Darken a color by a factor.
*
* @param color Base color (ARGB)
* @param factor Factor (0.0 = no change, 1.0 = black)
* @return Darkened color
*/
public static int darken(int color, float factor) {
int a = (color >> 24) & 0xFF;
int r = (color >> 16) & 0xFF;
int g = (color >> 8) & 0xFF;
int b = color & 0xFF;
r = (int) (r * (1 - factor));
g = (int) (g * (1 - factor));
b = (int) (b * (1 - factor));
return (a << 24) | (r << 16) | (g << 8) | b;
}
}

View File

@@ -0,0 +1,116 @@
package com.tiedup.remake.client.gui.util;
import net.minecraft.util.Mth;
/**
* Centralized layout constants for all TiedUp! GUI screens and widgets.
* Provides standard spacing and responsive calculation utilities.
*
* Refactored for Responsive Design.
*/
public final class GuiLayoutConstants {
private GuiLayoutConstants() {} // No instantiation
// ==================== MARGINS & PADDING ====================
public static final int MARGIN_XS = 2;
public static final int MARGIN_S = 4;
public static final int MARGIN_M = 8;
public static final int MARGIN_L = 16;
public static final int SCREEN_EDGE_MARGIN = 10;
public static final int LINE_HEIGHT = 10;
public static final int TITLE_HEIGHT = 20;
// ==================== WIDGET DIMENSIONS ====================
public static final int BUTTON_HEIGHT = 20;
public static final int BUTTON_WIDTH_S = 40;
public static final int BUTTON_WIDTH_M = 80;
public static final int BUTTON_WIDTH_L = 120;
public static final int BUTTON_WIDTH_XL = 160;
public static final int SLOT_SIZE = 24; // Standard square slot
public static final int SLOT_HEIGHT = 24;
public static final int SLOT_SPACING = 3;
public static final int ICON_SIZE = 16;
public static final int SCROLLBAR_WIDTH = 6;
// ==================== STATUS ICONS ====================
public static final int STATUS_ICON_SIZE = 14;
public static final int STATUS_ICON_SPACING = 4;
// ==================== ENTRY/LIST DIMENSIONS ====================
public static final int ENTRY_HEIGHT = 65;
public static final int ENTRY_SPACING = 4;
// ==================== PREVIEW SIZES ====================
public static final int PREVIEW_WIDTH_S = 50;
public static final int PREVIEW_WIDTH_M = 100;
public static final int PREVIEW_WIDTH_L = 120;
public static final int PREVIEW_HEIGHT = 160;
// Slider specific dimensions
public static final int SLIDER_THUMB_WIDTH = 8;
public static final int SLIDER_THUMB_HEIGHT = 20;
public static final int SLIDER_TRACK_WIDTH = 4;
// ==================== LAYOUT HELPERS ====================
/**
* Calculates a responsive width constrained by min/max values.
* @param screenWidth The current screen width
* @param percentTarget Target percentage of screen width (0.0 - 1.0)
* @param minWidth Minimum pixel width
* @param maxWidth Maximum pixel width
* @return Calculated width
*/
public static int getResponsiveWidth(
int screenWidth,
float percentTarget,
int minWidth,
int maxWidth
) {
int target = (int) (screenWidth * percentTarget);
int available = screenWidth - (SCREEN_EDGE_MARGIN * 2);
return Mth.clamp(target, minWidth, Math.min(maxWidth, available));
}
/**
* Calculates a responsive height constrained by min/max values.
* @param screenHeight The current screen height
* @param percentTarget Target percentage of screen height (0.0 - 1.0)
* @param minHeight Minimum pixel height
* @param maxHeight Maximum pixel height
* @return Calculated height
*/
public static int getResponsiveHeight(
int screenHeight,
float percentTarget,
int minHeight,
int maxHeight
) {
int target = (int) (screenHeight * percentTarget);
int available = screenHeight - (SCREEN_EDGE_MARGIN * 2);
return Mth.clamp(target, minHeight, Math.min(maxHeight, available));
}
/**
* Centers an element coordinate X.
*/
public static int centerX(int containerWidth, int elementWidth) {
return (containerWidth - elementWidth) / 2;
}
/**
* Centers an element coordinate Y.
*/
public static int centerY(int containerHeight, int elementHeight) {
return (containerHeight - elementHeight) / 2;
}
}

Some files were not shown because too many files have changed in this diff Show More