Uses ConcurrentHashMap for safe access from both client tick and render thread.
*/
private static final Map 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).
*
* 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 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 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 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 getLayer(LivingEntity entity) {
// Players: try PlayerAnimationAccess first, then cache
if (entity instanceof AbstractClientPlayer player) {
ModifierLayer 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 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 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 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 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 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.
*
* 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 getPlayerLayerSafe(
AbstractClientPlayer player
) {
// Try factory first
ModifierLayer 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 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 getOrCreateNpcContextLayer(
LivingEntity entity
) {
if (entity instanceof IAnimatedPlayer animated) {
return npcContextLayers.computeIfAbsent(entity.getUUID(), k -> {
ModifierLayer 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 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 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).
*
* 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.
*
* @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 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 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 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 getFurnitureLayer(Player player) {
if (player instanceof AbstractClientPlayer clientPlayer) {
try {
ModifierLayer 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.
*
* 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.
*
* If the player IS riding an ISeatProvider, the counter is reset.
*
* @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.
*
* Fallback chain:
*
* - Remove _sneak_ suffix (sneak variants often missing)
* - For sit_dog/kneel_dog variants, fall back to basic standing DOG
* - For _arms_ variants, try FULL variant
*
*
* @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>[] ALL_NPC_CACHES =
new Map[] { npcLayers, npcContextLayers, npcFurnitureLayers };
public static void cleanup(UUID entityId) {
for (Map> cache : ALL_NPC_CACHES) {
ModifierLayer 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> cache : ALL_NPC_CACHES) {
cache.values().forEach(layer -> layer.setAnimation(null));
cache.clear();
}
furnitureGraceTicks.clear();
LOGGER.info("Cleared all NPC animation layers");
}
}