D1: ThreadLocal alert suppression moved from ItemCollar to CollarHelper.
onCollarRemoved() logic (kidnapper alert) moved to CollarHelper.
D2+D3: Deleted 17 V1 item classes + 4 V1-only interfaces:
ItemBind, ItemGag, ItemBlindfold, ItemCollar, ItemEarplugs, ItemMittens,
ItemColor, ItemClassicCollar, ItemShockCollar, ItemShockCollarAuto,
ItemGpsCollar, ItemChokeCollar, ItemHood, ItemMedicalGag,
IBondageItem, IHasGaggingEffect, IHasBlindingEffect, IAdjustable
D4: KidnapperTheme/KidnapperItemSelector/DispenserBehaviors migrated
from variant enums to string-based DataDrivenItemRegistry IDs.
D5: Deleted 11 variant enums + Generic* factories + ItemBallGag3D:
BindVariant, GagVariant, BlindfoldVariant, EarplugsVariant, MittensVariant,
GenericBind, GenericGag, GenericBlindfold, GenericEarplugs, GenericMittens
D6: ModItems cleaned — all V1 bondage registrations removed.
D7: ModCreativeTabs rewritten — iterates DataDrivenItemRegistry.
D8+D9: All V2 helpers cleaned (V1 fallbacks removed), orphan imports removed.
Zero V1 bondage code references remain (only Javadoc comments).
All bondage items are now data-driven via 47 JSON definitions.
1155 lines
43 KiB
Java
1155 lines
43 KiB
Java
package com.tiedup.remake.v2.furniture;
|
|
|
|
import com.tiedup.remake.core.TiedUpMod;
|
|
import com.tiedup.remake.items.ItemMasterKey;
|
|
import com.tiedup.remake.v2.bondage.CollarHelper;
|
|
import com.tiedup.remake.state.IBondageState;
|
|
import com.tiedup.remake.state.PlayerBindState;
|
|
import com.tiedup.remake.state.PlayerCaptorManager;
|
|
import com.tiedup.remake.v2.BodyRegionV2;
|
|
import com.tiedup.remake.v2.furniture.network.PacketSyncFurnitureState;
|
|
import java.util.Collections;
|
|
import java.util.HashMap;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import java.util.Set;
|
|
import java.util.UUID;
|
|
import net.minecraft.core.BlockPos;
|
|
import net.minecraft.nbt.CompoundTag;
|
|
import net.minecraft.network.FriendlyByteBuf;
|
|
import net.minecraft.network.protocol.Packet;
|
|
import net.minecraft.network.protocol.game.ClientGamePacketListener;
|
|
import net.minecraft.network.syncher.EntityDataAccessor;
|
|
import net.minecraft.network.syncher.EntityDataSerializers;
|
|
import net.minecraft.network.syncher.SynchedEntityData;
|
|
import net.minecraft.resources.ResourceLocation;
|
|
import net.minecraft.server.level.ServerPlayer;
|
|
import net.minecraft.sounds.SoundEvent;
|
|
import net.minecraft.sounds.SoundSource;
|
|
import net.minecraft.world.InteractionHand;
|
|
import net.minecraft.world.InteractionResult;
|
|
import net.minecraft.world.entity.Entity;
|
|
import net.minecraft.world.entity.EntityDimensions;
|
|
import net.minecraft.world.entity.EntityType;
|
|
import net.minecraft.world.entity.LivingEntity;
|
|
import net.minecraft.world.entity.Pose;
|
|
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.entity.IEntityAdditionalSpawnData;
|
|
import net.minecraftforge.network.NetworkHooks;
|
|
import org.jetbrains.annotations.Nullable;
|
|
|
|
/**
|
|
* Core furniture entity for all data-driven furniture pieces.
|
|
*
|
|
* <p>Extends {@link Entity} (not LivingEntity) and implements {@link ISeatProvider}
|
|
* for seat management plus {@link IEntityAdditionalSpawnData} for syncing the
|
|
* furniture definition ID to clients on spawn.</p>
|
|
*
|
|
* <p>Behavior varies per instance based on the {@code tiedup_furniture_id} stored
|
|
* in SynchedEntityData, which maps to a {@link FurnitureDefinition} in
|
|
* {@link FurnitureRegistry}. All ISeatProvider calls delegate to the definition
|
|
* with null-safety: if the definition is removed by a data pack reload, the
|
|
* entity degrades gracefully (no seats, default hitbox, no interaction).</p>
|
|
*
|
|
* <p>{@code getAddEntityPacket()} is overridden to return
|
|
* {@link NetworkHooks#getEntitySpawningPacket(Entity)}, which is required for
|
|
* Forge to include the {@code IEntityAdditionalSpawnData} buffer in the spawn
|
|
* packet. Without this override, the entity would be invisible on clients.</p>
|
|
*/
|
|
public class EntityFurniture
|
|
extends Entity
|
|
implements ISeatProvider, IEntityAdditionalSpawnData
|
|
{
|
|
|
|
// ========== SynchedEntityData Accessors ==========
|
|
|
|
/** The furniture definition ID (ResourceLocation string form). */
|
|
private static final EntityDataAccessor<String> FURNITURE_ID =
|
|
SynchedEntityData.defineId(
|
|
EntityFurniture.class,
|
|
EntityDataSerializers.STRING
|
|
);
|
|
|
|
/**
|
|
* Bitmask of locked seats. Bit N corresponds to seat index N
|
|
* in {@link FurnitureDefinition#seats()}. Max 8 seats per furniture.
|
|
*/
|
|
private static final EntityDataAccessor<Byte> SEAT_LOCK_BITS =
|
|
SynchedEntityData.defineId(
|
|
EntityFurniture.class,
|
|
EntityDataSerializers.BYTE
|
|
);
|
|
|
|
/** Current animation state (idle, occupied, locking, struggle). */
|
|
private static final EntityDataAccessor<Byte> ANIM_STATE =
|
|
SynchedEntityData.defineId(
|
|
EntityFurniture.class,
|
|
EntityDataSerializers.BYTE
|
|
);
|
|
|
|
// ========== Animation State Constants ==========
|
|
|
|
public static final byte STATE_IDLE = 0;
|
|
public static final byte STATE_OCCUPIED = 1;
|
|
public static final byte STATE_LOCKING = 2;
|
|
public static final byte STATE_STRUGGLE = 3;
|
|
public static final byte STATE_UNLOCKING = 4;
|
|
public static final byte STATE_ENTERING = 5;
|
|
public static final byte STATE_EXITING = 6;
|
|
|
|
// ========== Server-side State ==========
|
|
|
|
/**
|
|
* Maps passenger UUID to their assigned seat ID. Server-authoritative.
|
|
* Persisted in NBT so seat assignments survive chunk unload/reload.
|
|
*/
|
|
private final Map<UUID, String> seatAssignments = new HashMap<>();
|
|
|
|
/**
|
|
* Accumulated damage from player attacks. Decays slowly per tick.
|
|
* When this reaches the definition's {@code breakResistance}, the furniture breaks.
|
|
*/
|
|
private float currentDamage = 0f;
|
|
|
|
/**
|
|
* Ticks remaining for a transitional animation state (ENTERING, EXITING, LOCKING, UNLOCKING).
|
|
* When this reaches 0, the animation state is reset to {@link #transitionTargetState}.
|
|
*/
|
|
private int transitionTicksLeft = 0;
|
|
|
|
/** The state to transition to after the current animation completes. */
|
|
private byte transitionTargetState = STATE_IDLE;
|
|
|
|
// ========== Constructor ==========
|
|
|
|
public EntityFurniture(EntityType<?> type, Level level) {
|
|
super(type, level);
|
|
// Prevent players from placing blocks inside the furniture's hitbox
|
|
this.blocksBuilding = true;
|
|
}
|
|
|
|
// ========== SynchedEntityData Registration ==========
|
|
|
|
@Override
|
|
protected void defineSynchedData() {
|
|
// Entity base class registers its own fields before calling this.
|
|
// Direct Entity subclasses do NOT call super (it is abstract).
|
|
this.entityData.define(FURNITURE_ID, "");
|
|
this.entityData.define(SEAT_LOCK_BITS, (byte) 0);
|
|
this.entityData.define(ANIM_STATE, STATE_IDLE);
|
|
}
|
|
|
|
// ========== IEntityAdditionalSpawnData ==========
|
|
|
|
/**
|
|
* Server-side: write furniture ID and facing to the spawn packet.
|
|
* This is the only reliable way to sync the definition ID to the client
|
|
* before the entity's first render frame.
|
|
*/
|
|
@Override
|
|
public void writeSpawnData(FriendlyByteBuf buffer) {
|
|
buffer.writeUtf(getFurnitureId());
|
|
buffer.writeFloat(this.getYRot());
|
|
}
|
|
|
|
/**
|
|
* Client-side: read furniture ID and facing from the spawn packet.
|
|
* Called before the entity is added to the client world.
|
|
*/
|
|
@Override
|
|
public void readSpawnData(FriendlyByteBuf additionalData) {
|
|
setFurnitureId(additionalData.readUtf());
|
|
this.setYRot(additionalData.readFloat());
|
|
// Recalculate bounding box with the correct definition dimensions
|
|
this.refreshDimensions();
|
|
}
|
|
|
|
// ========== Variable Dimensions ==========
|
|
|
|
/**
|
|
* Returns dimensions from the furniture definition, falling back to 1x1
|
|
* if the definition is missing (e.g., data pack removed the furniture type).
|
|
*/
|
|
@Override
|
|
public EntityDimensions getDimensions(Pose pose) {
|
|
FurnitureDefinition def = getDefinition();
|
|
if (def != null) {
|
|
return EntityDimensions.fixed(
|
|
def.hitboxWidth(),
|
|
def.hitboxHeight()
|
|
);
|
|
}
|
|
return EntityDimensions.fixed(1.0f, 1.0f);
|
|
}
|
|
|
|
// ========== ISeatProvider Implementation ==========
|
|
|
|
@Override
|
|
public List<SeatDefinition> getSeats() {
|
|
FurnitureDefinition def = getDefinition();
|
|
return def != null ? def.seats() : Collections.emptyList();
|
|
}
|
|
|
|
@Override
|
|
@Nullable
|
|
public SeatDefinition getSeatForPassenger(Entity passenger) {
|
|
String seatId = seatAssignments.get(passenger.getUUID());
|
|
if (seatId == null) return null;
|
|
FurnitureDefinition def = getDefinition();
|
|
return def != null ? def.getSeat(seatId) : null;
|
|
}
|
|
|
|
@Override
|
|
public void assignSeat(Entity passenger, String seatId) {
|
|
seatAssignments.put(passenger.getUUID(), seatId);
|
|
}
|
|
|
|
@Override
|
|
public void releaseSeat(Entity passenger) {
|
|
seatAssignments.remove(passenger.getUUID());
|
|
}
|
|
|
|
@Override
|
|
public boolean isSeatLocked(String seatId) {
|
|
FurnitureDefinition def = getDefinition();
|
|
if (def == null) return false;
|
|
int idx = def.getSeatIndex(seatId);
|
|
if (idx < 0) return false;
|
|
return (this.entityData.get(SEAT_LOCK_BITS) & (1 << idx)) != 0;
|
|
}
|
|
|
|
@Override
|
|
public void setSeatLocked(String seatId, boolean locked) {
|
|
FurnitureDefinition def = getDefinition();
|
|
if (def == null) return;
|
|
int idx = def.getSeatIndex(seatId);
|
|
if (idx < 0) return;
|
|
byte bits = this.entityData.get(SEAT_LOCK_BITS);
|
|
if (locked) {
|
|
bits |= (byte) (1 << idx);
|
|
} else {
|
|
bits &= (byte) ~(1 << idx);
|
|
}
|
|
this.entityData.set(SEAT_LOCK_BITS, bits);
|
|
|
|
// Update persistent data for reconnection system on the seated player
|
|
if (!this.level().isClientSide) {
|
|
Entity passenger = findPassengerInSeat(seatId);
|
|
if (passenger instanceof ServerPlayer serverPlayer) {
|
|
if (locked) {
|
|
writeFurnitureReconnectionTag(serverPlayer, seatId);
|
|
} else {
|
|
serverPlayer
|
|
.getPersistentData()
|
|
.remove("tiedup_locked_furniture");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Find the passenger entity sitting in a specific seat.
|
|
*
|
|
* @param seatId the seat identifier to search for
|
|
* @return the passenger entity, or null if the seat is unoccupied
|
|
*/
|
|
@Nullable
|
|
public Entity findPassengerInSeat(String seatId) {
|
|
for (Entity passenger : this.getPassengers()) {
|
|
String assignedSeat = seatAssignments.get(passenger.getUUID());
|
|
if (seatId.equals(assignedSeat)) {
|
|
return passenger;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Write the reconnection tag to a player's persistent data so they can be
|
|
* re-mounted on login if they disconnect while locked in a furniture seat.
|
|
*
|
|
* @param player the server player locked in this furniture
|
|
* @param seatId the seat ID the player is locked in
|
|
*/
|
|
private void writeFurnitureReconnectionTag(
|
|
ServerPlayer player,
|
|
String seatId
|
|
) {
|
|
CompoundTag tag = new CompoundTag();
|
|
BlockPos pos = this.blockPosition();
|
|
tag.putInt("x", pos.getX());
|
|
tag.putInt("y", pos.getY());
|
|
tag.putInt("z", pos.getZ());
|
|
tag.putString("dim", this.level().dimension().location().toString());
|
|
tag.putString("furniture_uuid", this.getStringUUID());
|
|
tag.putString("seat_id", seatId);
|
|
player.getPersistentData().put("tiedup_locked_furniture", tag);
|
|
}
|
|
|
|
@Override
|
|
public int getLockedDifficulty(String seatId) {
|
|
FurnitureDefinition def = getDefinition();
|
|
if (def == null) return 0;
|
|
SeatDefinition seat = def.getSeat(seatId);
|
|
return seat != null ? seat.lockedDifficulty() : 0;
|
|
}
|
|
|
|
@Override
|
|
public Set<BodyRegionV2> getBlockedRegions(String seatId) {
|
|
FurnitureDefinition def = getDefinition();
|
|
if (def == null) return Collections.emptySet();
|
|
SeatDefinition seat = def.getSeat(seatId);
|
|
return seat != null ? seat.blockedRegions() : Collections.emptySet();
|
|
}
|
|
|
|
@Override
|
|
public boolean hasItemDifficultyBonus(String seatId) {
|
|
FurnitureDefinition def = getDefinition();
|
|
if (def == null) return false;
|
|
SeatDefinition seat = def.getSeat(seatId);
|
|
return seat != null && seat.itemDifficultyBonus();
|
|
}
|
|
|
|
// ========== Vanilla Riding System ==========
|
|
|
|
@Override
|
|
protected boolean canAddPassenger(Entity passenger) {
|
|
FurnitureDefinition def = getDefinition();
|
|
return def != null && this.getPassengers().size() < def.seats().size();
|
|
}
|
|
|
|
/**
|
|
* Position the passenger at their assigned seat's world position.
|
|
*
|
|
* <p>On the client side, the exact seat transform is read from the parsed GLB
|
|
* data via {@code FurnitureSeatPositionHelper} (client-only). On the server
|
|
* side (or when GLB data is unavailable), an approximate position is computed
|
|
* by spacing seats evenly along the furniture's local right axis.</p>
|
|
*/
|
|
@Override
|
|
protected void positionRider(
|
|
Entity passenger,
|
|
Entity.MoveFunction moveFunction
|
|
) {
|
|
if (!this.hasPassenger(passenger)) return;
|
|
|
|
SeatDefinition seat = getSeatForPassenger(passenger);
|
|
if (seat == null) {
|
|
// Fallback: center of entity at base height
|
|
moveFunction.accept(
|
|
passenger,
|
|
this.getX(),
|
|
this.getY(),
|
|
this.getZ()
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Client-side: try to use real seat transforms parsed from the GLB model.
|
|
// FurnitureSeatPositionHelper is @OnlyIn(Dist.CLIENT) so we guard with isClientSide.
|
|
if (this.level().isClientSide) {
|
|
FurnitureDefinition def = getDefinition();
|
|
if (def != null) {
|
|
double[] pos =
|
|
com.tiedup.remake.v2.furniture.client.FurnitureSeatPositionHelper.getSeatWorldPosition(
|
|
def,
|
|
seat.id(),
|
|
this.getX(),
|
|
this.getY(),
|
|
this.getZ(),
|
|
this.getYRot()
|
|
);
|
|
if (pos != null) {
|
|
moveFunction.accept(passenger, pos[0], pos[1], pos[2]);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Server-side fallback (or missing GLB data): approximate positioning.
|
|
// Seats are spaced evenly along the entity's local right axis.
|
|
FurnitureDefinition def = getDefinition();
|
|
int seatCount = def != null ? def.seats().size() : 1;
|
|
int seatIdx = def != null ? def.getSeatIndex(seat.id()) : 0;
|
|
if (seatIdx < 0) seatIdx = 0;
|
|
|
|
float yawRad = (float) Math.toRadians(this.getYRot());
|
|
double rightX = -Math.sin(yawRad + Math.PI / 2.0);
|
|
double rightZ = Math.cos(yawRad + Math.PI / 2.0);
|
|
double offset =
|
|
seatCount == 1 ? 0.0 : (seatIdx - (seatCount - 1) / 2.0);
|
|
|
|
moveFunction.accept(
|
|
passenger,
|
|
this.getX() + rightX * offset,
|
|
this.getY() + 0.5,
|
|
this.getZ() + rightZ * offset
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Called after a passenger is added to the entity.
|
|
* Assigns the passenger to the nearest available seat based on the passenger's
|
|
* look direction, and updates animation state.
|
|
*/
|
|
@Override
|
|
protected void addPassenger(Entity passenger) {
|
|
super.addPassenger(passenger);
|
|
|
|
SeatDefinition nearest = findNearestAvailableSeat(passenger);
|
|
if (nearest != null) {
|
|
assignSeat(passenger, nearest.id());
|
|
}
|
|
|
|
// Play entering transition: furniture shows "Occupied" clip while player settles in.
|
|
// After 20 ticks (1 second), state stabilizes to OCCUPIED.
|
|
if (!this.level().isClientSide) {
|
|
this.entityData.set(ANIM_STATE, STATE_ENTERING);
|
|
this.transitionTicksLeft = 20;
|
|
this.transitionTargetState = STATE_OCCUPIED;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Find the available seat whose approximate world position has the smallest
|
|
* angle to the passenger's look direction.
|
|
*
|
|
* <p>Since we don't have GLB armature transforms yet (Task 14), seats are
|
|
* approximated as evenly spaced along the entity's local right axis.
|
|
* Seat 0 is at entity center, seat 1 is offset +1 block on the right, etc.
|
|
* The entity's Y rotation determines the right axis.</p>
|
|
*
|
|
* @param passenger the entity being seated
|
|
* @return the best available seat, or null if no seats are available
|
|
*/
|
|
@Nullable
|
|
private SeatDefinition findNearestAvailableSeat(Entity passenger) {
|
|
FurnitureDefinition def = getDefinition();
|
|
if (def == null || def.seats().isEmpty()) return null;
|
|
|
|
Vec3 passengerPos = passenger.getEyePosition();
|
|
Vec3 lookDir = passenger.getLookAngle();
|
|
float yawRad = (float) Math.toRadians(this.getYRot());
|
|
|
|
// Entity-local right axis (perpendicular to facing direction in the XZ plane)
|
|
double rightX = -Math.sin(yawRad + Math.PI / 2.0);
|
|
double rightZ = Math.cos(yawRad + Math.PI / 2.0);
|
|
|
|
SeatDefinition best = null;
|
|
double bestScore = Double.MAX_VALUE;
|
|
|
|
List<SeatDefinition> seats = def.seats();
|
|
int seatCount = seats.size();
|
|
|
|
for (int i = 0; i < seatCount; i++) {
|
|
SeatDefinition seat = seats.get(i);
|
|
|
|
// Skip already-occupied seats
|
|
if (seatAssignments.containsValue(seat.id())) continue;
|
|
|
|
// Approximate seat world position: entity origin + offset along right axis.
|
|
// For a single seat, it's at center. For multiple, spread evenly.
|
|
double offset = seatCount == 1 ? 0.0 : (i - (seatCount - 1) / 2.0);
|
|
Vec3 seatWorldPos = new Vec3(
|
|
this.getX() + rightX * offset,
|
|
this.getY() + 0.5,
|
|
this.getZ() + rightZ * offset
|
|
);
|
|
|
|
// Score: angle between passenger look direction and direction to seat.
|
|
// Use negative dot product of normalized vectors (smaller angle = lower score).
|
|
Vec3 toSeat = seatWorldPos.subtract(passengerPos);
|
|
double distSq = toSeat.lengthSqr();
|
|
if (distSq < 1e-6) {
|
|
// Passenger is essentially on top of the seat — best possible score
|
|
best = seat;
|
|
bestScore = -1.0;
|
|
continue;
|
|
}
|
|
|
|
Vec3 toSeatNorm = toSeat.normalize();
|
|
// Dot product: 1.0 = looking directly at seat, -1.0 = looking away.
|
|
// We want the highest dot product, so use negative as score.
|
|
double dot = lookDir.dot(toSeatNorm);
|
|
double score = -dot;
|
|
|
|
if (score < bestScore) {
|
|
bestScore = score;
|
|
best = seat;
|
|
}
|
|
}
|
|
|
|
return best;
|
|
}
|
|
|
|
/**
|
|
* Find the occupied, lockable seat whose approximate world position is
|
|
* closest to the player's look direction. Used for key interactions so
|
|
* that multi-seat furniture targets the seat the player is looking at.
|
|
*
|
|
* @param player the player performing the lock/unlock interaction
|
|
* @param def the furniture definition (must not be null)
|
|
* @return the best matching seat, or null if no occupied lockable seats exist
|
|
*/
|
|
@Nullable
|
|
private SeatDefinition findNearestOccupiedLockableSeat(
|
|
Player player,
|
|
FurnitureDefinition def
|
|
) {
|
|
Vec3 playerPos = player.getEyePosition();
|
|
Vec3 lookDir = player.getLookAngle();
|
|
float yawRad = (float) Math.toRadians(this.getYRot());
|
|
|
|
double rightX = -Math.sin(yawRad + Math.PI / 2.0);
|
|
double rightZ = Math.cos(yawRad + Math.PI / 2.0);
|
|
|
|
SeatDefinition best = null;
|
|
double bestScore = Double.MAX_VALUE;
|
|
|
|
List<SeatDefinition> seats = def.seats();
|
|
int seatCount = seats.size();
|
|
|
|
for (int i = 0; i < seatCount; i++) {
|
|
SeatDefinition seat = seats.get(i);
|
|
|
|
// Only consider occupied, lockable seats
|
|
if (
|
|
!seat.lockable() || !seatAssignments.containsValue(seat.id())
|
|
) continue;
|
|
|
|
double offset = seatCount == 1 ? 0.0 : (i - (seatCount - 1) / 2.0);
|
|
Vec3 seatWorldPos = new Vec3(
|
|
this.getX() + rightX * offset,
|
|
this.getY() + 0.5,
|
|
this.getZ() + rightZ * offset
|
|
);
|
|
|
|
Vec3 toSeat = seatWorldPos.subtract(playerPos);
|
|
double distSq = toSeat.lengthSqr();
|
|
if (distSq < 1e-6) {
|
|
best = seat;
|
|
bestScore = -1.0;
|
|
continue;
|
|
}
|
|
|
|
Vec3 toSeatNorm = toSeat.normalize();
|
|
double dot = lookDir.dot(toSeatNorm);
|
|
double score = -dot;
|
|
|
|
if (score < bestScore) {
|
|
bestScore = score;
|
|
best = seat;
|
|
}
|
|
}
|
|
|
|
return best;
|
|
}
|
|
|
|
/**
|
|
* Called when a passenger is being removed from the entity.
|
|
*
|
|
* <p>If the passenger's seat is locked and neither the entity nor passenger
|
|
* is being removed/killed, the dismount is cancelled by re-mounting the
|
|
* passenger on the next server tick via {@code getServer().execute()}.</p>
|
|
*
|
|
* <p>This deferred re-mount pattern is the same one used by
|
|
* {@link com.tiedup.remake.events.captivity.ForcedSeatingHandler} for forced seating.
|
|
* We cannot call {@code startRiding()} during the removePassenger call chain
|
|
* because the passenger list is being mutated.</p>
|
|
*/
|
|
@Override
|
|
protected void removePassenger(Entity passenger) {
|
|
SeatDefinition seat = getSeatForPassenger(passenger);
|
|
|
|
if (seat != null && isSeatLocked(seat.id()) && !this.isRemoved()) {
|
|
// Play denied sound to the passenger attempting to leave a locked seat
|
|
if (passenger instanceof ServerPlayer sp) {
|
|
FurnitureDefinition def = getDefinition();
|
|
if (def != null && def.feedback().deniedSound() != null) {
|
|
sp
|
|
.level()
|
|
.playSound(
|
|
null,
|
|
sp.getX(),
|
|
sp.getY(),
|
|
sp.getZ(),
|
|
SoundEvent.createVariableRangeEvent(
|
|
def.feedback().deniedSound()
|
|
),
|
|
SoundSource.PLAYERS,
|
|
0.5f,
|
|
1.0f
|
|
);
|
|
}
|
|
}
|
|
|
|
// Locked seat: prevent voluntary dismount by re-mounting next tick
|
|
super.removePassenger(passenger);
|
|
|
|
if (this.getServer() != null) {
|
|
String lockedSeatId = seat.id();
|
|
Entity self = this;
|
|
this.getServer().execute(() -> {
|
|
if (
|
|
passenger.isAlive() &&
|
|
self.isAlive() &&
|
|
!self.isRemoved()
|
|
) {
|
|
passenger.startRiding(self, true);
|
|
// Re-assign the specific seat (not just first available)
|
|
assignSeat(passenger, lockedSeatId);
|
|
} else {
|
|
// Passenger disconnected or entity removed — clean up the orphaned seat
|
|
releaseSeat(passenger);
|
|
updateAnimState();
|
|
}
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Normal dismount
|
|
super.removePassenger(passenger);
|
|
if (seat != null) {
|
|
releaseSeat(passenger);
|
|
}
|
|
// Clear reconnection tag on normal dismount
|
|
if (passenger instanceof ServerPlayer serverPlayer) {
|
|
serverPlayer.getPersistentData().remove("tiedup_locked_furniture");
|
|
}
|
|
|
|
// Play exiting transition: furniture transitions to Idle over 20 ticks.
|
|
// If other passengers remain, target state stays OCCUPIED.
|
|
if (!this.level().isClientSide) {
|
|
this.entityData.set(ANIM_STATE, STATE_EXITING);
|
|
this.transitionTicksLeft = 20;
|
|
this.transitionTargetState = this.getPassengers().isEmpty()
|
|
? STATE_IDLE
|
|
: STATE_OCCUPIED;
|
|
}
|
|
|
|
// Client-side: immediately stop the furniture pose animation on the dismounting player.
|
|
// BondageAnimationManager is @OnlyIn(Dist.CLIENT), so we guard with isClientSide.
|
|
// The safety tick in AnimationTickHandler also catches this after a 3-tick grace period,
|
|
// but stopping immediately avoids a visible animation glitch during dismount.
|
|
if (this.level().isClientSide && passenger instanceof Player) {
|
|
com.tiedup.remake.client.animation.BondageAnimationManager.stopFurniture(
|
|
(Player) passenger
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update the synched animation state based on passenger occupancy.
|
|
* More complex states (LOCKING, STRUGGLE, ENTERING, EXITING, UNLOCKING) are set
|
|
* explicitly by other systems. This method does NOT overwrite an active transition
|
|
* to avoid cutting short one-shot animations.
|
|
*/
|
|
private void updateAnimState() {
|
|
// Don't clobber active transitions — let the tick timer handle the reset
|
|
if (this.transitionTicksLeft > 0) return;
|
|
|
|
byte state = this.getPassengers().isEmpty()
|
|
? STATE_IDLE
|
|
: STATE_OCCUPIED;
|
|
this.entityData.set(ANIM_STATE, state);
|
|
}
|
|
|
|
// ========== Interaction ==========
|
|
|
|
/**
|
|
* Handle right-click interactions with the furniture.
|
|
*
|
|
* <p>Priority order:</p>
|
|
* <ol>
|
|
* <li>Force-mount a leashed captive (collar ownership required)</li>
|
|
* <li>Key item + occupied lockable seat: toggle lock</li>
|
|
* <li>Empty hand + available seat: self-mount</li>
|
|
* </ol>
|
|
*/
|
|
@Override
|
|
public InteractionResult interact(Player player, InteractionHand hand) {
|
|
if (this.level().isClientSide) {
|
|
return InteractionResult.SUCCESS;
|
|
}
|
|
|
|
FurnitureDefinition def = getDefinition();
|
|
if (def == null) {
|
|
return InteractionResult.PASS;
|
|
}
|
|
|
|
// Priority 1: Force-mount a leashed captive onto an available seat.
|
|
// The interacting player must be a captor with at least one leashed captive,
|
|
// and the captive must wear a collar owned by this player (or player is OP).
|
|
if (
|
|
player instanceof ServerPlayer serverPlayer &&
|
|
this.getPassengers().size() < def.seats().size()
|
|
) {
|
|
PlayerBindState ownerState = PlayerBindState.getInstance(
|
|
serverPlayer
|
|
);
|
|
if (ownerState != null) {
|
|
PlayerCaptorManager captorManager =
|
|
ownerState.getCaptorManager();
|
|
if (captorManager != null && captorManager.hasCaptives()) {
|
|
for (IBondageState captive : captorManager.getCaptives()) {
|
|
LivingEntity captiveEntity = captive.asLivingEntity();
|
|
|
|
// Skip captives that are already riding something
|
|
if (captiveEntity.isPassenger()) continue;
|
|
// Must be tied (leashed) and alive
|
|
if (!captive.isTiedUp()) continue;
|
|
if (!captiveEntity.isAlive()) continue;
|
|
// Must be within 5 blocks of the furniture
|
|
if (captiveEntity.distanceTo(this) > 5.0) continue;
|
|
|
|
// Verify collar ownership
|
|
if (!captive.hasCollar()) continue;
|
|
ItemStack collarStack = captive.getEquipment(
|
|
BodyRegionV2.NECK
|
|
);
|
|
if (
|
|
collarStack.isEmpty() ||
|
|
!CollarHelper.isCollar(collarStack)
|
|
) continue;
|
|
if (
|
|
!CollarHelper.isOwner(collarStack, serverPlayer) &&
|
|
!serverPlayer.hasPermissions(2)
|
|
) continue;
|
|
|
|
// Detach leash only (drop the lead, keep tied-up status)
|
|
captive.free(false);
|
|
|
|
// Force-mount: startRiding triggers addPassenger which assigns nearest seat
|
|
boolean mounted = captiveEntity.startRiding(this, true);
|
|
if (mounted) {
|
|
// Play mount sound
|
|
FurnitureFeedback feedback = def.feedback();
|
|
if (feedback.mountSound() != null) {
|
|
this.level().playSound(
|
|
null,
|
|
this.getX(),
|
|
this.getY(),
|
|
this.getZ(),
|
|
SoundEvent.createVariableRangeEvent(
|
|
feedback.mountSound()
|
|
),
|
|
SoundSource.BLOCKS,
|
|
1.0f,
|
|
1.0f
|
|
);
|
|
}
|
|
|
|
// Broadcast updated state to tracking clients
|
|
PacketSyncFurnitureState.sendToTracking(this);
|
|
|
|
TiedUpMod.LOGGER.debug(
|
|
"[EntityFurniture] {} force-mounted captive {} onto furniture {}",
|
|
serverPlayer.getName().getString(),
|
|
captiveEntity.getName().getString(),
|
|
getFurnitureId()
|
|
);
|
|
}
|
|
return InteractionResult.CONSUME;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Priority 2: Key + occupied seat -> lock/unlock
|
|
// Use look direction to pick the nearest occupied, lockable seat.
|
|
ItemStack heldItem = player.getItemInHand(hand);
|
|
if (isKeyItem(heldItem) && !this.getPassengers().isEmpty()) {
|
|
SeatDefinition targetSeat = findNearestOccupiedLockableSeat(
|
|
player,
|
|
def
|
|
);
|
|
if (targetSeat != null) {
|
|
boolean wasLocked = isSeatLocked(targetSeat.id());
|
|
setSeatLocked(targetSeat.id(), !wasLocked);
|
|
|
|
// Play lock/unlock sound
|
|
FurnitureFeedback feedback = def.feedback();
|
|
ResourceLocation soundRL = wasLocked
|
|
? feedback.unlockSound()
|
|
: feedback.lockSound();
|
|
if (soundRL != null) {
|
|
this.level().playSound(
|
|
null,
|
|
this.getX(),
|
|
this.getY(),
|
|
this.getZ(),
|
|
SoundEvent.createVariableRangeEvent(soundRL),
|
|
SoundSource.BLOCKS,
|
|
1.0f,
|
|
1.0f
|
|
);
|
|
}
|
|
|
|
// Set lock/unlock animation with transition timer
|
|
boolean nowLocked = !wasLocked;
|
|
this.entityData.set(
|
|
ANIM_STATE,
|
|
nowLocked ? STATE_LOCKING : STATE_UNLOCKING
|
|
);
|
|
this.transitionTicksLeft = 20;
|
|
this.transitionTargetState = STATE_OCCUPIED;
|
|
|
|
TiedUpMod.LOGGER.debug(
|
|
"[EntityFurniture] {} {} seat '{}' on furniture {}",
|
|
player.getName().getString(),
|
|
wasLocked ? "unlocked" : "locked",
|
|
targetSeat.id(),
|
|
getFurnitureId()
|
|
);
|
|
return InteractionResult.CONSUME;
|
|
}
|
|
}
|
|
|
|
// Priority 3: Empty hand + available seat -> self mount
|
|
if (
|
|
heldItem.isEmpty() &&
|
|
this.getPassengers().size() < def.seats().size()
|
|
) {
|
|
player.startRiding(this);
|
|
return InteractionResult.CONSUME;
|
|
}
|
|
|
|
// No valid action — play denied sound if configured
|
|
FurnitureFeedback feedback = def.feedback();
|
|
if (feedback != null && feedback.deniedSound() != null) {
|
|
this.level().playSound(
|
|
null,
|
|
this.getX(),
|
|
this.getY(),
|
|
this.getZ(),
|
|
SoundEvent.createVariableRangeEvent(feedback.deniedSound()),
|
|
SoundSource.BLOCKS,
|
|
0.5f,
|
|
1.0f
|
|
);
|
|
}
|
|
|
|
return InteractionResult.PASS;
|
|
}
|
|
|
|
/**
|
|
* Check if the given item is a key that can lock/unlock furniture seats.
|
|
* Currently only {@link ItemMasterKey} qualifies.
|
|
*/
|
|
private boolean isKeyItem(ItemStack stack) {
|
|
return !stack.isEmpty() && stack.getItem() instanceof ItemMasterKey;
|
|
}
|
|
|
|
// ========== Damage ==========
|
|
|
|
/**
|
|
* Handle left-click (attack) interactions.
|
|
*
|
|
* <p>Creative mode: instant discard. Survival mode: accumulate damage
|
|
* until it reaches the definition's {@code breakResistance}, then break
|
|
* the furniture, eject all passengers, and optionally drop an item.</p>
|
|
*
|
|
* @return true if the attack was consumed, false to pass to vanilla handling
|
|
*/
|
|
@Override
|
|
public boolean skipAttackInteraction(Entity attacker) {
|
|
if (this.level().isClientSide) {
|
|
return true;
|
|
}
|
|
|
|
if (attacker instanceof Player player) {
|
|
if (player.isCreative()) {
|
|
this.ejectPassengers();
|
|
this.discard();
|
|
return true;
|
|
}
|
|
|
|
// Cannot break occupied furniture in survival — must remove passengers first.
|
|
// Creative mode bypasses this check (handled above with instant discard).
|
|
if (!this.getPassengers().isEmpty()) {
|
|
FurnitureDefinition occupiedDef = getDefinition();
|
|
if (
|
|
occupiedDef != null &&
|
|
occupiedDef.feedback().deniedSound() != null
|
|
) {
|
|
this.level().playSound(
|
|
null,
|
|
this.getX(),
|
|
this.getY(),
|
|
this.getZ(),
|
|
SoundEvent.createVariableRangeEvent(
|
|
occupiedDef.feedback().deniedSound()
|
|
),
|
|
SoundSource.BLOCKS,
|
|
0.5f,
|
|
1.0f
|
|
);
|
|
}
|
|
return true; // Consume the attack but accumulate no damage
|
|
}
|
|
|
|
FurnitureDefinition def = getDefinition();
|
|
float resistance = def != null ? def.breakResistance() : 100f;
|
|
this.currentDamage += 1.0f;
|
|
|
|
if (this.currentDamage >= resistance) {
|
|
this.ejectPassengers();
|
|
if (def != null && def.dropOnBreak()) {
|
|
ItemStack dropStack = FurniturePlacerItem.createStack(
|
|
def.id()
|
|
);
|
|
if (!dropStack.isEmpty()) {
|
|
this.spawnAtLocation(dropStack);
|
|
}
|
|
}
|
|
this.discard();
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// ========== Tick ==========
|
|
|
|
/**
|
|
* Per-tick logic. Currently only handles damage decay on the server.
|
|
* Future tasks may add animation ticking or sound loops here.
|
|
*/
|
|
@Override
|
|
public void tick() {
|
|
super.tick();
|
|
|
|
if (!this.level().isClientSide) {
|
|
// Transition animation timer: count down and reset state when complete
|
|
if (this.transitionTicksLeft > 0) {
|
|
this.transitionTicksLeft--;
|
|
if (this.transitionTicksLeft == 0) {
|
|
this.entityData.set(ANIM_STATE, this.transitionTargetState);
|
|
}
|
|
}
|
|
|
|
// Damage decay: ~20 seconds to fully heal 1 point of damage
|
|
if (this.currentDamage > 0) {
|
|
this.currentDamage = Math.max(0f, this.currentDamage - 0.05f);
|
|
}
|
|
|
|
// Periodic cleanup: remove stale seat assignments for passengers
|
|
// that are no longer riding this entity (e.g., disconnected players).
|
|
if (this.tickCount % 100 == 0) {
|
|
cleanupStaleSeatAssignments();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove seat assignments whose UUID does not match any current passenger.
|
|
* This catches edge cases where a player disconnects or is removed without
|
|
* going through the normal removePassenger path (e.g., server crash recovery,
|
|
* /kick, or chunk unload races).
|
|
*/
|
|
private void cleanupStaleSeatAssignments() {
|
|
if (seatAssignments.isEmpty()) return;
|
|
|
|
Set<UUID> passengerUuids = new java.util.HashSet<>();
|
|
for (Entity passenger : this.getPassengers()) {
|
|
passengerUuids.add(passenger.getUUID());
|
|
}
|
|
|
|
boolean removed = seatAssignments
|
|
.keySet()
|
|
.removeIf(uuid -> !passengerUuids.contains(uuid));
|
|
|
|
if (removed) {
|
|
TiedUpMod.LOGGER.debug(
|
|
"[EntityFurniture] Cleaned up stale seat assignments on {}",
|
|
getFurnitureId()
|
|
);
|
|
updateAnimState();
|
|
}
|
|
}
|
|
|
|
// ========== NBT Persistence ==========
|
|
|
|
@Override
|
|
protected void addAdditionalSaveData(CompoundTag tag) {
|
|
tag.putString(FurnitureRegistry.NBT_FURNITURE_ID, getFurnitureId());
|
|
tag.putFloat("facing", this.getYRot());
|
|
tag.putByte("seat_locks", this.entityData.get(SEAT_LOCK_BITS));
|
|
tag.putFloat("current_damage", this.currentDamage);
|
|
|
|
// Persist transition state so animations don't get stuck after server restart
|
|
if (this.transitionTicksLeft > 0) {
|
|
tag.putInt("transition_ticks", this.transitionTicksLeft);
|
|
tag.putByte("transition_target", this.transitionTargetState);
|
|
}
|
|
|
|
// Persist seat assignments so locked passengers keep their seat after reload
|
|
CompoundTag assignments = new CompoundTag();
|
|
for (Map.Entry<UUID, String> entry : seatAssignments.entrySet()) {
|
|
assignments.putString(entry.getKey().toString(), entry.getValue());
|
|
}
|
|
tag.put("seat_assignments", assignments);
|
|
}
|
|
|
|
@Override
|
|
protected void readAdditionalSaveData(CompoundTag tag) {
|
|
if (tag.contains(FurnitureRegistry.NBT_FURNITURE_ID, 8)) {
|
|
// 8 = TAG_String
|
|
setFurnitureId(tag.getString(FurnitureRegistry.NBT_FURNITURE_ID));
|
|
}
|
|
if (tag.contains("facing")) {
|
|
this.setYRot(tag.getFloat("facing"));
|
|
}
|
|
if (tag.contains("seat_locks")) {
|
|
this.entityData.set(SEAT_LOCK_BITS, tag.getByte("seat_locks"));
|
|
}
|
|
if (tag.contains("current_damage")) {
|
|
this.currentDamage = tag.getFloat("current_damage");
|
|
}
|
|
|
|
// Restore transition state so in-progress animations resume after reload
|
|
if (tag.contains("transition_ticks")) {
|
|
this.transitionTicksLeft = tag.getInt("transition_ticks");
|
|
this.transitionTargetState = tag.getByte("transition_target");
|
|
}
|
|
|
|
// Restore seat assignments
|
|
seatAssignments.clear();
|
|
if (tag.contains("seat_assignments", 10)) {
|
|
// 10 = TAG_Compound
|
|
CompoundTag assignments = tag.getCompound("seat_assignments");
|
|
for (String key : assignments.getAllKeys()) {
|
|
try {
|
|
UUID uuid = UUID.fromString(key);
|
|
seatAssignments.put(uuid, assignments.getString(key));
|
|
} catch (IllegalArgumentException e) {
|
|
TiedUpMod.LOGGER.warn(
|
|
"[EntityFurniture] Skipping invalid UUID in seat assignments: {}",
|
|
key
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
this.refreshDimensions();
|
|
}
|
|
|
|
// ========== Getters / Setters ==========
|
|
|
|
/**
|
|
* Get the furniture definition ID string (ResourceLocation form).
|
|
* May be empty if the entity has not been initialized yet.
|
|
*/
|
|
public String getFurnitureId() {
|
|
return this.entityData.get(FURNITURE_ID);
|
|
}
|
|
|
|
/**
|
|
* Set the furniture definition ID. Triggers a dimension refresh so the
|
|
* bounding box updates to match the new definition's hitbox.
|
|
*/
|
|
public void setFurnitureId(String id) {
|
|
this.entityData.set(FURNITURE_ID, id != null ? id : "");
|
|
this.refreshDimensions();
|
|
}
|
|
|
|
/**
|
|
* Resolve the current furniture definition from the registry.
|
|
*
|
|
* @return the definition, or null if the ID is empty, unparseable,
|
|
* or no longer registered (e.g., data pack was removed)
|
|
*/
|
|
@Nullable
|
|
public FurnitureDefinition getDefinition() {
|
|
return FurnitureRegistry.get(getFurnitureId());
|
|
}
|
|
|
|
/**
|
|
* Get the raw seat lock bitmask. Bit N corresponds to seat index N in the
|
|
* furniture definition's seat list.
|
|
*
|
|
* @return the lock bitmask byte (0 = all unlocked)
|
|
*/
|
|
public byte getSeatLockBits() {
|
|
return this.entityData.get(SEAT_LOCK_BITS);
|
|
}
|
|
|
|
/**
|
|
* Set the raw seat lock bitmask directly. Used by the network sync packet
|
|
* ({@link com.tiedup.remake.v2.furniture.network.PacketSyncFurnitureState})
|
|
* to apply server-authoritative state on the client without per-seat iteration.
|
|
*
|
|
* <p>Server code should prefer {@link #setSeatLocked(String, boolean)} for
|
|
* individual seat lock changes, as it validates the seat ID against the
|
|
* furniture definition.</p>
|
|
*
|
|
* @param bits the raw lock bitmask
|
|
*/
|
|
public void setSeatLockBitsRaw(byte bits) {
|
|
this.entityData.set(SEAT_LOCK_BITS, bits);
|
|
}
|
|
|
|
/**
|
|
* Get the current animation state byte. Use the STATE_ constants to compare.
|
|
*/
|
|
public byte getAnimState() {
|
|
return this.entityData.get(ANIM_STATE);
|
|
}
|
|
|
|
/**
|
|
* Explicitly set the animation state. Used by external systems (e.g., struggle
|
|
* minigame) to signal visual state changes beyond simple occupancy.
|
|
*/
|
|
public void setAnimState(byte state) {
|
|
this.entityData.set(ANIM_STATE, state);
|
|
}
|
|
|
|
// ========== Entity Behavior Overrides ==========
|
|
|
|
/** Furniture can be targeted by the crosshair (for interaction and attack). */
|
|
@Override
|
|
public boolean isPickable() {
|
|
return true;
|
|
}
|
|
|
|
/** Furniture cannot be pushed by entities or pistons. */
|
|
@Override
|
|
public boolean isPushable() {
|
|
return false;
|
|
}
|
|
|
|
/** Furniture has solid collision (entities cannot walk through it). */
|
|
@Override
|
|
public boolean canBeCollidedWith() {
|
|
return true;
|
|
}
|
|
|
|
/** Furniture does not emit movement sounds or events. */
|
|
@Override
|
|
protected Entity.MovementEmission getMovementEmission() {
|
|
return Entity.MovementEmission.NONE;
|
|
}
|
|
|
|
// ========== Spawn Packet ==========
|
|
|
|
/**
|
|
* Return the Forge-aware spawn packet so that {@link IEntityAdditionalSpawnData}
|
|
* fields ({@code writeSpawnData}/{@code readSpawnData}) are included.
|
|
*
|
|
* <p>This override is required per the Forge Community Wiki. Without it,
|
|
* Forge sends a vanilla {@code ClientboundAddEntityPacket} which does NOT
|
|
* include the additional spawn buffer, causing the entity to be invisible
|
|
* or have missing data on the client.</p>
|
|
*/
|
|
@Override
|
|
public Packet<ClientGamePacketListener> getAddEntityPacket() {
|
|
return NetworkHooks.getEntitySpawningPacket(this);
|
|
}
|
|
}
|