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.
821 lines
30 KiB
Java
821 lines
30 KiB
Java
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.
|
|
*
|
|
* <p>Handles both players and NPCs (any entity implementing IAnimatedPlayer).
|
|
* Uses PlayerAnimator library for smooth keyframe animations with bendy-lib support.
|
|
*
|
|
* <p>This replaces the previous split system:
|
|
* <ul>
|
|
* <li>PlayerAnimatorBridge (for players)</li>
|
|
* <li>DamselAnimationManager (for NPCs)</li>
|
|
* </ul>
|
|
*/
|
|
@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<UUID, ModifierLayer<IAnimation>> npcLayers =
|
|
new ConcurrentHashMap<>();
|
|
|
|
/** Cache of context ModifierLayers for NPC entities */
|
|
private static final Map<UUID, ModifierLayer<IAnimation>> npcContextLayers =
|
|
new ConcurrentHashMap<>();
|
|
|
|
/** Cache of furniture ModifierLayers for NPC entities */
|
|
private static final Map<
|
|
UUID,
|
|
ModifierLayer<IAnimation>
|
|
> 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.
|
|
*
|
|
* <p>Uses ConcurrentHashMap for safe access from both client tick and render thread.</p>
|
|
*/
|
|
private static final Map<UUID, Integer> 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).
|
|
*
|
|
* <p>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<IAnimation> 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<IAnimation> 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<IAnimation> 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<IAnimation> getLayer(LivingEntity entity) {
|
|
// Players: try PlayerAnimationAccess first, then cache
|
|
if (entity instanceof AbstractClientPlayer player) {
|
|
ModifierLayer<IAnimation> 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<IAnimation> 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<IAnimation> 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<IAnimation> 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<IAnimation> 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;
|
|
}
|
|
|
|
/** Per-player dedup set so we log the factory-access failure at most once per UUID. */
|
|
private static final java.util.Set<UUID> layerFailureLogged =
|
|
java.util.concurrent.ConcurrentHashMap.newKeySet();
|
|
|
|
/**
|
|
* Get the animation layer for a player from PlayerAnimationAccess.
|
|
*
|
|
* <p>Throws during the factory-race window for remote players (the factory
|
|
* hasn't yet initialized their associated data). This is the expected path
|
|
* for the {@link PendingAnimationManager} retry loop, so we log at DEBUG
|
|
* and at most once per UUID — a per-tick log would flood during busy
|
|
* multiplayer.</p>
|
|
*/
|
|
@SuppressWarnings("unchecked")
|
|
private static ModifierLayer<IAnimation> getPlayerLayer(
|
|
AbstractClientPlayer player
|
|
) {
|
|
try {
|
|
return (ModifierLayer<
|
|
IAnimation
|
|
>) PlayerAnimationAccess.getPlayerAssociatedData(player).get(
|
|
FACTORY_ID
|
|
);
|
|
} catch (Exception e) {
|
|
if (layerFailureLogged.add(player.getUUID())) {
|
|
LOGGER.debug(
|
|
"Animation layer not yet available for player {} (will retry): {}",
|
|
player.getName().getString(),
|
|
e.toString()
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Safely get the animation layer for a player.
|
|
* Returns null if the layer is not yet initialized.
|
|
*
|
|
* <p>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<IAnimation> getPlayerLayerSafe(
|
|
AbstractClientPlayer player
|
|
) {
|
|
// Try factory first
|
|
ModifierLayer<IAnimation> 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<IAnimation> 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<IAnimation> getOrCreateNpcContextLayer(
|
|
LivingEntity entity
|
|
) {
|
|
if (entity instanceof IAnimatedPlayer animated) {
|
|
return npcContextLayers.computeIfAbsent(entity.getUUID(), k -> {
|
|
ModifierLayer<IAnimation> 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<IAnimation> 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<IAnimation> 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).
|
|
*
|
|
* <p>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.</p>
|
|
*
|
|
* @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<IAnimation> layer = getOrCreateFurnitureLayer(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<IAnimation> 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<IAnimation> layer = getFurnitureLayer(player);
|
|
return layer != null && layer.getAnimation() != null;
|
|
}
|
|
|
|
/**
|
|
* Get the furniture ModifierLayer for a player (READ-ONLY).
|
|
* Uses PlayerAnimationAccess for local/factory-registered players,
|
|
* falls back to NPC cache for remote players. Returns null if no layer
|
|
* has been created yet — callers that need to guarantee a layer should use
|
|
* {@link #getOrCreateFurnitureLayer}.
|
|
*/
|
|
@SuppressWarnings("unchecked")
|
|
@javax.annotation.Nullable
|
|
private static ModifierLayer<IAnimation> getFurnitureLayer(Player player) {
|
|
if (player instanceof AbstractClientPlayer clientPlayer) {
|
|
try {
|
|
ModifierLayer<IAnimation> 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());
|
|
}
|
|
|
|
/**
|
|
* Get or create the furniture ModifierLayer for a player. Mirrors
|
|
* {@link #getOrCreateLayer} but for the FURNITURE layer priority.
|
|
*
|
|
* <p>For the local player (factory-registered), returns the factory layer.
|
|
* For remote players, creates a new layer on first call and caches it in
|
|
* {@link #npcFurnitureLayers} — remote players don't own a factory layer,
|
|
* so without a fallback they can't receive any furniture seat pose.</p>
|
|
*/
|
|
@SuppressWarnings("unchecked")
|
|
@javax.annotation.Nullable
|
|
private static ModifierLayer<IAnimation> getOrCreateFurnitureLayer(
|
|
Player player
|
|
) {
|
|
if (player instanceof AbstractClientPlayer clientPlayer) {
|
|
try {
|
|
ModifierLayer<IAnimation> layer = (ModifierLayer<
|
|
IAnimation
|
|
>) PlayerAnimationAccess.getPlayerAssociatedData(
|
|
clientPlayer
|
|
).get(FURNITURE_FACTORY_ID);
|
|
if (layer != null) {
|
|
return layer;
|
|
}
|
|
} catch (Exception e) {
|
|
// Fall through to fallback-create below.
|
|
}
|
|
|
|
// Remote players: fallback-create via the animation stack.
|
|
if (clientPlayer instanceof IAnimatedPlayer animated) {
|
|
return npcFurnitureLayers.computeIfAbsent(
|
|
clientPlayer.getUUID(),
|
|
k -> {
|
|
ModifierLayer<IAnimation> newLayer =
|
|
new ModifierLayer<>();
|
|
animated
|
|
.getAnimationStack()
|
|
.addAnimLayer(FURNITURE_LAYER_PRIORITY, newLayer);
|
|
LOGGER.debug(
|
|
"Created furniture animation layer for remote player via stack: {}",
|
|
clientPlayer.getName().getString()
|
|
);
|
|
return newLayer;
|
|
}
|
|
);
|
|
}
|
|
|
|
return npcFurnitureLayers.get(clientPlayer.getUUID());
|
|
}
|
|
|
|
// Non-player entities: use NPC cache (read-only; NPC furniture animation
|
|
// is not currently produced by this codebase).
|
|
return npcFurnitureLayers.get(player.getUUID());
|
|
}
|
|
|
|
/**
|
|
* Safety tick for furniture animations. Call once per client tick per player.
|
|
*
|
|
* <p>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.</p>
|
|
*
|
|
* <p>If the player IS riding an ISeatProvider, the counter is reset.</p>
|
|
*
|
|
* @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.
|
|
*
|
|
* <p>Fallback chain:
|
|
* <ol>
|
|
* <li>Remove _sneak_ suffix (sneak variants often missing)</li>
|
|
* <li>For sit_dog/kneel_dog variants, fall back to basic standing DOG</li>
|
|
* <li>For _arms_ variants, try FULL variant</li>
|
|
* </ol>
|
|
*
|
|
* @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<UUID, ModifierLayer<IAnimation>>[] ALL_NPC_CACHES =
|
|
new Map[] { npcLayers, npcContextLayers, npcFurnitureLayers };
|
|
|
|
public static void cleanup(UUID entityId) {
|
|
for (Map<UUID, ModifierLayer<IAnimation>> cache : ALL_NPC_CACHES) {
|
|
ModifierLayer<IAnimation> layer = cache.remove(entityId);
|
|
if (layer != null) {
|
|
layer.setAnimation(null);
|
|
}
|
|
}
|
|
furnitureGraceTicks.remove(entityId);
|
|
layerFailureLogged.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<UUID, ModifierLayer<IAnimation>> cache : ALL_NPC_CACHES) {
|
|
cache.values().forEach(layer -> layer.setAnimation(null));
|
|
cache.clear();
|
|
}
|
|
furnitureGraceTicks.clear();
|
|
layerFailureLogged.clear();
|
|
LOGGER.info("Cleared all NPC animation layers");
|
|
}
|
|
}
|