package com.tiedup.remake.client.gltf; import com.tiedup.remake.client.animation.BondageAnimationManager; import com.tiedup.remake.client.animation.context.AnimationContext; import com.tiedup.remake.client.animation.context.ContextAnimationFactory; import com.tiedup.remake.client.animation.context.GlbAnimationResolver; import com.tiedup.remake.client.animation.context.RegionBoneMapper; import dev.kosmx.playerAnim.core.data.KeyframeAnimation; import java.util.Collections; import java.util.HashSet; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.TreeSet; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import net.minecraft.resources.ResourceLocation; import net.minecraft.world.entity.LivingEntity; import net.minecraftforge.api.distmarker.Dist; import net.minecraftforge.api.distmarker.OnlyIn; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.Nullable; /** * V2 Animation Applier -- manages dual-layer animation for V2 bondage items. * *

Orchestrates two PlayerAnimator layers simultaneously: *

* *

Each equipped V2 item controls ONLY the bones matching its occupied body regions. * Bones not owned by any item pass through from the context layer, which provides the * appropriate base posture animation. * *

State tracking avoids redundant animation replays: a composite key of * {@code animSource|context|ownedParts} is compared per-entity to skip no-op updates. * *

Item animations are cached by {@code animSource#context#ownedParts} since the same * GLB + context + owned parts always produces the same KeyframeAnimation. * * @see ContextAnimationFactory * @see GlbAnimationResolver * @see GltfPoseConverter#convertSelective * @see BondageAnimationManager */ @OnlyIn(Dist.CLIENT) public final class GltfAnimationApplier { private static final Logger LOGGER = LogManager.getLogger("GltfPipeline"); /** * Cache of converted item-layer KeyframeAnimations, keyed by * {@code animSource#context#ownedPartsHash}. LRU-bounded via * access-ordered {@link LinkedHashMap}: size capped at * {@link #ITEM_ANIM_CACHE_MAX}, head (least-recently-used) evicted on * overflow. Wrapped in {@link Collections#synchronizedMap} because * {@code LinkedHashMap.get} mutates the iteration order. External * iteration is not supported; use only {@code get}/{@code put}/{@code clear}. */ private static final int ITEM_ANIM_CACHE_MAX = 256; // Initial capacity (int)(cap / loadFactor) + 1 so the cap is reached // without a rehash. private static final int ITEM_ANIM_CACHE_INITIAL_CAPACITY = (int) (ITEM_ANIM_CACHE_MAX / 0.75f) + 1; private static final Map itemAnimCache = Collections.synchronizedMap( new LinkedHashMap( ITEM_ANIM_CACHE_INITIAL_CAPACITY, 0.75f, true // access-order ) { @Override protected boolean removeEldestEntry( Map.Entry eldest ) { return size() > ITEM_ANIM_CACHE_MAX; } } ); /** * Track which composite state is currently active per entity, to avoid redundant replays. * Keyed by entity UUID, value is "animSource|context|sortedParts". */ private static final Map activeStateKeys = new ConcurrentHashMap<>(); /** Track cache keys where GLB loading failed, to avoid per-tick retries. */ private static final Set failedLoadKeys = ConcurrentHashMap.newKeySet(); private GltfAnimationApplier() {} // INIT (legacy) /** * Legacy init method -- called by GltfClientSetup. * No-op: layer registration is handled by {@link BondageAnimationManager#init()}. */ public static void init() { // No-op: animation layers are managed by BondageAnimationManager } // V2 DUAL-LAYER API /** * Apply the full V2 animation state: context layer + item layer. * *

Flow: *

    *
  1. Build a composite state key and skip if unchanged
  2. *
  3. Create/retrieve a context animation with disabledOnContext parts disabled, * play on context layer via {@link BondageAnimationManager#playContext}
  4. *
  5. Load the GLB (from {@code animationSource} or {@code modelLoc}), * resolve the named animation via {@link GlbAnimationResolver#resolve}, * convert with selective parts via {@link GltfPoseConverter#convertSelective}, * play on item layer via {@link BondageAnimationManager#playDirect}
  6. *
* *

The ownership model enables "free bone" animation: if a bone is not claimed * by any item, the winning item can animate it IF its GLB has keyframes for that bone. * This allows a straitjacket (ARMS+TORSO) to also animate free legs.

* * @param entity the entity to animate * @param modelLoc the item's GLB model (for mesh rendering, and default animation source) * @param animationSource separate GLB for animations (shared template), or null to use modelLoc * @param context current animation context (STAND_IDLE, SIT_IDLE, etc.) * @param ownership bone ownership: which parts this item owns vs other items * @return true if the item layer animation was applied successfully */ public static boolean applyV2Animation( LivingEntity entity, ResourceLocation modelLoc, @Nullable ResourceLocation animationSource, AnimationContext context, RegionBoneMapper.BoneOwnership ownership ) { if (entity == null || modelLoc == null) return false; ResourceLocation animSource = animationSource != null ? animationSource : modelLoc; // Cache key includes both owned and enabled parts for full disambiguation String ownedKey = canonicalPartsKey(ownership.thisParts()); String enabledKey = canonicalPartsKey(ownership.enabledParts()); String partsKey = ownedKey + ";" + enabledKey; // Build composite state key to detect context changes. // NOTE: This key does NOT include the variant name — that is resolved fresh // each time the context changes, enabling random variant selection. String stateKey = animSource + "|" + context.name() + "|" + partsKey; String currentKey = activeStateKeys.get(entity.getUUID()); if (stateKey.equals(currentKey)) { return true; // Same context, same parts — no need to re-resolve } // === Layer 1: Context animation (base body posture) === // Parts owned by ANY item (this or others) are disabled on the context layer. // Only free parts remain enabled on context. KeyframeAnimation contextAnim = ContextAnimationFactory.create( context, ownership.disabledOnContext() ); if (contextAnim != null) { BondageAnimationManager.playContext(entity, contextAnim); } // === Layer 2: Item animation (GLB pose with selective bones) === String itemCacheKey = buildItemCacheKey(animSource, context, partsKey); // Skip if this GLB already failed to load if (failedLoadKeys.contains(itemCacheKey)) { activeStateKeys.put(entity.getUUID(), stateKey); return false; } // Resolve animation data first (needed for variant resolution) GltfData animData = GlbAnimationResolver.resolveAnimationData( modelLoc, animationSource ); if (animData == null) { LOGGER.warn( "[GltfPipeline] Failed to load animation GLB: {}", animSource ); failedLoadKeys.add(itemCacheKey); activeStateKeys.put(entity.getUUID(), stateKey); return false; } // Resolve which named animation to use (with fallback chain + variant selection). // This must happen BEFORE the cache lookup because variant selection is random — // we want a fresh random pick each time the context changes, not a permanently // cached first pick. String glbAnimName = GlbAnimationResolver.resolve( animData, context ); // Include the resolved animation name in the cache key so different variants // (Struggle.1 vs Struggle.2) get separate cache entries. String variantCacheKey = itemCacheKey + "#" + (glbAnimName != null ? glbAnimName : "default"); // Atomic get-or-compute under the map's monitor. Collections // .synchronizedMap only synchronizes individual get/put calls, so a // naive check-then-put races between concurrent converters and can // both double-convert and trip removeEldestEntry with a stale size. KeyframeAnimation itemAnim; synchronized (itemAnimCache) { itemAnim = itemAnimCache.get(variantCacheKey); if (itemAnim == null) { itemAnim = GltfPoseConverter.convertSelective( animData, glbAnimName, ownership.thisParts(), ownership.enabledParts() ); itemAnimCache.put(variantCacheKey, itemAnim); } } BondageAnimationManager.playDirect(entity, itemAnim); activeStateKeys.put(entity.getUUID(), stateKey); return true; } /** * Apply V2 animation from ALL equipped items simultaneously. * *

Each item contributes keyframes for only its owned bones into a shared * {@link KeyframeAnimation.AnimationBuilder}. The first item in the list (highest priority) * can additionally animate free bones if its GLB has keyframes for them.

* * @param entity the entity to animate * @param items resolved V2 items with per-item ownership, sorted by priority desc * @param context current animation context * @param allOwnedParts union of all owned parts across all items * @return true if the composite animation was applied */ public static boolean applyMultiItemV2Animation( LivingEntity entity, List items, AnimationContext context, Set allOwnedParts ) { if (entity == null || items.isEmpty()) return false; // Build composite state key StringBuilder keyBuilder = new StringBuilder(); for (RegionBoneMapper.V2ItemAnimInfo item : items) { ResourceLocation src = item.animSource() != null ? item.animSource() : item.modelLoc(); keyBuilder .append(src) .append(':') .append(canonicalPartsKey(item.ownedParts())) .append(';'); } keyBuilder.append(context.name()); String stateKey = keyBuilder.toString(); String currentKey = activeStateKeys.get(entity.getUUID()); if (stateKey.equals(currentKey)) { return true; // Already active } // === Layer 1: Context animation === KeyframeAnimation contextAnim = ContextAnimationFactory.create( context, allOwnedParts ); if (contextAnim != null) { BondageAnimationManager.playContext(entity, contextAnim); } // === Layer 2: Composite item animation === // Pre-resolve animation data and variant names for all items BEFORE cache lookup. // This ensures random variant selection happens fresh on each context change, // and each variant combination gets its own cache entry. record ResolvedItem( GltfData animData, String glbAnimName, RegionBoneMapper.V2ItemAnimInfo info ) {} List resolvedItems = new ArrayList<>(); StringBuilder variantKeyBuilder = new StringBuilder("multi#").append(stateKey); for (RegionBoneMapper.V2ItemAnimInfo item : items) { ResourceLocation animSource = item.animSource() != null ? item.animSource() : item.modelLoc(); GltfData animData = GlbAnimationResolver.resolveAnimationData( item.modelLoc(), item.animSource() ); if (animData == null) { LOGGER.warn( "[GltfPipeline] Failed to load GLB for multi-item: {}", animSource ); continue; } String glbAnimName = GlbAnimationResolver.resolve(animData, context); resolvedItems.add(new ResolvedItem(animData, glbAnimName, item)); variantKeyBuilder.append('#') .append(glbAnimName != null ? glbAnimName : "default"); } String compositeCacheKey = variantKeyBuilder.toString(); if (failedLoadKeys.contains(compositeCacheKey)) { activeStateKeys.put(entity.getUUID(), stateKey); return false; } // Atomic get-or-compute under the map's monitor (see // applyV2Animation). All current callers are render-thread so no // contention in practice, but the synchronized wrap closes the // window where two converters could race and clobber each other. KeyframeAnimation compositeAnim; synchronized (itemAnimCache) { compositeAnim = itemAnimCache.get(compositeCacheKey); } if (compositeAnim == null) { KeyframeAnimation.AnimationBuilder builder = new KeyframeAnimation.AnimationBuilder( dev.kosmx.playerAnim.core.data.AnimationFormat.JSON_EMOTECRAFT ); builder.beginTick = 0; builder.isLooped = true; builder.returnTick = 0; builder.name = "gltf_composite"; boolean anyLoaded = false; int maxEndTick = 1; Set unionKeyframeParts = new HashSet<>(); boolean anyFullBody = false; boolean anyFullHead = false; for (ResolvedItem resolved : resolvedItems) { RegionBoneMapper.V2ItemAnimInfo item = resolved.info(); GltfData animData = resolved.animData(); String glbAnimName = resolved.glbAnimName(); ResourceLocation animSource = item.animSource() != null ? item.animSource() : item.modelLoc(); GltfData.AnimationClip rawClip; if (glbAnimName != null) { rawClip = animData.getRawAnimation(glbAnimName); } else { rawClip = null; } if (rawClip == null) { rawClip = animData.rawGltfAnimation(); } // Compute effective parts: intersect animation_bones whitelist with ownedParts // if the item declares per-animation bone filtering. Set effectiveParts = item.ownedParts(); if (glbAnimName != null && !item.animationBones().isEmpty()) { Set override = item.animationBones().get(glbAnimName); if (override != null) { Set filtered = new HashSet<>(override); filtered.retainAll(item.ownedParts()); if (!filtered.isEmpty()) { effectiveParts = filtered; } } } Set itemKeyframeParts = GltfPoseConverter.addBonesToBuilder( builder, animData, rawClip, effectiveParts ); unionKeyframeParts.addAll(itemKeyframeParts); maxEndTick = Math.max( maxEndTick, GltfPoseConverter.computeEndTick(rawClip) ); // FullX / FullHeadX opt-in: ANY item requesting it lifts the // restriction for the composite. The animation name passed to // the core helper uses the same "gltf_" prefix convention as // the single-item path. String prefixed = glbAnimName != null ? "gltf_" + glbAnimName : null; if (GltfPoseConverter.isFullBodyAnimName(prefixed)) { anyFullBody = true; } if (GltfPoseConverter.isFullHeadAnimName(prefixed)) { anyFullHead = true; } anyLoaded = true; LOGGER.debug( "[GltfPipeline] Multi-item: {} -> owned={}, effective={}, anim={}", animSource, item.ownedParts(), effectiveParts, glbAnimName ); } if (!anyLoaded) { failedLoadKeys.add(compositeCacheKey); activeStateKeys.put(entity.getUUID(), stateKey); return false; } builder.endTick = maxEndTick; builder.stopTick = maxEndTick; // Selective-part enabling for the composite. Owned parts always on; // free parts (including head) opt-in only if ANY item declares a // FullX / FullHeadX animation AND has keyframes for that part. GltfPoseConverter.enableSelectivePartsComposite( builder, allOwnedParts, unionKeyframeParts, anyFullBody, anyFullHead ); compositeAnim = builder.build(); synchronized (itemAnimCache) { // Another thread may have computed the same key while we were // building. Prefer its result to keep one instance per key, // matching removeEldestEntry's accounting. KeyframeAnimation winner = itemAnimCache.get(compositeCacheKey); if (winner != null) { compositeAnim = winner; } else { itemAnimCache.put(compositeCacheKey, compositeAnim); } } } BondageAnimationManager.playDirect(entity, compositeAnim); activeStateKeys.put(entity.getUUID(), stateKey); return true; } // CLEAR / QUERY /** * Clear all V2 animation layers from an entity and remove tracking. * Stops both the context layer and the item layer. * * @param entity the entity to clear animations from */ public static void clearV2Animation(LivingEntity entity) { if (entity == null) return; activeStateKeys.remove(entity.getUUID()); BondageAnimationManager.stopContext(entity); BondageAnimationManager.stopAnimation(entity); } /** * Check if an entity has active V2 animation state. * * @param entity the entity to check * @return true if the entity has an active V2 animation state key */ public static boolean hasActiveState(LivingEntity entity) { return entity != null && activeStateKeys.containsKey(entity.getUUID()); } /** * Remove tracking for an entity (e.g., on logout/unload). * Does NOT stop any currently playing animation -- use {@link #clearV2Animation} for that. * * @param entityId UUID of the entity to stop tracking */ public static void removeTracking(UUID entityId) { activeStateKeys.remove(entityId); } // CACHE MANAGEMENT /** * Invalidate all cached item animations and tracking state. * Call this on resource reload (F3+T) to pick up changed GLB/JSON files. * *

Does NOT clear ContextAnimationFactory or ContextGlbRegistry here. * Those are cleared in the reload listener AFTER ContextGlbRegistry.reload() * to prevent the render thread from caching stale JSON fallbacks during * the window between clear and repopulate.

*/ public static void invalidateCache() { itemAnimCache.clear(); activeStateKeys.clear(); failedLoadKeys.clear(); } /** * Clear all state (cache + tracking). Called on world unload. * Clears everything including context caches (no concurrent reload during unload). */ public static void clearAll() { itemAnimCache.clear(); activeStateKeys.clear(); failedLoadKeys.clear(); com.tiedup.remake.client.animation.context.ContextGlbRegistry.clear(); ContextAnimationFactory.clearCache(); } // INTERNAL /** * Build cache key for item-layer animations. * Format: "animSource#contextName#sortedParts" */ private static String buildItemCacheKey( ResourceLocation animSource, AnimationContext context, String partsKey ) { return animSource + "#" + context.name() + "#" + partsKey; } /** * Build a canonical, deterministic string from the owned parts set. * Sorted alphabetically and joined by comma — guarantees no hash collisions. */ private static String canonicalPartsKey(Set ownedParts) { return String.join(",", new TreeSet<>(ownedParts)); } }