Refactor V2 animation, furniture, and GLTF rendering
Broad consolidation of the V2 bondage-item, furniture-entity, and
client-side GLTF pipeline.
Parsing and rendering
- Shared GLB parsing helpers consolidated into GlbParserUtils
(accessor reads, weight normalization, joint-index clamping,
coordinate-space conversion, animation parse, primitive loop).
- Grow-on-demand Matrix4f[] scratch pool in GltfSkinningEngine and
GltfLiveBoneReader — removes per-frame joint-matrix allocation
from the render hot path.
- emitVertex helper dedups three parallel loops in GltfMeshRenderer.
- TintColorResolver.resolve has a zero-alloc path when the item
declares no tint channels.
- itemAnimCache bounded to 256 entries (access-order LRU) with
atomic get-or-compute under the map's monitor.
Animation correctness
- First-in-joint-order wins when body and torso both map to the
same PlayerAnimator slot; duplicate writes log a single WARN.
- Multi-item composites honor the FullX / FullHeadX opt-in that
the single-item path already recognized.
- Seat transforms converted to Minecraft model-def space so
asymmetric furniture renders passengers at the correct offset.
- GlbValidator: IBM count / type / presence, JOINTS_0 presence,
animation channel target validation, multi-skin support.
Furniture correctness and anti-exploit
- Seat assignment synced via SynchedEntityData (server is
authoritative; eliminates client-server divergence on multi-seat).
- Force-mount authorization requires same dimension and a free
seat; cross-dimension distance checks rejected.
- Reconnection on login checks for seat takeover before re-mount
and force-loads the target chunk for cross-dimension cases.
- tiedup_furniture_lockpick_ctx carries a session UUID nonce so
stale context can't misroute a body-item lockpick.
- tiedup_locked_furniture survives death without keepInventory
(Forge 1.20.1 does not auto-copy persistent data on respawn).
Lifecycle and memory
- EntityCleanupHandler fans EntityLeaveLevelEvent out to every
per-entity state map on the client.
- DogPoseRenderHandler re-keyed by UUID (stable across dimension
change; entity int ids are recycled).
- PetBedRenderHandler, PlayerArmHideEventHandler, and
HeldItemHideHandler use receiveCanceled + sentinel sets so
Pre-time mutations are restored even when a downstream handler
cancels the render.
Tests
- JUnit harness with 76+ tests across GlbParserUtils, GltfPoseConverter,
FurnitureSeatGeometry, and FurnitureAuthPredicate.
This commit is contained in:
@@ -90,6 +90,23 @@ public class EntityFurniture
|
||||
EntityDataSerializers.BYTE
|
||||
);
|
||||
|
||||
/**
|
||||
* Passenger UUID → seat id, serialized as
|
||||
* {@code "uuid;seatId|uuid;seatId|..."} (empty string = no assignments).
|
||||
* Seat id itself must not contain {@code |} or {@code ;} — furniture
|
||||
* definitions use lowercase snake_case which is safe.
|
||||
* <p>
|
||||
* Server updates this string alongside every {@link #seatAssignments}
|
||||
* mutation so clients see the authoritative mapping. Without this, each
|
||||
* side independently ran {@code findNearestAvailableSeat} and could
|
||||
* diverge on multi-seat furniture (wrong render offset, wrong anim).
|
||||
*/
|
||||
private static final EntityDataAccessor<String> SEAT_ASSIGNMENTS_SYNC =
|
||||
SynchedEntityData.defineId(
|
||||
EntityFurniture.class,
|
||||
EntityDataSerializers.STRING
|
||||
);
|
||||
|
||||
// ========== Animation State Constants ==========
|
||||
|
||||
public static final byte STATE_IDLE = 0;
|
||||
@@ -140,6 +157,7 @@ public class EntityFurniture
|
||||
this.entityData.define(FURNITURE_ID, "");
|
||||
this.entityData.define(SEAT_LOCK_BITS, (byte) 0);
|
||||
this.entityData.define(ANIM_STATE, STATE_IDLE);
|
||||
this.entityData.define(SEAT_ASSIGNMENTS_SYNC, "");
|
||||
}
|
||||
|
||||
// ========== IEntityAdditionalSpawnData ==========
|
||||
@@ -205,11 +223,51 @@ public class EntityFurniture
|
||||
@Override
|
||||
public void assignSeat(Entity passenger, String seatId) {
|
||||
seatAssignments.put(passenger.getUUID(), seatId);
|
||||
syncSeatAssignmentsIfServer();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void releaseSeat(Entity passenger) {
|
||||
seatAssignments.remove(passenger.getUUID());
|
||||
syncSeatAssignmentsIfServer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize {@link #seatAssignments} into {@link #SEAT_ASSIGNMENTS_SYNC}
|
||||
* so tracking clients see the authoritative mapping. No-op on client.
|
||||
*/
|
||||
private void syncSeatAssignmentsIfServer() {
|
||||
if (this.level().isClientSide) return;
|
||||
StringBuilder sb = new StringBuilder(seatAssignments.size() * 40);
|
||||
boolean first = true;
|
||||
for (Map.Entry<UUID, String> entry : seatAssignments.entrySet()) {
|
||||
if (!first) sb.append('|');
|
||||
sb.append(entry.getKey()).append(';').append(entry.getValue());
|
||||
first = false;
|
||||
}
|
||||
this.entityData.set(SEAT_ASSIGNMENTS_SYNC, sb.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse {@link #SEAT_ASSIGNMENTS_SYNC} back into {@link #seatAssignments}.
|
||||
* Called on the client when the server's broadcast arrives. Malformed
|
||||
* entries (bad UUID, empty seat id) are skipped silently; we don't want
|
||||
* to throw on a packet from a future protocol version.
|
||||
*/
|
||||
private void applySyncedSeatAssignments(String serialized) {
|
||||
seatAssignments.clear();
|
||||
if (serialized.isEmpty()) return;
|
||||
for (String entry : serialized.split("\\|")) {
|
||||
int sep = entry.indexOf(';');
|
||||
if (sep <= 0 || sep == entry.length() - 1) continue;
|
||||
try {
|
||||
UUID uuid = UUID.fromString(entry.substring(0, sep));
|
||||
String seatId = entry.substring(sep + 1);
|
||||
seatAssignments.put(uuid, seatId);
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
// Corrupt UUID — skip this entry, preserve the rest.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -376,17 +434,14 @@ public class EntityFurniture
|
||||
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);
|
||||
Vec3 right = FurnitureSeatGeometry.rightAxis(this.getYRot());
|
||||
double offset = FurnitureSeatGeometry.seatOffset(seatIdx, seatCount);
|
||||
|
||||
moveFunction.accept(
|
||||
passenger,
|
||||
this.getX() + rightX * offset,
|
||||
this.getX() + right.x * offset,
|
||||
this.getY() + 0.5,
|
||||
this.getZ() + rightZ * offset
|
||||
this.getZ() + right.z * offset
|
||||
);
|
||||
}
|
||||
|
||||
@@ -399,9 +454,16 @@ public class EntityFurniture
|
||||
protected void addPassenger(Entity passenger) {
|
||||
super.addPassenger(passenger);
|
||||
|
||||
SeatDefinition nearest = findNearestAvailableSeat(passenger);
|
||||
if (nearest != null) {
|
||||
assignSeat(passenger, nearest.id());
|
||||
// Seat selection is server-authoritative. The client waits for the
|
||||
// SEAT_ASSIGNMENTS_SYNC broadcast (~1 tick later) before knowing
|
||||
// which seat this passenger is in. During that gap positionRider
|
||||
// falls back to entity center — 1 frame of imperceptible glitch
|
||||
// on mount, in exchange for deterministic multi-seat correctness.
|
||||
if (!this.level().isClientSide) {
|
||||
SeatDefinition nearest = findNearestAvailableSeat(passenger);
|
||||
if (nearest != null) {
|
||||
assignSeat(passenger, nearest.id());
|
||||
}
|
||||
}
|
||||
|
||||
// Play entering transition: furniture shows "Occupied" clip while player settles in.
|
||||
@@ -411,6 +473,125 @@ public class EntityFurniture
|
||||
this.transitionTicksLeft = 20;
|
||||
this.transitionTargetState = STATE_OCCUPIED;
|
||||
}
|
||||
|
||||
// Note: the previous client-side startFurnitureAnimationClient call is
|
||||
// no longer needed here — the animation is kicked off by
|
||||
// onSyncedDataUpdated when SEAT_ASSIGNMENTS_SYNC arrives (~1 tick
|
||||
// after mount). The cold-cache retry in AnimationTickHandler still
|
||||
// covers the case where the GLB hasn't parsed yet.
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-only: (re)start the seat pose animation for a player mounting this
|
||||
* furniture. Safe to call repeatedly — {@link BondageAnimationManager#playFurniture}
|
||||
* replaces any active animation. No-op if the GLB isn't loaded (cold cache),
|
||||
* the seat has no authored clip, or the animation context rejects the setup.
|
||||
*
|
||||
* <p>Called from three sites:</p>
|
||||
* <ul>
|
||||
* <li>{@link #addPassenger} on mount</li>
|
||||
* <li>{@link #onSyncedDataUpdated} when server-side {@code ANIM_STATE} changes</li>
|
||||
* <li>The per-tick retry in {@code AnimationTickHandler} (for cold-cache recovery)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Clip selection mirrors {@link com.tiedup.remake.v2.furniture.client.FurnitureEntityRenderer#resolveActiveAnimation}
|
||||
* for mesh/pose coherence. Fallback chain: state-specific clip → {@code "Occupied"}
|
||||
* → first available clip. This lets artists author optional state-specific poses
|
||||
* ({@code Entering}, {@code Exiting}, {@code Shake}) without requiring all of them.</p>
|
||||
*/
|
||||
public static void startFurnitureAnimationClient(
|
||||
EntityFurniture furniture,
|
||||
Player player
|
||||
) {
|
||||
if (!furniture.level().isClientSide) return;
|
||||
|
||||
SeatDefinition seat = furniture.getSeatForPassenger(player);
|
||||
if (seat == null) return;
|
||||
|
||||
FurnitureDefinition def = furniture.getDefinition();
|
||||
if (def == null) return;
|
||||
|
||||
com.tiedup.remake.v2.furniture.client.FurnitureGltfData gltfData =
|
||||
com.tiedup.remake.v2.furniture.client.FurnitureGltfCache.get(
|
||||
def.modelLocation()
|
||||
);
|
||||
if (gltfData == null) return;
|
||||
|
||||
Map<
|
||||
String,
|
||||
com.tiedup.remake.client.gltf.GltfData.AnimationClip
|
||||
> seatClips = gltfData.seatAnimations().get(seat.id());
|
||||
if (seatClips == null || seatClips.isEmpty()) return;
|
||||
|
||||
com.tiedup.remake.client.gltf.GltfData seatSkeleton =
|
||||
gltfData.seatSkeletons().get(seat.id());
|
||||
|
||||
// State-driven clip selection for the player seat armature. Names match
|
||||
// the ARTIST_GUIDE.md "Player Seat Animations" section so artists can
|
||||
// author matching clips. The fallback chain handles missing clips
|
||||
// (state-specific → "Occupied" → first available), so artists only need
|
||||
// to author what they want to customize.
|
||||
String stateClipName = switch (furniture.getAnimState()) {
|
||||
case STATE_OCCUPIED -> "Occupied";
|
||||
case STATE_STRUGGLE -> "Struggle";
|
||||
case STATE_ENTERING -> "Enter";
|
||||
case STATE_EXITING -> "Exit";
|
||||
case STATE_LOCKING -> "LockClose";
|
||||
case STATE_UNLOCKING -> "LockOpen";
|
||||
default -> "Idle";
|
||||
};
|
||||
|
||||
com.tiedup.remake.client.gltf.GltfData.AnimationClip clip =
|
||||
seatClips.get(stateClipName);
|
||||
if (clip == null) clip = seatClips.get("Occupied");
|
||||
if (clip == null) clip = seatClips.values().iterator().next();
|
||||
if (clip == null) return;
|
||||
|
||||
dev.kosmx.playerAnim.core.data.KeyframeAnimation anim =
|
||||
com.tiedup.remake.v2.furniture.client.FurnitureAnimationContext.create(
|
||||
clip,
|
||||
seatSkeleton,
|
||||
seat.blockedRegions()
|
||||
);
|
||||
if (anim != null) {
|
||||
com.tiedup.remake.client.animation.BondageAnimationManager.playFurniture(
|
||||
player,
|
||||
anim
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side: when the synched {@code ANIM_STATE} changes, re-play the seat
|
||||
* pose for each seated player so the authored state-specific clip kicks in.
|
||||
* Without this, a server-side transition (mount entering → occupied, lock
|
||||
* close, struggle start) never propagates to the player's pose.
|
||||
*/
|
||||
@Override
|
||||
public void onSyncedDataUpdated(
|
||||
net.minecraft.network.syncher.EntityDataAccessor<?> key
|
||||
) {
|
||||
super.onSyncedDataUpdated(key);
|
||||
if (!this.level().isClientSide) return;
|
||||
if (ANIM_STATE.equals(key)) {
|
||||
for (Entity passenger : this.getPassengers()) {
|
||||
if (passenger instanceof Player player) {
|
||||
startFurnitureAnimationClient(this, player);
|
||||
}
|
||||
}
|
||||
} else if (SEAT_ASSIGNMENTS_SYNC.equals(key)) {
|
||||
applySyncedSeatAssignments(
|
||||
this.entityData.get(SEAT_ASSIGNMENTS_SYNC)
|
||||
);
|
||||
// Re-play animations for passengers whose seat id just changed.
|
||||
// Without this the client could keep rendering a passenger with
|
||||
// the previous seat's blockedRegions until some other trigger.
|
||||
for (Entity passenger : this.getPassengers()) {
|
||||
if (passenger instanceof Player player) {
|
||||
startFurnitureAnimationClient(this, player);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -432,11 +613,7 @@ public class EntityFurniture
|
||||
|
||||
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);
|
||||
Vec3 right = FurnitureSeatGeometry.rightAxis(this.getYRot());
|
||||
|
||||
SeatDefinition best = null;
|
||||
double bestScore = Double.MAX_VALUE;
|
||||
@@ -452,11 +629,11 @@ public class EntityFurniture
|
||||
|
||||
// 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);
|
||||
double offset = FurnitureSeatGeometry.seatOffset(i, seatCount);
|
||||
Vec3 seatWorldPos = new Vec3(
|
||||
this.getX() + rightX * offset,
|
||||
this.getX() + right.x * offset,
|
||||
this.getY() + 0.5,
|
||||
this.getZ() + rightZ * offset
|
||||
this.getZ() + right.z * offset
|
||||
);
|
||||
|
||||
// Score: angle between passenger look direction and direction to seat.
|
||||
@@ -501,10 +678,7 @@ public class EntityFurniture
|
||||
) {
|
||||
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);
|
||||
Vec3 right = FurnitureSeatGeometry.rightAxis(this.getYRot());
|
||||
|
||||
SeatDefinition best = null;
|
||||
double bestScore = Double.MAX_VALUE;
|
||||
@@ -520,11 +694,11 @@ public class EntityFurniture
|
||||
!seat.lockable() || !seatAssignments.containsValue(seat.id())
|
||||
) continue;
|
||||
|
||||
double offset = seatCount == 1 ? 0.0 : (i - (seatCount - 1) / 2.0);
|
||||
double offset = FurnitureSeatGeometry.seatOffset(i, seatCount);
|
||||
Vec3 seatWorldPos = new Vec3(
|
||||
this.getX() + rightX * offset,
|
||||
this.getX() + right.x * offset,
|
||||
this.getY() + 0.5,
|
||||
this.getZ() + rightZ * offset
|
||||
this.getZ() + right.z * offset
|
||||
);
|
||||
|
||||
Vec3 toSeat = seatWorldPos.subtract(playerPos);
|
||||
@@ -698,27 +872,16 @@ public class EntityFurniture
|
||||
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
|
||||
);
|
||||
// Unified authorization via shared predicate.
|
||||
if (
|
||||
collarStack.isEmpty() ||
|
||||
!CollarHelper.isCollar(collarStack)
|
||||
) continue;
|
||||
if (
|
||||
!CollarHelper.isOwner(collarStack, serverPlayer) &&
|
||||
!serverPlayer.hasPermissions(2)
|
||||
) continue;
|
||||
!FurnitureAuthPredicate.canForceMount(
|
||||
serverPlayer,
|
||||
this,
|
||||
captiveEntity
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detach leash only (drop the lead, keep tied-up status)
|
||||
captive.free(false);
|
||||
@@ -761,13 +924,22 @@ public class EntityFurniture
|
||||
|
||||
// Priority 2: Key + occupied seat -> lock/unlock
|
||||
// Use look direction to pick the nearest occupied, lockable seat.
|
||||
// Authorization is enforced via FurnitureAuthPredicate so the in-world
|
||||
// path cannot drift from the packet path.
|
||||
ItemStack heldItem = player.getItemInHand(hand);
|
||||
if (isKeyItem(heldItem) && !this.getPassengers().isEmpty()) {
|
||||
if (
|
||||
isKeyItem(heldItem) &&
|
||||
!this.getPassengers().isEmpty() &&
|
||||
player instanceof ServerPlayer sp
|
||||
) {
|
||||
SeatDefinition targetSeat = findNearestOccupiedLockableSeat(
|
||||
player,
|
||||
def
|
||||
);
|
||||
if (targetSeat != null) {
|
||||
if (
|
||||
targetSeat != null &&
|
||||
FurnitureAuthPredicate.canLockUnlock(sp, this, targetSeat.id())
|
||||
) {
|
||||
boolean wasLocked = isSeatLocked(targetSeat.id());
|
||||
setSeatLocked(targetSeat.id(), !wasLocked);
|
||||
|
||||
@@ -838,7 +1010,9 @@ public class EntityFurniture
|
||||
|
||||
/**
|
||||
* Check if the given item is a key that can lock/unlock furniture seats.
|
||||
* Currently only {@link ItemMasterKey} qualifies.
|
||||
* Hardcoded to {@link ItemMasterKey}: the seat-lock mechanic is single-key
|
||||
* by design (no per-seat keys). A future second key item would need an
|
||||
* {@code ILockKey} interface; until then {@code instanceof} is cheapest.
|
||||
*/
|
||||
private boolean isKeyItem(ItemStack stack) {
|
||||
return !stack.isEmpty() && stack.getItem() instanceof ItemMasterKey;
|
||||
@@ -970,6 +1144,7 @@ public class EntityFurniture
|
||||
"[EntityFurniture] Cleaned up stale seat assignments on {}",
|
||||
getFurnitureId()
|
||||
);
|
||||
syncSeatAssignmentsIfServer();
|
||||
updateAnimState();
|
||||
}
|
||||
}
|
||||
@@ -1036,6 +1211,9 @@ public class EntityFurniture
|
||||
}
|
||||
}
|
||||
}
|
||||
// Push the restored map into the synced field so clients that start
|
||||
// tracking the entity after load get the right assignments too.
|
||||
syncSeatAssignmentsIfServer();
|
||||
|
||||
this.refreshDimensions();
|
||||
}
|
||||
@@ -1110,6 +1288,19 @@ public class EntityFurniture
|
||||
this.entityData.set(ANIM_STATE, state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a transient animation state that auto-reverts to {@code target}
|
||||
* after {@code ticks} ticks. Callers of transitional states
|
||||
* (LOCKING/UNLOCKING/ENTERING/EXITING) MUST use this rather than
|
||||
* {@link #setAnimState} so the tick-decrement path has the right
|
||||
* target to revert to.
|
||||
*/
|
||||
public void setTransitionState(byte state, int ticks, byte target) {
|
||||
this.entityData.set(ANIM_STATE, state);
|
||||
this.transitionTicksLeft = ticks;
|
||||
this.transitionTargetState = target;
|
||||
}
|
||||
|
||||
// ========== Entity Behavior Overrides ==========
|
||||
|
||||
/** Furniture can be targeted by the crosshair (for interaction and attack). */
|
||||
|
||||
Reference in New Issue
Block a user