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. * *

Handles both players and NPCs (any entity implementing IAnimatedPlayer). * Uses PlayerAnimator library for smooth keyframe animations with bendy-lib support. * *

This replaces the previous split system: *

*/ @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> npcLayers = new ConcurrentHashMap<>(); /** Cache of context ModifierLayers for NPC entities */ private static final Map> npcContextLayers = new ConcurrentHashMap<>(); /** Cache of furniture ModifierLayers for NPC entities */ private static final Map< UUID, ModifierLayer > 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. * *

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: *

    *
  1. Remove _sneak_ suffix (sneak variants often missing)
  2. *
  3. For sit_dog/kneel_dog variants, fall back to basic standing DOG
  4. *
  5. For _arms_ variants, try FULL variant
  6. *
* * @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"); } }