Clean repo for open source release
Remove build artifacts, dev tool configs, unused dependencies, and third-party source dumps. Add proper README, update .gitignore, clean up Makefile.
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
package com.tiedup.remake.client.animation;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Central registry for player animation state tracking.
|
||||
*
|
||||
* <p>Holds per-player state maps that were previously scattered across
|
||||
* AnimationTickHandler. Provides a single clearAll() entry point for
|
||||
* world unload cleanup.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public final class AnimationStateRegistry {
|
||||
|
||||
/** Track last tied state per player */
|
||||
static final Map<UUID, Boolean> lastTiedState = new ConcurrentHashMap<>();
|
||||
|
||||
/** Track last animation ID per player to avoid redundant updates */
|
||||
static final Map<UUID, String> lastAnimId = new ConcurrentHashMap<>();
|
||||
|
||||
private AnimationStateRegistry() {}
|
||||
|
||||
public static Map<UUID, Boolean> getLastTiedState() {
|
||||
return lastTiedState;
|
||||
}
|
||||
|
||||
public static Map<UUID, String> getLastAnimId() {
|
||||
return lastAnimId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all animation-related state in one call.
|
||||
* Called on world unload to prevent memory leaks and stale data.
|
||||
*/
|
||||
public static void clearAll() {
|
||||
// Animation state tracking
|
||||
lastTiedState.clear();
|
||||
lastAnimId.clear();
|
||||
|
||||
// Animation managers
|
||||
BondageAnimationManager.clearAll();
|
||||
PendingAnimationManager.clearAll();
|
||||
|
||||
// V2 animation context system (clearAll chains to ContextAnimationFactory.clearCache)
|
||||
com.tiedup.remake.client.gltf.GltfAnimationApplier.clearAll();
|
||||
|
||||
// Render state
|
||||
com.tiedup.remake.client.animation.render.DogPoseRenderHandler.clearState();
|
||||
|
||||
// NPC animation state
|
||||
com.tiedup.remake.client.animation.tick.NpcAnimationTickHandler.clearAll();
|
||||
|
||||
// MCA animation cache
|
||||
com.tiedup.remake.client.animation.tick.MCAAnimationTickCache.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,737 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the animation layer for a player from PlayerAnimationAccess.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private static ModifierLayer<IAnimation> 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.
|
||||
*
|
||||
* <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 = 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<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.
|
||||
* Uses PlayerAnimationAccess for local/factory-registered players,
|
||||
* falls back to NPC cache for remote players.
|
||||
*/
|
||||
@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());
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
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();
|
||||
LOGGER.info("Cleared all NPC animation layers");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package com.tiedup.remake.client.animation;
|
||||
|
||||
import com.mojang.logging.LogUtils;
|
||||
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.minecraftApi.PlayerAnimationRegistry;
|
||||
import java.util.Iterator;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import net.minecraft.client.multiplayer.ClientLevel;
|
||||
import net.minecraft.client.player.AbstractClientPlayer;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import org.slf4j.Logger;
|
||||
|
||||
/**
|
||||
* Manages pending animations for remote players whose animation layers
|
||||
* may not be immediately available due to timing issues.
|
||||
*
|
||||
* <p>When a player is tied, the sync packet may arrive before the remote player's
|
||||
* animation layer is initialized by PlayerAnimator. This class queues failed
|
||||
* animation attempts and retries them each tick until success or timeout.
|
||||
*
|
||||
* <p>This follows the same pattern as SyncManager's pending queue for inventory sync.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class PendingAnimationManager {
|
||||
|
||||
private static final Logger LOGGER = LogUtils.getLogger();
|
||||
|
||||
/** Pending animations waiting for layer initialization */
|
||||
private static final Map<UUID, PendingEntry> pending =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
/** Maximum retry attempts before giving up (~2 seconds at 20 ticks/sec) */
|
||||
private static final int MAX_RETRIES = 40;
|
||||
|
||||
/**
|
||||
* Queue a player's animation for retry.
|
||||
* Called when playAnimation fails due to null layer.
|
||||
*
|
||||
* @param uuid The player's UUID
|
||||
* @param animId The animation ID (without namespace)
|
||||
*/
|
||||
public static void queueForRetry(UUID uuid, String animId) {
|
||||
pending.compute(uuid, (k, existing) -> {
|
||||
if (existing == null) {
|
||||
LOGGER.debug(
|
||||
"Queued animation '{}' for retry on player {}",
|
||||
animId,
|
||||
uuid
|
||||
);
|
||||
return new PendingEntry(animId, 0);
|
||||
}
|
||||
// Update animation ID but preserve retry count
|
||||
return new PendingEntry(animId, existing.retries);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a player from the pending queue.
|
||||
* Called when animation succeeds or player disconnects.
|
||||
*
|
||||
* @param uuid The player's UUID
|
||||
*/
|
||||
public static void remove(UUID uuid) {
|
||||
pending.remove(uuid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a player has a pending animation.
|
||||
*
|
||||
* @param uuid The player's UUID
|
||||
* @return true if pending
|
||||
*/
|
||||
public static boolean hasPending(UUID uuid) {
|
||||
return pending.containsKey(uuid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process pending animations. Called every tick from AnimationTickHandler.
|
||||
* Attempts to play queued animations and removes successful or expired entries.
|
||||
*
|
||||
* @param level The client level
|
||||
*/
|
||||
public static void processPending(ClientLevel level) {
|
||||
if (pending.isEmpty()) return;
|
||||
|
||||
Iterator<Map.Entry<UUID, PendingEntry>> it = pending
|
||||
.entrySet()
|
||||
.iterator();
|
||||
|
||||
while (it.hasNext()) {
|
||||
Map.Entry<UUID, PendingEntry> entry = it.next();
|
||||
UUID uuid = entry.getKey();
|
||||
PendingEntry pe = entry.getValue();
|
||||
|
||||
// Check expiration
|
||||
if (pe.retries >= MAX_RETRIES) {
|
||||
LOGGER.warn("Animation retry exhausted for player {}", uuid);
|
||||
it.remove();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to find player and play animation
|
||||
Player player = level.getPlayerByUUID(uuid);
|
||||
if (player instanceof AbstractClientPlayer clientPlayer) {
|
||||
ModifierLayer<IAnimation> layer =
|
||||
BondageAnimationManager.getPlayerLayerSafe(clientPlayer);
|
||||
|
||||
if (layer != null) {
|
||||
ResourceLocation loc =
|
||||
ResourceLocation.fromNamespaceAndPath(
|
||||
"tiedup",
|
||||
pe.animId
|
||||
);
|
||||
KeyframeAnimation anim =
|
||||
PlayerAnimationRegistry.getAnimation(loc);
|
||||
|
||||
if (anim != null) {
|
||||
layer.setAnimation(new KeyframeAnimationPlayer(anim));
|
||||
LOGGER.info(
|
||||
"Animation retry succeeded for {} after {} attempts",
|
||||
clientPlayer.getName().getString(),
|
||||
pe.retries
|
||||
);
|
||||
it.remove();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Increment retry count
|
||||
pending.put(uuid, new PendingEntry(pe.animId, pe.retries + 1));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all pending animations.
|
||||
* Called on world unload.
|
||||
*/
|
||||
public static void clearAll() {
|
||||
pending.clear();
|
||||
LOGGER.debug("Cleared all pending animations");
|
||||
}
|
||||
|
||||
/**
|
||||
* Record to store pending animation data.
|
||||
*/
|
||||
private record PendingEntry(String animId, int retries) {}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package com.tiedup.remake.client.animation;
|
||||
|
||||
import com.tiedup.remake.items.base.PoseType;
|
||||
import net.minecraft.client.model.HumanoidModel;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Applies static bondage poses directly to HumanoidModel.
|
||||
*
|
||||
* <p>Used for entities that don't support PlayerAnimator (e.g., MCA villagers).
|
||||
* Directly modifies arm/leg rotations on the model.
|
||||
*
|
||||
* <p>Extracted from BondageAnimationManager to separate concerns:
|
||||
* BondageAnimationManager handles PlayerAnimator layers,
|
||||
* StaticPoseApplier handles raw model manipulation.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class StaticPoseApplier {
|
||||
|
||||
/**
|
||||
* Apply a static bondage pose directly to a HumanoidModel.
|
||||
*
|
||||
* @param model The humanoid model to modify
|
||||
* @param poseType The pose type (STANDARD, STRAITJACKET, WRAP, LATEX_SACK)
|
||||
* @param armsBound whether ARMS region is occupied
|
||||
* @param legsBound whether LEGS region is occupied
|
||||
*/
|
||||
public static void applyStaticPose(
|
||||
HumanoidModel<?> model,
|
||||
PoseType poseType,
|
||||
boolean armsBound,
|
||||
boolean legsBound
|
||||
) {
|
||||
if (model == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyBodyPose(model, poseType);
|
||||
|
||||
if (armsBound) {
|
||||
applyArmPose(model, poseType);
|
||||
}
|
||||
|
||||
if (legsBound) {
|
||||
applyLegPose(model, poseType);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply arm pose based on pose type.
|
||||
* Values converted from animation JSON (degrees to radians).
|
||||
*/
|
||||
private static void applyArmPose(
|
||||
HumanoidModel<?> model,
|
||||
PoseType poseType
|
||||
) {
|
||||
switch (poseType) {
|
||||
case STANDARD -> {
|
||||
model.rightArm.xRot = 0.899f;
|
||||
model.rightArm.yRot = 1.0f;
|
||||
model.rightArm.zRot = 0f;
|
||||
model.leftArm.xRot = 0.899f;
|
||||
model.leftArm.yRot = -1.0f;
|
||||
model.leftArm.zRot = 0f;
|
||||
}
|
||||
case STRAITJACKET -> {
|
||||
model.rightArm.xRot = 0.764f;
|
||||
model.rightArm.yRot = -0.84f;
|
||||
model.rightArm.zRot = 0f;
|
||||
model.leftArm.xRot = 0.764f;
|
||||
model.leftArm.yRot = 0.84f;
|
||||
model.leftArm.zRot = 0f;
|
||||
}
|
||||
case WRAP, LATEX_SACK -> {
|
||||
model.rightArm.xRot = 0f;
|
||||
model.rightArm.yRot = 0f;
|
||||
model.rightArm.zRot = -0.087f;
|
||||
model.leftArm.xRot = 0f;
|
||||
model.leftArm.yRot = 0f;
|
||||
model.leftArm.zRot = 0.087f;
|
||||
}
|
||||
case DOG -> {
|
||||
model.rightArm.xRot = -2.094f;
|
||||
model.rightArm.yRot = 0.175f;
|
||||
model.rightArm.zRot = 0f;
|
||||
model.leftArm.xRot = -2.094f;
|
||||
model.leftArm.yRot = -0.175f;
|
||||
model.leftArm.zRot = 0f;
|
||||
}
|
||||
case HUMAN_CHAIR -> {
|
||||
model.rightArm.xRot = -2.094f;
|
||||
model.rightArm.yRot = 0.175f;
|
||||
model.rightArm.zRot = 0f;
|
||||
model.leftArm.xRot = -2.094f;
|
||||
model.leftArm.yRot = -0.175f;
|
||||
model.leftArm.zRot = 0f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply leg pose based on pose type.
|
||||
*/
|
||||
private static void applyLegPose(
|
||||
HumanoidModel<?> model,
|
||||
PoseType poseType
|
||||
) {
|
||||
if (poseType == PoseType.DOG || poseType == PoseType.HUMAN_CHAIR) {
|
||||
model.rightLeg.xRot = -1.047f;
|
||||
model.rightLeg.yRot = 0.349f;
|
||||
model.rightLeg.zRot = 0f;
|
||||
model.leftLeg.xRot = -1.047f;
|
||||
model.leftLeg.yRot = -0.349f;
|
||||
model.leftLeg.zRot = 0f;
|
||||
} else {
|
||||
model.rightLeg.xRot = 0f;
|
||||
model.rightLeg.yRot = 0f;
|
||||
model.rightLeg.zRot = -0.1f;
|
||||
model.leftLeg.xRot = 0f;
|
||||
model.leftLeg.yRot = 0f;
|
||||
model.leftLeg.zRot = 0.1f;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply body pose for DOG/HUMAN_CHAIR pose.
|
||||
*/
|
||||
public static void applyBodyPose(
|
||||
HumanoidModel<?> model,
|
||||
PoseType poseType
|
||||
) {
|
||||
if (poseType == PoseType.DOG || poseType == PoseType.HUMAN_CHAIR) {
|
||||
model.body.xRot = -1.571f;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.tiedup.remake.client.animation.context;
|
||||
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Represents the current player/NPC posture and action state for animation selection.
|
||||
* Determines which base body posture animation to play.
|
||||
*
|
||||
* <p>Each context maps to a GLB animation name via a prefix + variant scheme:
|
||||
* <ul>
|
||||
* <li>Prefix: "Sit", "Kneel", "Sneak", "Walk", or "" (standing)</li>
|
||||
* <li>Variant: "Idle" or "Struggle"</li>
|
||||
* </ul>
|
||||
* The {@link GlbAnimationResolver} uses these to build a fallback chain
|
||||
* (e.g., SitStruggle -> Struggle -> SitIdle -> Idle).</p>
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public enum AnimationContext {
|
||||
|
||||
STAND_IDLE("stand_idle", false),
|
||||
STAND_WALK("stand_walk", false),
|
||||
STAND_SNEAK("stand_sneak", false),
|
||||
STAND_STRUGGLE("stand_struggle", true),
|
||||
SIT_IDLE("sit_idle", false),
|
||||
SIT_STRUGGLE("sit_struggle", true),
|
||||
KNEEL_IDLE("kneel_idle", false),
|
||||
KNEEL_STRUGGLE("kneel_struggle", true),
|
||||
|
||||
// Movement style contexts
|
||||
SHUFFLE_IDLE("shuffle_idle", false),
|
||||
SHUFFLE_WALK("shuffle_walk", false),
|
||||
HOP_IDLE("hop_idle", false),
|
||||
HOP_WALK("hop_walk", false),
|
||||
WADDLE_IDLE("waddle_idle", false),
|
||||
WADDLE_WALK("waddle_walk", false),
|
||||
CRAWL_IDLE("crawl_idle", false),
|
||||
CRAWL_MOVE("crawl_move", false);
|
||||
|
||||
private final String animationSuffix;
|
||||
private final boolean struggling;
|
||||
|
||||
AnimationContext(String animationSuffix, boolean struggling) {
|
||||
this.animationSuffix = animationSuffix;
|
||||
this.struggling = struggling;
|
||||
}
|
||||
|
||||
/**
|
||||
* Suffix used as key for context animation JSON files (e.g., "stand_idle").
|
||||
*/
|
||||
public String getAnimationSuffix() {
|
||||
return animationSuffix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this context represents an active struggle state.
|
||||
*/
|
||||
public boolean isStruggling() {
|
||||
return struggling;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the GLB animation name prefix for this context's posture.
|
||||
* Used by the fallback chain in {@link GlbAnimationResolver}.
|
||||
*
|
||||
* @return "Sit", "Kneel", "Sneak", "Walk", or "" for standing
|
||||
*/
|
||||
public String getGlbContextPrefix() {
|
||||
return switch (this) {
|
||||
case SIT_IDLE, SIT_STRUGGLE -> "Sit";
|
||||
case KNEEL_IDLE, KNEEL_STRUGGLE -> "Kneel";
|
||||
case STAND_SNEAK -> "Sneak";
|
||||
case STAND_WALK -> "Walk";
|
||||
case STAND_IDLE, STAND_STRUGGLE -> "";
|
||||
case SHUFFLE_IDLE, SHUFFLE_WALK -> "Shuffle";
|
||||
case HOP_IDLE, HOP_WALK -> "Hop";
|
||||
case WADDLE_IDLE, WADDLE_WALK -> "Waddle";
|
||||
case CRAWL_IDLE, CRAWL_MOVE -> "Crawl";
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the GLB animation variant name: "Struggle" or "Idle".
|
||||
*/
|
||||
public String getGlbVariant() {
|
||||
return switch (this) {
|
||||
case STAND_STRUGGLE, SIT_STRUGGLE, KNEEL_STRUGGLE -> "Struggle";
|
||||
case STAND_WALK, SHUFFLE_WALK, HOP_WALK, WADDLE_WALK -> "Walk";
|
||||
case CRAWL_MOVE -> "Move";
|
||||
default -> "Idle";
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.tiedup.remake.client.animation.context;
|
||||
|
||||
import com.tiedup.remake.client.state.PetBedClientState;
|
||||
import com.tiedup.remake.entities.AbstractTiedUpNpc;
|
||||
import com.tiedup.remake.state.PlayerBindState;
|
||||
import com.tiedup.remake.v2.bondage.movement.MovementStyle;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
/**
|
||||
* Resolves the current {@link AnimationContext} for players and NPCs based on their state.
|
||||
*
|
||||
* <p>This is a pure function with no side effects -- it reads entity state and returns
|
||||
* the appropriate animation context. The resolution priority is:
|
||||
* <ol>
|
||||
* <li><b>Sitting</b> (pet bed for players, pose for NPCs) -- highest priority posture</li>
|
||||
* <li><b>Kneeling</b> (NPCs only)</li>
|
||||
* <li><b>Struggling</b> (standing struggle if not sitting/kneeling)</li>
|
||||
* <li><b>Sneaking</b> (players only)</li>
|
||||
* <li><b>Walking</b> (horizontal movement detected)</li>
|
||||
* <li><b>Standing idle</b> (fallback)</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>For players, the "sitting" state is determined by the client-side pet bed cache
|
||||
* ({@link PetBedClientState}) rather than entity data, since pet bed state is not
|
||||
* synced via entity data accessors.</p>
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public final class AnimationContextResolver {
|
||||
|
||||
private AnimationContextResolver() {}
|
||||
|
||||
/**
|
||||
* Resolve the animation context for a player based on their bind state and movement.
|
||||
*
|
||||
* <p>Priority chain:
|
||||
* <ol>
|
||||
* <li>Sitting (pet bed/furniture) -- highest priority posture</li>
|
||||
* <li>Struggling -- standing struggle if not sitting</li>
|
||||
* <li>Movement style -- style-specific idle/walk based on movement</li>
|
||||
* <li>Sneaking</li>
|
||||
* <li>Walking</li>
|
||||
* <li>Standing idle -- fallback</li>
|
||||
* </ol>
|
||||
*
|
||||
* @param player the player entity (must not be null)
|
||||
* @param state the player's bind state, or null if not bound
|
||||
* @param activeStyle the active movement style from client state, or null
|
||||
* @return the resolved animation context, never null
|
||||
*/
|
||||
public static AnimationContext resolve(Player player, @Nullable PlayerBindState state,
|
||||
@Nullable MovementStyle activeStyle) {
|
||||
boolean sitting = PetBedClientState.get(player.getUUID()) != 0;
|
||||
boolean struggling = state != null && state.isStruggling();
|
||||
boolean sneaking = player.isCrouching();
|
||||
boolean moving = player.getDeltaMovement().horizontalDistanceSqr() > 1.0E-6;
|
||||
|
||||
if (sitting) {
|
||||
return struggling ? AnimationContext.SIT_STRUGGLE : AnimationContext.SIT_IDLE;
|
||||
}
|
||||
if (struggling) {
|
||||
return AnimationContext.STAND_STRUGGLE;
|
||||
}
|
||||
if (activeStyle != null) {
|
||||
return resolveStyleContext(activeStyle, moving);
|
||||
}
|
||||
if (sneaking) {
|
||||
return AnimationContext.STAND_SNEAK;
|
||||
}
|
||||
if (moving) {
|
||||
return AnimationContext.STAND_WALK;
|
||||
}
|
||||
return AnimationContext.STAND_IDLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a movement style + moving flag to the appropriate AnimationContext.
|
||||
*/
|
||||
private static AnimationContext resolveStyleContext(MovementStyle style, boolean moving) {
|
||||
return switch (style) {
|
||||
case SHUFFLE -> moving ? AnimationContext.SHUFFLE_WALK : AnimationContext.SHUFFLE_IDLE;
|
||||
case HOP -> moving ? AnimationContext.HOP_WALK : AnimationContext.HOP_IDLE;
|
||||
case WADDLE -> moving ? AnimationContext.WADDLE_WALK : AnimationContext.WADDLE_IDLE;
|
||||
case CRAWL -> moving ? AnimationContext.CRAWL_MOVE : AnimationContext.CRAWL_IDLE;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the animation context for a Damsel NPC based on pose and movement.
|
||||
*
|
||||
* <p>Unlike players, NPCs support kneeling as a distinct posture and do not sneak.</p>
|
||||
*
|
||||
* @param entity the damsel entity (must not be null)
|
||||
* @return the resolved animation context, never null
|
||||
*/
|
||||
public static AnimationContext resolveNpc(AbstractTiedUpNpc entity) {
|
||||
boolean sitting = entity.isSitting();
|
||||
boolean kneeling = entity.isKneeling();
|
||||
boolean struggling = entity.isStruggling();
|
||||
boolean moving = entity.getDeltaMovement().horizontalDistanceSqr() > 1.0E-6;
|
||||
|
||||
if (sitting) {
|
||||
return struggling ? AnimationContext.SIT_STRUGGLE : AnimationContext.SIT_IDLE;
|
||||
}
|
||||
if (kneeling) {
|
||||
return struggling ? AnimationContext.KNEEL_STRUGGLE : AnimationContext.KNEEL_IDLE;
|
||||
}
|
||||
if (struggling) {
|
||||
return AnimationContext.STAND_STRUGGLE;
|
||||
}
|
||||
if (moving) {
|
||||
return AnimationContext.STAND_WALK;
|
||||
}
|
||||
return AnimationContext.STAND_IDLE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package com.tiedup.remake.client.animation.context;
|
||||
|
||||
import com.mojang.logging.LogUtils;
|
||||
import dev.kosmx.playerAnim.core.data.KeyframeAnimation;
|
||||
import dev.kosmx.playerAnim.minecraftApi.PlayerAnimationRegistry;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import org.slf4j.Logger;
|
||||
|
||||
/**
|
||||
* Builds context {@link KeyframeAnimation}s with item-owned body parts disabled.
|
||||
*
|
||||
* <p>Context animations (loaded from {@code context_*.json} files in the PlayerAnimator
|
||||
* registry) control the base body posture -- standing, sitting, walking, etc.
|
||||
* When a V2 bondage item "owns" certain body parts (e.g., handcuffs own rightArm + leftArm),
|
||||
* those parts must NOT be driven by the context animation because the item's own
|
||||
* GLB animation controls them instead.</p>
|
||||
*
|
||||
* <p>This factory loads the base context animation, creates a mutable copy, disables
|
||||
* the owned parts, and builds an immutable result. Results are cached by
|
||||
* {@code contextSuffix|ownedPartsHash} to avoid repeated copies.</p>
|
||||
*
|
||||
* <p>Thread safety: the cache uses {@link ConcurrentHashMap}. All methods are
|
||||
* called from the render thread, but the concurrent map avoids issues if
|
||||
* resource reload triggers on a different thread.</p>
|
||||
*
|
||||
* @see AnimationContext
|
||||
* @see RegionBoneMapper#computeOwnedParts
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public final class ContextAnimationFactory {
|
||||
|
||||
private static final Logger LOGGER = LogUtils.getLogger();
|
||||
private static final String NAMESPACE = "tiedup";
|
||||
|
||||
/**
|
||||
* Cache keyed by "contextSuffix|ownedPartsHashCode".
|
||||
* Null values are stored as sentinels for missing animations to avoid repeated lookups.
|
||||
*/
|
||||
private static final Map<String, KeyframeAnimation> CACHE = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* Sentinel set used to track cache keys where the base animation was not found,
|
||||
* so we don't log the same warning repeatedly.
|
||||
*/
|
||||
private static final Set<String> MISSING_WARNED = ConcurrentHashMap.newKeySet();
|
||||
|
||||
private ContextAnimationFactory() {}
|
||||
|
||||
/**
|
||||
* Create (or retrieve from cache) a context animation with the given parts disabled.
|
||||
*
|
||||
* <p>If no parts need disabling, the base animation is returned as-is (no copy needed).
|
||||
* If the base animation is not found in the PlayerAnimator registry, returns null.</p>
|
||||
*
|
||||
* @param context the current animation context (determines which context_*.json to load)
|
||||
* @param disabledParts set of PlayerAnimator part names to disable on the context layer
|
||||
* (e.g., {"rightArm", "leftArm"}), typically from
|
||||
* {@link RegionBoneMapper.BoneOwnership#disabledOnContext()}
|
||||
* @return the context animation with disabled parts suppressed, or null if not found
|
||||
*/
|
||||
@Nullable
|
||||
public static KeyframeAnimation create(AnimationContext context, Set<String> disabledParts) {
|
||||
String cacheKey = context.getAnimationSuffix() + "|" + String.join(",", new java.util.TreeSet<>(disabledParts));
|
||||
// computeIfAbsent cannot store null values, so we handle the missing case
|
||||
// by checking the MISSING_WARNED set to avoid redundant work.
|
||||
KeyframeAnimation cached = CACHE.get(cacheKey);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
if (MISSING_WARNED.contains(cacheKey)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
KeyframeAnimation result = buildContextAnimation(context, disabledParts);
|
||||
if (result != null) {
|
||||
CACHE.put(cacheKey, result);
|
||||
} else {
|
||||
MISSING_WARNED.add(cacheKey);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a context animation with the specified parts disabled.
|
||||
*
|
||||
* <p>Flow:
|
||||
* <ol>
|
||||
* <li>Check {@link ContextGlbRegistry} for a GLB-based context animation (takes priority)</li>
|
||||
* <li>Fall back to {@code tiedup:context_<suffix>} in PlayerAnimationRegistry (JSON-based)</li>
|
||||
* <li>If no parts need disabling, return the base animation directly (immutable, shared)</li>
|
||||
* <li>Otherwise, create a mutable copy via {@link KeyframeAnimation#mutableCopy()}</li>
|
||||
* <li>Disable each part via {@link KeyframeAnimation.StateCollection#setEnabled(boolean)}</li>
|
||||
* <li>Build and return the new immutable animation</li>
|
||||
* </ol>
|
||||
*/
|
||||
@Nullable
|
||||
private static KeyframeAnimation buildContextAnimation(AnimationContext context,
|
||||
Set<String> disabledParts) {
|
||||
String suffix = context.getAnimationSuffix();
|
||||
|
||||
// Priority 1: GLB-based context animation from ContextGlbRegistry
|
||||
KeyframeAnimation baseAnim = ContextGlbRegistry.get(suffix);
|
||||
|
||||
// Priority 2: JSON-based context animation from PlayerAnimationRegistry
|
||||
if (baseAnim == null) {
|
||||
ResourceLocation animId = ResourceLocation.fromNamespaceAndPath(
|
||||
NAMESPACE, "context_" + suffix
|
||||
);
|
||||
baseAnim = PlayerAnimationRegistry.getAnimation(animId);
|
||||
}
|
||||
|
||||
if (baseAnim == null) {
|
||||
LOGGER.warn("[V2Animation] Context animation not found for suffix: {}", suffix);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (disabledParts.isEmpty()) {
|
||||
return baseAnim;
|
||||
}
|
||||
|
||||
// Create mutable copy so we can disable parts without affecting the registry/cache original
|
||||
KeyframeAnimation.AnimationBuilder builder = baseAnim.mutableCopy();
|
||||
disableParts(builder, disabledParts);
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable all animation axes on the specified parts.
|
||||
*
|
||||
* <p>Uses {@link KeyframeAnimation.AnimationBuilder#getPart(String)} to look up parts
|
||||
* by name, then {@link KeyframeAnimation.StateCollection#setEnabled(boolean)} to disable
|
||||
* all axes (x, y, z, pitch, yaw, roll, and bend/bendDirection if applicable).</p>
|
||||
*
|
||||
* <p>Unknown part names are silently ignored -- this can happen if the disabled parts set
|
||||
* includes future bone names not present in the current context animation.</p>
|
||||
*/
|
||||
private static void disableParts(KeyframeAnimation.AnimationBuilder builder,
|
||||
Set<String> disabledParts) {
|
||||
for (String partName : disabledParts) {
|
||||
KeyframeAnimation.StateCollection part = builder.getPart(partName);
|
||||
if (part != null) {
|
||||
part.setEnabled(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached animations. Call this on resource reload or when equipped items change
|
||||
* in a way that might invalidate cached part ownership.
|
||||
*/
|
||||
public static void clearCache() {
|
||||
CACHE.clear();
|
||||
MISSING_WARNED.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.tiedup.remake.client.animation.context;
|
||||
|
||||
import com.tiedup.remake.client.gltf.GlbParser;
|
||||
import com.tiedup.remake.client.gltf.GltfData;
|
||||
import com.tiedup.remake.client.gltf.GltfPoseConverter;
|
||||
import dev.kosmx.playerAnim.core.data.KeyframeAnimation;
|
||||
import java.io.InputStream;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.server.packs.resources.Resource;
|
||||
import net.minecraft.server.packs.resources.ResourceManager;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
/**
|
||||
* Registry for context animations loaded from GLB files.
|
||||
*
|
||||
* <p>Scans the {@code tiedup_contexts/} resource directory for {@code .glb} files,
|
||||
* parses each one via {@link GlbParser}, converts to a {@link KeyframeAnimation}
|
||||
* via {@link GltfPoseConverter#convert(GltfData)}, and stores the result keyed by
|
||||
* the file name suffix (e.g., {@code "stand_walk"} from {@code tiedup_contexts/stand_walk.glb}).</p>
|
||||
*
|
||||
* <p>GLB context animations take priority over JSON-based PlayerAnimator context
|
||||
* animations. This allows artists to author posture animations directly in Blender
|
||||
* instead of hand-editing JSON keyframes.</p>
|
||||
*
|
||||
* <p>Reloaded on resource pack reload (F3+T) via the listener registered in
|
||||
* {@link com.tiedup.remake.client.gltf.GltfClientSetup}.</p>
|
||||
*
|
||||
* <p>Thread safety: the registry field is a volatile reference to an unmodifiable map.
|
||||
* {@link #reload} builds a new map on the reload thread then atomically swaps the
|
||||
* reference, so the render thread never sees a partially populated registry.</p>
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public final class ContextGlbRegistry {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
|
||||
|
||||
/** Resource directory containing context GLB files. */
|
||||
private static final String DIRECTORY = "tiedup_contexts";
|
||||
|
||||
/**
|
||||
* Registry keyed by context suffix (e.g., "stand_walk", "sit_idle").
|
||||
* Values are fully converted KeyframeAnimations with all parts enabled.
|
||||
*
|
||||
* <p>Volatile reference to an unmodifiable map. Reload builds a new map
|
||||
* and swaps atomically; the render thread always sees a consistent snapshot.</p>
|
||||
*/
|
||||
private static volatile Map<String, KeyframeAnimation> REGISTRY = Map.of();
|
||||
|
||||
private ContextGlbRegistry() {}
|
||||
|
||||
/**
|
||||
* Reload all context GLB files from the resource manager.
|
||||
*
|
||||
* <p>Scans {@code assets/<namespace>/tiedup_contexts/} for {@code .glb} files.
|
||||
* Each file is parsed and converted to a full-body KeyframeAnimation.
|
||||
* The context suffix is extracted from the file path:
|
||||
* {@code tiedup_contexts/stand_walk.glb} becomes key {@code "stand_walk"}.</p>
|
||||
*
|
||||
* <p>GLB files without animation data or with parse errors are logged and skipped.</p>
|
||||
*
|
||||
* @param resourceManager the current resource manager (from reload listener)
|
||||
*/
|
||||
public static void reload(ResourceManager resourceManager) {
|
||||
Map<String, KeyframeAnimation> newRegistry = new HashMap<>();
|
||||
|
||||
Map<ResourceLocation, Resource> resources = resourceManager.listResources(
|
||||
DIRECTORY, loc -> loc.getPath().endsWith(".glb"));
|
||||
|
||||
for (Map.Entry<ResourceLocation, Resource> entry : resources.entrySet()) {
|
||||
ResourceLocation loc = entry.getKey();
|
||||
Resource resource = entry.getValue();
|
||||
|
||||
// Extract suffix from path: "tiedup_contexts/stand_walk.glb" -> "stand_walk"
|
||||
String path = loc.getPath();
|
||||
String fileName = path.substring(path.lastIndexOf('/') + 1);
|
||||
String suffix = fileName.substring(0, fileName.length() - 4); // strip ".glb"
|
||||
|
||||
try (InputStream is = resource.open()) {
|
||||
GltfData data = GlbParser.parse(is, loc.toString());
|
||||
|
||||
// Convert to a full-body KeyframeAnimation (all parts enabled)
|
||||
KeyframeAnimation anim = GltfPoseConverter.convert(data);
|
||||
newRegistry.put(suffix, anim);
|
||||
|
||||
LOGGER.info("[GltfPipeline] Loaded context GLB: '{}' -> suffix '{}'", loc, suffix);
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("[GltfPipeline] Failed to load context GLB: {}", loc, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Atomic swap: render thread never sees a partially populated registry
|
||||
REGISTRY = Collections.unmodifiableMap(newRegistry);
|
||||
LOGGER.info("[ContextGlb] Loaded {} context GLB animations", newRegistry.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a context animation by suffix.
|
||||
*
|
||||
* @param contextSuffix the context suffix (e.g., "stand_walk", "sit_idle")
|
||||
* @return the KeyframeAnimation, or null if no GLB was found for this suffix
|
||||
*/
|
||||
@Nullable
|
||||
public static KeyframeAnimation get(String contextSuffix) {
|
||||
return REGISTRY.get(contextSuffix);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached context animations.
|
||||
* Called on resource reload and world unload.
|
||||
*/
|
||||
public static void clear() {
|
||||
REGISTRY = Map.of();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.tiedup.remake.client.animation.context;
|
||||
|
||||
import com.tiedup.remake.client.gltf.GltfCache;
|
||||
import com.tiedup.remake.client.gltf.GltfData;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Resolves which named animation to play from a GLB file based on the current
|
||||
* {@link AnimationContext}. Implements three features:
|
||||
*
|
||||
* <ol>
|
||||
* <li><b>Context-based resolution with fallback chain</b> — tries progressively
|
||||
* less specific animation names until one is found:
|
||||
* <pre>SitStruggle -> Struggle -> SitIdle -> Sit -> Idle -> null</pre></li>
|
||||
* <li><b>Animation variants</b> — if {@code Struggle.1}, {@code Struggle.2},
|
||||
* {@code Struggle.3} exist in the GLB, one is picked at random each time</li>
|
||||
* <li><b>Shared animation templates</b> — animations can come from a separate GLB
|
||||
* file (passed as {@code animationSource} to {@link #resolveAnimationData})</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>This class is stateless and thread-safe. All methods are static.</p>
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public final class GlbAnimationResolver {
|
||||
|
||||
private GlbAnimationResolver() {}
|
||||
|
||||
/**
|
||||
* Resolve the animation data source.
|
||||
* If {@code animationSource} is non-null, load that GLB for animations
|
||||
* (shared template). Otherwise use the item's own model GLB.
|
||||
*
|
||||
* @param itemModelLoc the item's GLB model resource location
|
||||
* @param animationSource optional separate GLB containing shared animations
|
||||
* @return parsed GLB data, or null if loading failed
|
||||
*/
|
||||
@Nullable
|
||||
public static GltfData resolveAnimationData(ResourceLocation itemModelLoc,
|
||||
@Nullable ResourceLocation animationSource) {
|
||||
ResourceLocation source = animationSource != null ? animationSource : itemModelLoc;
|
||||
return GltfCache.get(source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the best animation name from a GLB for the given context.
|
||||
* Supports variant selection ({@code Struggle.1}, {@code Struggle.2} -> random pick)
|
||||
* and full-body animations ({@code FullWalk}, {@code FullStruggle}).
|
||||
*
|
||||
* <p>Fallback chain (Full variants checked first at each step):</p>
|
||||
* <pre>
|
||||
* FullSitStruggle -> SitStruggle -> FullStruggle -> Struggle
|
||||
* -> FullSitIdle -> SitIdle -> FullSit -> Sit
|
||||
* -> FullIdle -> Idle -> null
|
||||
* </pre>
|
||||
*
|
||||
* @param data the parsed GLB data containing named animations
|
||||
* @param context the current animation context (posture + action)
|
||||
* @return the animation name to use, or null to use the default (first) clip
|
||||
*/
|
||||
@Nullable
|
||||
public static String resolve(GltfData data, AnimationContext context) {
|
||||
String prefix = context.getGlbContextPrefix(); // "Sit", "Kneel", "Sneak", "Walk", ""
|
||||
String variant = context.getGlbVariant(); // "Idle" or "Struggle"
|
||||
|
||||
// 1. Exact match: "FullSitIdle" then "SitIdle" (with variants)
|
||||
String exact = prefix + variant;
|
||||
if (!exact.isEmpty()) {
|
||||
String picked = pickWithVariants(data, "Full" + exact);
|
||||
if (picked != null) return picked;
|
||||
picked = pickWithVariants(data, exact);
|
||||
if (picked != null) return picked;
|
||||
}
|
||||
|
||||
// 2. For struggles: try "FullStruggle" then "Struggle" (with variants)
|
||||
if (context.isStruggling()) {
|
||||
String picked = pickWithVariants(data, "FullStruggle");
|
||||
if (picked != null) return picked;
|
||||
picked = pickWithVariants(data, "Struggle");
|
||||
if (picked != null) return picked;
|
||||
}
|
||||
|
||||
// 3. Context-only: "FullSit" then "Sit" (with variants)
|
||||
if (!prefix.isEmpty()) {
|
||||
String picked = pickWithVariants(data, "Full" + prefix);
|
||||
if (picked != null) return picked;
|
||||
picked = pickWithVariants(data, prefix);
|
||||
if (picked != null) return picked;
|
||||
}
|
||||
|
||||
// 4. Variant-only: "FullIdle" then "Idle" (with variants)
|
||||
{
|
||||
String picked = pickWithVariants(data, "Full" + variant);
|
||||
if (picked != null) return picked;
|
||||
picked = pickWithVariants(data, variant);
|
||||
if (picked != null) return picked;
|
||||
}
|
||||
|
||||
// 5. Default: return null = use first animation clip in GLB
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look for an animation by base name, including numbered variants.
|
||||
* <ul>
|
||||
* <li>If "Struggle" exists alone, return "Struggle"</li>
|
||||
* <li>If "Struggle.1" and "Struggle.2" exist, pick one randomly</li>
|
||||
* <li>If both "Struggle" and "Struggle.1" exist, include all in the random pool</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Variant numbering starts at 1 and tolerates a missing {@code .1}
|
||||
* (continues to check {@code .2}). Gaps after index 1 stop the scan.
|
||||
* For example, {@code Struggle.1, Struggle.3} would only find
|
||||
* {@code Struggle.1} because the gap at index 2 stops iteration.
|
||||
* However, if only {@code Struggle.2} exists (no {@code .1}), it will
|
||||
* still be found because the scan skips the first gap.</p>
|
||||
*
|
||||
* @param data the parsed GLB data
|
||||
* @param baseName the base animation name (e.g., "Struggle", "SitIdle")
|
||||
* @return the selected animation name, or null if no match found
|
||||
*/
|
||||
@Nullable
|
||||
private static String pickWithVariants(GltfData data, String baseName) {
|
||||
Map<String, GltfData.AnimationClip> anims = data.namedAnimations();
|
||||
List<String> candidates = new ArrayList<>();
|
||||
|
||||
if (anims.containsKey(baseName)) {
|
||||
candidates.add(baseName);
|
||||
}
|
||||
|
||||
// Check numbered variants: baseName.1, baseName.2, ...
|
||||
for (int i = 1; i <= 99; i++) {
|
||||
String variantName = baseName + "." + i;
|
||||
if (anims.containsKey(variantName)) {
|
||||
candidates.add(variantName);
|
||||
} else if (i > 1) {
|
||||
break; // Stop at first gap after .1
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.isEmpty()) return null;
|
||||
if (candidates.size() == 1) return candidates.get(0);
|
||||
return candidates.get(ThreadLocalRandom.current().nextInt(candidates.size()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
package com.tiedup.remake.client.animation.context;
|
||||
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import com.tiedup.remake.v2.bondage.IV2BondageItem;
|
||||
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenBondageItem;
|
||||
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemDefinition;
|
||||
import com.tiedup.remake.v2.bondage.datadriven.DataDrivenItemRegistry;
|
||||
import java.util.*;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Maps V2 body regions to PlayerAnimator part names.
|
||||
* Bridge between gameplay regions and animation bones.
|
||||
*
|
||||
* <p>PlayerAnimator uses 6 named parts: head, body, rightArm, leftArm, rightLeg, leftLeg.
|
||||
* This mapper translates the 14 {@link BodyRegionV2} gameplay regions into those bone names,
|
||||
* enabling the animation system to know which bones are "owned" by equipped bondage items.</p>
|
||||
*
|
||||
* <p>Regions without a direct bone mapping (NECK, FINGERS, TAIL, WINGS) return empty sets.
|
||||
* These regions still affect gameplay (blocking, escape difficulty) but don't directly
|
||||
* constrain animation bones.</p>
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public final class RegionBoneMapper {
|
||||
|
||||
/** All PlayerAnimator part names for the player model. */
|
||||
public static final Set<String> ALL_PARTS = Set.of(
|
||||
"head", "body", "rightArm", "leftArm", "rightLeg", "leftLeg"
|
||||
);
|
||||
|
||||
/**
|
||||
* Describes bone ownership for a specific item in the context of all equipped items.
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code thisParts} — parts owned exclusively by the winning item</li>
|
||||
* <li>{@code otherParts} — parts owned by other equipped items</li>
|
||||
* <li>{@link #freeParts()} — parts not owned by any item (available for animation)</li>
|
||||
* <li>{@link #enabledParts()} — parts the winning item may animate (owned + free)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>When both the winning item and another item claim the same bone,
|
||||
* the other item takes precedence (the bone goes to {@code otherParts}).</p>
|
||||
*/
|
||||
public record BoneOwnership(Set<String> thisParts, Set<String> otherParts) {
|
||||
|
||||
/**
|
||||
* Parts not owned by any item. These are "free" and can be animated
|
||||
* by the winning item IF the GLB contains keyframes for them.
|
||||
*/
|
||||
public Set<String> freeParts() {
|
||||
Set<String> free = new HashSet<>(ALL_PARTS);
|
||||
free.removeAll(thisParts);
|
||||
free.removeAll(otherParts);
|
||||
return Collections.unmodifiableSet(free);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parts the winning item is allowed to animate: its own parts + free parts.
|
||||
* Free parts are only actually enabled if the GLB has keyframes for them.
|
||||
*/
|
||||
public Set<String> enabledParts() {
|
||||
Set<String> enabled = new HashSet<>(thisParts);
|
||||
enabled.addAll(freeParts());
|
||||
return Collections.unmodifiableSet(enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parts that must be disabled on the context layer: parts owned by this item
|
||||
* (handled by item layer) + parts owned by other items (handled by their layer).
|
||||
* This equals ALL_PARTS minus freeParts.
|
||||
*/
|
||||
public Set<String> disabledOnContext() {
|
||||
Set<String> disabled = new HashSet<>(thisParts);
|
||||
disabled.addAll(otherParts);
|
||||
return Collections.unmodifiableSet(disabled);
|
||||
}
|
||||
}
|
||||
|
||||
private static final Map<BodyRegionV2, Set<String>> REGION_TO_PARTS;
|
||||
|
||||
static {
|
||||
Map<BodyRegionV2, Set<String>> map = new EnumMap<>(BodyRegionV2.class);
|
||||
map.put(BodyRegionV2.HEAD, Set.of("head"));
|
||||
map.put(BodyRegionV2.EYES, Set.of("head"));
|
||||
map.put(BodyRegionV2.EARS, Set.of("head"));
|
||||
map.put(BodyRegionV2.MOUTH, Set.of("head"));
|
||||
map.put(BodyRegionV2.NECK, Set.of());
|
||||
map.put(BodyRegionV2.TORSO, Set.of("body"));
|
||||
map.put(BodyRegionV2.ARMS, Set.of("rightArm", "leftArm"));
|
||||
map.put(BodyRegionV2.HANDS, Set.of("rightArm", "leftArm"));
|
||||
map.put(BodyRegionV2.FINGERS, Set.of());
|
||||
map.put(BodyRegionV2.WAIST, Set.of("body"));
|
||||
map.put(BodyRegionV2.LEGS, Set.of("rightLeg", "leftLeg"));
|
||||
map.put(BodyRegionV2.FEET, Set.of("rightLeg", "leftLeg"));
|
||||
map.put(BodyRegionV2.TAIL, Set.of());
|
||||
map.put(BodyRegionV2.WINGS, Set.of());
|
||||
REGION_TO_PARTS = Collections.unmodifiableMap(map);
|
||||
}
|
||||
|
||||
private RegionBoneMapper() {}
|
||||
|
||||
/**
|
||||
* Get the PlayerAnimator part names affected by a single body region.
|
||||
*
|
||||
* @param region the V2 body region
|
||||
* @return unmodifiable set of part name strings, never null (may be empty)
|
||||
*/
|
||||
public static Set<String> getPartsForRegion(BodyRegionV2 region) {
|
||||
return REGION_TO_PARTS.getOrDefault(region, Set.of());
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the union of all PlayerAnimator parts "owned" by equipped bondage items.
|
||||
*
|
||||
* <p>Iterates over the equipped map (as returned by
|
||||
* {@link com.tiedup.remake.v2.bondage.IV2BondageEquipment#getAllEquipped()})
|
||||
* and collects every bone affected by each item's occupied regions.</p>
|
||||
*
|
||||
* @param equipped map from representative region to equipped ItemStack
|
||||
* @return unmodifiable set of owned part name strings
|
||||
*/
|
||||
public static Set<String> computeOwnedParts(Map<BodyRegionV2, ItemStack> equipped) {
|
||||
Set<String> owned = new HashSet<>();
|
||||
for (Map.Entry<BodyRegionV2, ItemStack> entry : equipped.entrySet()) {
|
||||
ItemStack stack = entry.getValue();
|
||||
if (stack.getItem() instanceof IV2BondageItem v2Item) {
|
||||
for (BodyRegionV2 region : v2Item.getOccupiedRegions(stack)) {
|
||||
owned.addAll(getPartsForRegion(region));
|
||||
}
|
||||
}
|
||||
}
|
||||
return Collections.unmodifiableSet(owned);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute per-item bone ownership for a specific "winning" item.
|
||||
*
|
||||
* <p>Iterates over all equipped items. Parts owned by the winning item
|
||||
* go to {@code thisParts}; parts owned by other items go to {@code otherParts}.
|
||||
* If both the winning item and another item claim the same bone, the other
|
||||
* item takes precedence (conflict resolution: other wins).</p>
|
||||
*
|
||||
* <p>Uses ItemStack reference equality ({@code ==}) to identify the winning item
|
||||
* because the same ItemStack instance is used in the equipped map.</p>
|
||||
*
|
||||
* @param equipped map from representative region to equipped ItemStack
|
||||
* @param winningItemStack the ItemStack of the highest-priority V2 item with a GLB model
|
||||
* @return BoneOwnership describing this item's parts vs other items' parts
|
||||
*/
|
||||
public static BoneOwnership computePerItemParts(Map<BodyRegionV2, ItemStack> equipped,
|
||||
ItemStack winningItemStack) {
|
||||
Set<String> thisParts = new HashSet<>();
|
||||
Set<String> otherParts = new HashSet<>();
|
||||
|
||||
// Track which ItemStacks we've already processed to avoid duplicate work
|
||||
// (multiple regions can map to the same ItemStack)
|
||||
Set<ItemStack> processed = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
|
||||
for (Map.Entry<BodyRegionV2, ItemStack> entry : equipped.entrySet()) {
|
||||
ItemStack stack = entry.getValue();
|
||||
if (processed.contains(stack)) continue;
|
||||
processed.add(stack);
|
||||
|
||||
if (stack.getItem() instanceof IV2BondageItem v2Item) {
|
||||
Set<String> itemParts = new HashSet<>();
|
||||
for (BodyRegionV2 region : v2Item.getOccupiedRegions(stack)) {
|
||||
itemParts.addAll(getPartsForRegion(region));
|
||||
}
|
||||
|
||||
if (stack == winningItemStack) {
|
||||
thisParts.addAll(itemParts);
|
||||
} else {
|
||||
otherParts.addAll(itemParts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Conflict resolution: if both this item and another claim the same bone,
|
||||
// the other item takes precedence
|
||||
thisParts.removeAll(otherParts);
|
||||
|
||||
return new BoneOwnership(
|
||||
Collections.unmodifiableSet(thisParts),
|
||||
Collections.unmodifiableSet(otherParts)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of resolving the highest-priority V2 item with a GLB model.
|
||||
* Combines the model location, optional animation source, and the winning ItemStack
|
||||
* into a single object so callers don't need two separate iteration passes.
|
||||
*
|
||||
* @param modelLoc the GLB model ResourceLocation of the winning item
|
||||
* @param animSource separate GLB for animations (shared template), or null to use modelLoc
|
||||
* @param winningItem the actual ItemStack reference (for identity comparison in
|
||||
* {@link #computePerItemParts})
|
||||
*/
|
||||
public record GlbModelResult(ResourceLocation modelLoc, @Nullable ResourceLocation animSource,
|
||||
ItemStack winningItem) {}
|
||||
|
||||
/**
|
||||
* Animation info for a single equipped V2 item.
|
||||
* Used by the multi-item animation pipeline to process each item independently.
|
||||
*
|
||||
* @param modelLoc GLB model location (for rendering + default animation source)
|
||||
* @param animSource separate animation GLB, or null to use modelLoc
|
||||
* @param ownedParts parts this item exclusively owns (after conflict resolution)
|
||||
* @param posePriority the item's pose priority (for free-bone assignment)
|
||||
* @param animationBones per-animation bone whitelist from the data-driven definition.
|
||||
* Empty map for hardcoded items (no filtering applied).
|
||||
*/
|
||||
public record V2ItemAnimInfo(ResourceLocation modelLoc, @Nullable ResourceLocation animSource,
|
||||
Set<String> ownedParts, int posePriority,
|
||||
Map<String, Set<String>> animationBones) {}
|
||||
|
||||
/**
|
||||
* Find the highest-priority V2 item with a GLB model in the equipped map.
|
||||
*
|
||||
* <p>Single pass over all equipped items, comparing their
|
||||
* {@link IV2BondageItem#getPosePriority()} to select the dominant model.
|
||||
* Returns both the model location and the winning ItemStack reference so
|
||||
* callers can pass the ItemStack to {@link #computePerItemParts} without
|
||||
* a second iteration.</p>
|
||||
*
|
||||
* @param equipped map of equipped V2 items by body region (may be empty, never null)
|
||||
* @return the winning item's model info, or null if no V2 item has a GLB model (V1 fallback)
|
||||
*/
|
||||
@Nullable
|
||||
public static GlbModelResult resolveWinningItem(Map<BodyRegionV2, ItemStack> equipped) {
|
||||
ItemStack bestStack = null;
|
||||
ResourceLocation bestModel = null;
|
||||
int bestPriority = Integer.MIN_VALUE;
|
||||
for (Map.Entry<BodyRegionV2, ItemStack> entry : equipped.entrySet()) {
|
||||
ItemStack stack = entry.getValue();
|
||||
if (stack.getItem() instanceof IV2BondageItem v2Item) {
|
||||
ResourceLocation model = v2Item.getModelLocation(stack);
|
||||
if (model != null && v2Item.getPosePriority(stack) > bestPriority) {
|
||||
bestPriority = v2Item.getPosePriority(stack);
|
||||
bestModel = model;
|
||||
bestStack = stack;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (bestStack == null || bestModel == null) return null;
|
||||
|
||||
// Extract animation source from data-driven item definitions.
|
||||
// For hardcoded IV2BondageItem implementations, animSource stays null
|
||||
// (the model's own animations are used).
|
||||
ResourceLocation animSource = null;
|
||||
if (bestStack.getItem() instanceof DataDrivenBondageItem) {
|
||||
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(bestStack);
|
||||
if (def != null) {
|
||||
animSource = def.animationSource();
|
||||
}
|
||||
}
|
||||
|
||||
return new GlbModelResult(bestModel, animSource, bestStack);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve ALL equipped V2 items with GLB models, with per-item bone ownership.
|
||||
*
|
||||
* <p>Each item gets ownership of its declared regions' bones. When two items claim
|
||||
* the same bone, the higher-priority item wins. The highest-priority item is also
|
||||
* designated as the "free bone donor" — it can animate free bones if its GLB has
|
||||
* keyframes for them.</p>
|
||||
*
|
||||
* @param equipped map from representative region to equipped ItemStack
|
||||
* @return list of V2ItemAnimInfo, sorted by priority descending. Empty if no V2 items.
|
||||
* The first element (if any) is the free-bone donor.
|
||||
*/
|
||||
public static List<V2ItemAnimInfo> resolveAllV2Items(Map<BodyRegionV2, ItemStack> equipped) {
|
||||
record ItemEntry(ItemStack stack, IV2BondageItem v2Item, ResourceLocation model,
|
||||
@Nullable ResourceLocation animSource, Set<String> rawParts, int priority,
|
||||
Map<String, Set<String>> animationBones) {}
|
||||
|
||||
List<ItemEntry> entries = new ArrayList<>();
|
||||
Set<ItemStack> seen = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
|
||||
for (Map.Entry<BodyRegionV2, ItemStack> entry : equipped.entrySet()) {
|
||||
ItemStack stack = entry.getValue();
|
||||
if (seen.contains(stack)) continue;
|
||||
seen.add(stack);
|
||||
|
||||
if (stack.getItem() instanceof IV2BondageItem v2Item) {
|
||||
ResourceLocation model = v2Item.getModelLocation(stack);
|
||||
if (model == null) continue;
|
||||
|
||||
Set<String> rawParts = new HashSet<>();
|
||||
for (BodyRegionV2 region : v2Item.getOccupiedRegions(stack)) {
|
||||
rawParts.addAll(getPartsForRegion(region));
|
||||
}
|
||||
if (rawParts.isEmpty()) continue;
|
||||
|
||||
ResourceLocation animSource = null;
|
||||
Map<String, Set<String>> animBones = Map.of();
|
||||
if (stack.getItem() instanceof DataDrivenBondageItem) {
|
||||
DataDrivenItemDefinition def = DataDrivenItemRegistry.get(stack);
|
||||
if (def != null) {
|
||||
animSource = def.animationSource();
|
||||
animBones = def.animationBones();
|
||||
}
|
||||
}
|
||||
|
||||
entries.add(new ItemEntry(stack, v2Item, model, animSource, rawParts,
|
||||
v2Item.getPosePriority(stack), animBones));
|
||||
}
|
||||
}
|
||||
|
||||
if (entries.isEmpty()) return List.of();
|
||||
|
||||
entries.sort((a, b) -> Integer.compare(b.priority(), a.priority()));
|
||||
|
||||
Set<String> claimed = new HashSet<>();
|
||||
List<V2ItemAnimInfo> result = new ArrayList<>();
|
||||
|
||||
for (ItemEntry e : entries) {
|
||||
Set<String> ownedParts = new HashSet<>(e.rawParts());
|
||||
ownedParts.removeAll(claimed);
|
||||
if (ownedParts.isEmpty()) continue;
|
||||
claimed.addAll(ownedParts);
|
||||
result.add(new V2ItemAnimInfo(e.model(), e.animSource(),
|
||||
Collections.unmodifiableSet(ownedParts), e.priority(), e.animationBones()));
|
||||
}
|
||||
|
||||
return Collections.unmodifiableList(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the set of all bone parts owned by any item in the resolved list.
|
||||
* Used to disable owned parts on the context layer.
|
||||
*/
|
||||
public static Set<String> computeAllOwnedParts(List<V2ItemAnimInfo> items) {
|
||||
Set<String> allOwned = new HashSet<>();
|
||||
for (V2ItemAnimInfo item : items) {
|
||||
allOwned.addAll(item.ownedParts());
|
||||
}
|
||||
return Collections.unmodifiableSet(allOwned);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package com.tiedup.remake.client.animation.render;
|
||||
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import com.tiedup.remake.items.base.ItemBind;
|
||||
import com.tiedup.remake.items.base.PoseType;
|
||||
import com.tiedup.remake.state.HumanChairHelper;
|
||||
import com.tiedup.remake.state.PlayerBindState;
|
||||
import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
|
||||
import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
|
||||
import net.minecraft.client.player.AbstractClientPlayer;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import net.minecraftforge.client.event.RenderPlayerEvent;
|
||||
import net.minecraftforge.eventbus.api.EventPriority;
|
||||
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||
import net.minecraftforge.fml.common.Mod;
|
||||
|
||||
/**
|
||||
* Handles DOG and HUMAN_CHAIR pose rendering adjustments.
|
||||
*
|
||||
* <p>Applies vertical offset and smooth body rotation for DOG/HUMAN_CHAIR poses.
|
||||
* Runs at HIGH priority to ensure transforms are applied before other Pre handlers.
|
||||
*
|
||||
* <p>Extracted from PlayerArmHideEventHandler for single-responsibility.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
@Mod.EventBusSubscriber(
|
||||
modid = TiedUpMod.MOD_ID,
|
||||
bus = Mod.EventBusSubscriber.Bus.FORGE,
|
||||
value = Dist.CLIENT
|
||||
)
|
||||
public class DogPoseRenderHandler {
|
||||
|
||||
/**
|
||||
* DOG pose state tracking per player.
|
||||
* Stores: [0: smoothedTarget, 1: currentRot, 2: appliedDelta, 3: isMoving (0/1)]
|
||||
*/
|
||||
private static final Int2ObjectMap<float[]> dogPoseState =
|
||||
new Int2ObjectOpenHashMap<>();
|
||||
|
||||
// Array indices for dogPoseState
|
||||
private static final int IDX_TARGET = 0;
|
||||
private static final int IDX_CURRENT = 1;
|
||||
private static final int IDX_DELTA = 2;
|
||||
private static final int IDX_MOVING = 3;
|
||||
|
||||
/**
|
||||
* Get the rotation delta applied to a player's render for DOG pose.
|
||||
* Used by MixinPlayerModel to compensate head rotation.
|
||||
*/
|
||||
public static float getAppliedRotationDelta(int playerId) {
|
||||
float[] state = dogPoseState.get(playerId);
|
||||
return state != null ? state[IDX_DELTA] : 0f;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a player is currently moving in DOG pose.
|
||||
*/
|
||||
public static boolean isDogPoseMoving(int playerId) {
|
||||
float[] state = dogPoseState.get(playerId);
|
||||
return state != null && state[IDX_MOVING] > 0.5f;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all DOG pose state data.
|
||||
* Called on world unload to prevent memory leaks.
|
||||
*/
|
||||
public static void clearState() {
|
||||
dogPoseState.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Before player render: Apply vertical offset and rotation for DOG/HUMAN_CHAIR poses.
|
||||
* HIGH priority ensures this runs before arm/item hiding handlers.
|
||||
*/
|
||||
@SubscribeEvent(priority = EventPriority.HIGH)
|
||||
public static void onRenderPlayerPre(RenderPlayerEvent.Pre event) {
|
||||
Player player = event.getEntity();
|
||||
if (!(player instanceof AbstractClientPlayer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (player.isRemoved() || !player.isAlive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
PlayerBindState state = PlayerBindState.getInstance(player);
|
||||
if (state == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
ItemStack bindForPose = state.getEquipment(BodyRegionV2.ARMS);
|
||||
if (
|
||||
bindForPose.isEmpty() ||
|
||||
!(bindForPose.getItem() instanceof ItemBind itemBind)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
PoseType bindPoseType = itemBind.getPoseType();
|
||||
// Check for humanChairMode NBT override
|
||||
bindPoseType = HumanChairHelper.resolveEffectivePose(
|
||||
bindPoseType,
|
||||
bindForPose
|
||||
);
|
||||
|
||||
if (
|
||||
bindPoseType != PoseType.DOG && bindPoseType != PoseType.HUMAN_CHAIR
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Lower player by 6 model units (6/16 = 0.375 blocks)
|
||||
event
|
||||
.getPoseStack()
|
||||
.translate(0, RenderConstants.DOG_AND_PETBED_Y_OFFSET, 0);
|
||||
|
||||
int playerId = player.getId();
|
||||
net.minecraft.world.phys.Vec3 movement = player.getDeltaMovement();
|
||||
boolean isMoving = movement.horizontalDistanceSqr() > 0.0001;
|
||||
|
||||
// Get or create state - initialize to current body rotation
|
||||
float[] s = dogPoseState.get(playerId);
|
||||
if (s == null) {
|
||||
s = new float[] { player.yBodyRot, player.yBodyRot, 0f, 0f };
|
||||
dogPoseState.put(playerId, s);
|
||||
}
|
||||
|
||||
// Human chair: lock rotation state — body must not turn
|
||||
if (bindPoseType == PoseType.HUMAN_CHAIR) {
|
||||
s[IDX_CURRENT] = player.yBodyRot;
|
||||
s[IDX_TARGET] = player.yBodyRot;
|
||||
s[IDX_DELTA] = 0f;
|
||||
s[IDX_MOVING] = 0f;
|
||||
} else {
|
||||
// Determine target rotation
|
||||
float rawTarget;
|
||||
if (isMoving) {
|
||||
// Moving: face movement direction
|
||||
rawTarget = (float) Math.toDegrees(
|
||||
Math.atan2(-movement.x, movement.z)
|
||||
);
|
||||
} else {
|
||||
// Stationary: face where head is looking
|
||||
rawTarget = player.yHeadRot;
|
||||
}
|
||||
|
||||
// Check if head would be clamped (body lagging behind head)
|
||||
float predictedHeadYaw = net.minecraft.util.Mth.wrapDegrees(
|
||||
player.yHeadRot - s[IDX_CURRENT]
|
||||
);
|
||||
float maxYaw = isMoving
|
||||
? RenderConstants.HEAD_MAX_YAW_MOVING
|
||||
: RenderConstants.HEAD_MAX_YAW_STATIONARY;
|
||||
boolean headAtLimit =
|
||||
Math.abs(predictedHeadYaw) >
|
||||
maxYaw * RenderConstants.HEAD_AT_LIMIT_RATIO;
|
||||
|
||||
if (headAtLimit && !isMoving) {
|
||||
// Head at limit while stationary: snap body to release head
|
||||
float sign = predictedHeadYaw > 0 ? 1f : -1f;
|
||||
s[IDX_CURRENT] =
|
||||
player.yHeadRot -
|
||||
sign * maxYaw * RenderConstants.HEAD_SNAP_RELEASE_RATIO;
|
||||
s[IDX_TARGET] = s[IDX_CURRENT];
|
||||
} else {
|
||||
// Normal smoothing
|
||||
float targetDelta = net.minecraft.util.Mth.wrapDegrees(
|
||||
rawTarget - s[IDX_TARGET]
|
||||
);
|
||||
float targetSpeed = isMoving
|
||||
? RenderConstants.DOG_TARGET_SPEED_MOVING
|
||||
: RenderConstants.DOG_TARGET_SPEED_STATIONARY;
|
||||
s[IDX_TARGET] += targetDelta * targetSpeed;
|
||||
|
||||
float rotDelta = net.minecraft.util.Mth.wrapDegrees(
|
||||
s[IDX_TARGET] - s[IDX_CURRENT]
|
||||
);
|
||||
float speed = isMoving
|
||||
? RenderConstants.DOG_ROT_SPEED_MOVING
|
||||
: RenderConstants.DOG_ROT_SPEED_STATIONARY;
|
||||
s[IDX_CURRENT] += rotDelta * speed;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate and store the delta we apply to poseStack
|
||||
s[IDX_DELTA] = player.yBodyRot - s[IDX_CURRENT];
|
||||
s[IDX_MOVING] = isMoving ? 1f : 0f;
|
||||
|
||||
// Apply rotation to make body face our custom direction
|
||||
event
|
||||
.getPoseStack()
|
||||
.mulPose(com.mojang.math.Axis.YP.rotationDegrees(s[IDX_DELTA]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.tiedup.remake.client.animation.render;
|
||||
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
import com.tiedup.remake.state.PlayerBindState;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.player.LocalPlayer;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import net.minecraftforge.client.event.RenderHandEvent;
|
||||
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||
import net.minecraftforge.fml.common.Mod;
|
||||
|
||||
/**
|
||||
* Hide first-person hand/item rendering based on bondage state.
|
||||
*
|
||||
* Behavior:
|
||||
* - Tied up: Hide hands completely (hands are behind back)
|
||||
* - Mittens: Hide hands + items (Forge limitation - can't separate them)
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
@Mod.EventBusSubscriber(
|
||||
modid = TiedUpMod.MOD_ID,
|
||||
bus = Mod.EventBusSubscriber.Bus.FORGE,
|
||||
value = Dist.CLIENT
|
||||
)
|
||||
public class FirstPersonHandHideHandler {
|
||||
|
||||
@SubscribeEvent
|
||||
public static void onRenderHand(RenderHandEvent event) {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
if (mc == null) {
|
||||
return;
|
||||
}
|
||||
LocalPlayer player = mc.player;
|
||||
|
||||
if (player == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
PlayerBindState state = PlayerBindState.getInstance(player);
|
||||
if (state == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Tied or Mittens: hide hands completely
|
||||
// (Forge limitation: RenderHandEvent controls hand + item together)
|
||||
if (state.isTiedUp() || state.hasMittens()) {
|
||||
event.setCanceled(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.tiedup.remake.client.animation.render;
|
||||
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
import com.tiedup.remake.state.PlayerBindState;
|
||||
import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
|
||||
import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
|
||||
import net.minecraft.client.player.AbstractClientPlayer;
|
||||
import net.minecraft.world.InteractionHand;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import net.minecraftforge.client.event.RenderPlayerEvent;
|
||||
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||
import net.minecraftforge.fml.common.Mod;
|
||||
|
||||
/**
|
||||
* Hides held items when player has arms bound or is wearing mittens.
|
||||
*
|
||||
* <p>Uses Pre/Post pattern to temporarily replace held items with empty
|
||||
* stacks for rendering, then restore them after.
|
||||
*
|
||||
* <p>Extracted from PlayerArmHideEventHandler for single-responsibility.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
@Mod.EventBusSubscriber(
|
||||
modid = TiedUpMod.MOD_ID,
|
||||
bus = Mod.EventBusSubscriber.Bus.FORGE,
|
||||
value = Dist.CLIENT
|
||||
)
|
||||
public class HeldItemHideHandler {
|
||||
|
||||
/**
|
||||
* Stored items to restore after rendering.
|
||||
* Key: Player entity ID (int), Value: [mainHand, offHand]
|
||||
*/
|
||||
private static final Int2ObjectMap<ItemStack[]> storedItems =
|
||||
new Int2ObjectOpenHashMap<>();
|
||||
|
||||
@SubscribeEvent
|
||||
public static void onRenderPlayerPre(RenderPlayerEvent.Pre event) {
|
||||
Player player = event.getEntity();
|
||||
if (!(player instanceof AbstractClientPlayer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (player.isRemoved() || !player.isAlive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
PlayerBindState state = PlayerBindState.getInstance(player);
|
||||
if (state == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
boolean hasArmsBound = state.hasArmsBound();
|
||||
boolean hasMittens = state.hasMittens();
|
||||
|
||||
if (hasArmsBound || hasMittens) {
|
||||
ItemStack mainHand = player.getItemInHand(
|
||||
InteractionHand.MAIN_HAND
|
||||
);
|
||||
ItemStack offHand = player.getItemInHand(InteractionHand.OFF_HAND);
|
||||
|
||||
if (!mainHand.isEmpty() || !offHand.isEmpty()) {
|
||||
storedItems.put(
|
||||
player.getId(),
|
||||
new ItemStack[] { mainHand.copy(), offHand.copy() }
|
||||
);
|
||||
|
||||
player.setItemInHand(
|
||||
InteractionHand.MAIN_HAND,
|
||||
ItemStack.EMPTY
|
||||
);
|
||||
player.setItemInHand(InteractionHand.OFF_HAND, ItemStack.EMPTY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public static void onRenderPlayerPost(RenderPlayerEvent.Post event) {
|
||||
Player player = event.getEntity();
|
||||
if (!(player instanceof AbstractClientPlayer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
ItemStack[] items = storedItems.remove(player.getId());
|
||||
if (items != null) {
|
||||
player.setItemInHand(InteractionHand.MAIN_HAND, items[0]);
|
||||
player.setItemInHand(InteractionHand.OFF_HAND, items[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.tiedup.remake.client.animation.render;
|
||||
|
||||
import com.tiedup.remake.client.state.PetBedClientState;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
import com.tiedup.remake.items.base.ItemBind;
|
||||
import com.tiedup.remake.items.base.PoseType;
|
||||
import com.tiedup.remake.state.HumanChairHelper;
|
||||
import com.tiedup.remake.state.PlayerBindState;
|
||||
import net.minecraft.client.player.AbstractClientPlayer;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import net.minecraftforge.client.event.RenderPlayerEvent;
|
||||
import net.minecraftforge.eventbus.api.EventPriority;
|
||||
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||
import net.minecraftforge.fml.common.Mod;
|
||||
|
||||
/**
|
||||
* Handles pet bed render adjustments (SIT and SLEEP modes).
|
||||
*
|
||||
* <p>Applies vertical offset and forced standing pose for pet bed states.
|
||||
* Runs at HIGH priority alongside DogPoseRenderHandler.
|
||||
*
|
||||
* <p>Extracted from PlayerArmHideEventHandler for single-responsibility.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
@Mod.EventBusSubscriber(
|
||||
modid = TiedUpMod.MOD_ID,
|
||||
bus = Mod.EventBusSubscriber.Bus.FORGE,
|
||||
value = Dist.CLIENT
|
||||
)
|
||||
public class PetBedRenderHandler {
|
||||
|
||||
/**
|
||||
* Before player render: Apply vertical offset and forced pose for pet bed.
|
||||
*/
|
||||
@SubscribeEvent(priority = EventPriority.HIGH)
|
||||
public static void onRenderPlayerPre(RenderPlayerEvent.Pre event) {
|
||||
Player player = event.getEntity();
|
||||
if (!(player instanceof AbstractClientPlayer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (player.isRemoved() || !player.isAlive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
java.util.UUID petBedUuid = player.getUUID();
|
||||
byte petBedMode = PetBedClientState.get(petBedUuid);
|
||||
|
||||
if (petBedMode == 1 || petBedMode == 2) {
|
||||
// Skip Y-offset if DogPoseRenderHandler already applies it
|
||||
// (DOG/HUMAN_CHAIR pose uses the same offset amount)
|
||||
if (!isDogOrChairPose(player)) {
|
||||
event
|
||||
.getPoseStack()
|
||||
.translate(0, RenderConstants.DOG_AND_PETBED_Y_OFFSET, 0);
|
||||
}
|
||||
}
|
||||
if (petBedMode == 2) {
|
||||
// SLEEP: force STANDING pose to prevent vanilla sleeping rotation
|
||||
player.setForcedPose(net.minecraft.world.entity.Pose.STANDING);
|
||||
|
||||
// Compensate for vanilla sleeping Y offset
|
||||
player
|
||||
.getSleepingPos()
|
||||
.ifPresent(pos -> {
|
||||
double yOffset = player.getY() - pos.getY();
|
||||
if (yOffset > 0.01) {
|
||||
event.getPoseStack().translate(0, -yOffset, 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the player is in DOG or HUMAN_CHAIR pose.
|
||||
* Used to avoid double Y-offset with DogPoseRenderHandler.
|
||||
*/
|
||||
private static boolean isDogOrChairPose(Player player) {
|
||||
PlayerBindState state = PlayerBindState.getInstance(player);
|
||||
if (state == null) return false;
|
||||
ItemStack bind = state.getEquipment(BodyRegionV2.ARMS);
|
||||
if (
|
||||
bind.isEmpty() || !(bind.getItem() instanceof ItemBind itemBind)
|
||||
) return false;
|
||||
PoseType pose = HumanChairHelper.resolveEffectivePose(
|
||||
itemBind.getPoseType(),
|
||||
bind
|
||||
);
|
||||
return pose == PoseType.DOG || pose == PoseType.HUMAN_CHAIR;
|
||||
}
|
||||
|
||||
/**
|
||||
* After player render: Restore forced pose for pet bed SLEEP mode.
|
||||
*/
|
||||
@SubscribeEvent
|
||||
public static void onRenderPlayerPost(RenderPlayerEvent.Post event) {
|
||||
Player player = event.getEntity();
|
||||
if (!(player instanceof AbstractClientPlayer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
byte petBedMode = PetBedClientState.get(player.getUUID());
|
||||
if (petBedMode == 2) {
|
||||
player.setForcedPose(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package com.tiedup.remake.client.animation.render;
|
||||
|
||||
import com.tiedup.remake.client.renderer.layers.ClothesRenderHelper;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
import com.tiedup.remake.items.base.ItemBind;
|
||||
import com.tiedup.remake.items.base.PoseType;
|
||||
import com.tiedup.remake.items.clothes.ClothesProperties;
|
||||
import com.tiedup.remake.state.PlayerBindState;
|
||||
import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
|
||||
import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
|
||||
import net.minecraft.client.model.PlayerModel;
|
||||
import net.minecraft.client.player.AbstractClientPlayer;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import net.minecraftforge.client.event.RenderPlayerEvent;
|
||||
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||
import net.minecraftforge.fml.common.Mod;
|
||||
|
||||
/**
|
||||
* Hide player arms and outer layers based on bondage/clothes state.
|
||||
*
|
||||
* <p>Responsibilities (after extraction of dog pose, pet bed, and held items):
|
||||
* <ul>
|
||||
* <li>Hide arms for wrap/latex_sack poses</li>
|
||||
* <li>Hide outer layers (hat, jacket, sleeves, pants) based on clothes settings</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Uses Pre/Post pattern to temporarily modify and restore state.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
@Mod.EventBusSubscriber(
|
||||
modid = TiedUpMod.MOD_ID,
|
||||
bus = Mod.EventBusSubscriber.Bus.FORGE,
|
||||
value = Dist.CLIENT
|
||||
)
|
||||
public class PlayerArmHideEventHandler {
|
||||
|
||||
/**
|
||||
* Stored layer visibility to restore after rendering.
|
||||
* Key: Player entity ID (int), Value: [hat, jacket, leftSleeve, rightSleeve, leftPants, rightPants]
|
||||
*/
|
||||
private static final Int2ObjectMap<boolean[]> storedLayers =
|
||||
new Int2ObjectOpenHashMap<>();
|
||||
|
||||
/**
|
||||
* Before player render:
|
||||
* - Hide arms for wrap/latex_sack poses
|
||||
* - Hide outer layers based on clothes settings (Phase 19)
|
||||
*/
|
||||
@SubscribeEvent
|
||||
public static void onRenderPlayerPre(RenderPlayerEvent.Pre event) {
|
||||
Player player = event.getEntity();
|
||||
if (!(player instanceof AbstractClientPlayer clientPlayer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (player.isRemoved() || !player.isAlive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
PlayerBindState state = PlayerBindState.getInstance(player);
|
||||
if (state == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
PlayerModel<?> model = event.getRenderer().getModel();
|
||||
|
||||
// === HIDE ARMS (wrap/latex_sack poses) ===
|
||||
if (state.hasArmsBound()) {
|
||||
ItemStack bind = state.getEquipment(BodyRegionV2.ARMS);
|
||||
if (
|
||||
!bind.isEmpty() && bind.getItem() instanceof ItemBind itemBind
|
||||
) {
|
||||
PoseType poseType = itemBind.getPoseType();
|
||||
|
||||
// Only hide arms for wrap/sack poses (arms are covered by the item)
|
||||
if (
|
||||
poseType == PoseType.WRAP || poseType == PoseType.LATEX_SACK
|
||||
) {
|
||||
model.leftArm.visible = false;
|
||||
model.rightArm.visible = false;
|
||||
model.leftSleeve.visible = false;
|
||||
model.rightSleeve.visible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === HIDE WEARER LAYERS (clothes settings) - Phase 19 ===
|
||||
ItemStack clothes = state.getEquipment(BodyRegionV2.TORSO);
|
||||
if (!clothes.isEmpty()) {
|
||||
ClothesProperties props =
|
||||
ClothesRenderHelper.getPropsForLayerHiding(
|
||||
clothes,
|
||||
clientPlayer
|
||||
);
|
||||
if (props != null) {
|
||||
boolean[] savedLayers = ClothesRenderHelper.hideWearerLayers(
|
||||
model,
|
||||
props
|
||||
);
|
||||
if (savedLayers != null) {
|
||||
storedLayers.put(player.getId(), savedLayers);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After player render: Restore arm visibility and layer visibility.
|
||||
*/
|
||||
@SubscribeEvent
|
||||
public static void onRenderPlayerPost(RenderPlayerEvent.Post event) {
|
||||
Player player = event.getEntity();
|
||||
if (!(player instanceof AbstractClientPlayer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
PlayerModel<?> model = event.getRenderer().getModel();
|
||||
|
||||
// === RESTORE ARM VISIBILITY ===
|
||||
model.leftArm.visible = true;
|
||||
model.rightArm.visible = true;
|
||||
model.leftSleeve.visible = true;
|
||||
model.rightSleeve.visible = true;
|
||||
|
||||
// === RESTORE WEARER LAYERS - Phase 19 ===
|
||||
boolean[] savedLayers = storedLayers.remove(player.getId());
|
||||
if (savedLayers != null) {
|
||||
ClothesRenderHelper.restoreWearerLayers(model, savedLayers);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.tiedup.remake.client.animation.render;
|
||||
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Centralizes magic numbers used across render handlers.
|
||||
*
|
||||
* <p>DOG pose rotation smoothing, head clamp limits, and vertical offsets
|
||||
* that were previously scattered as unnamed literals.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public final class RenderConstants {
|
||||
|
||||
private RenderConstants() {}
|
||||
|
||||
// === DOG pose rotation smoothing speeds ===
|
||||
|
||||
/** Speed for smoothing body rotation toward target while moving */
|
||||
public static final float DOG_ROT_SPEED_MOVING = 0.15f;
|
||||
|
||||
/** Speed for smoothing body rotation toward target while stationary */
|
||||
public static final float DOG_ROT_SPEED_STATIONARY = 0.12f;
|
||||
|
||||
/** Speed for smoothing target rotation while moving */
|
||||
public static final float DOG_TARGET_SPEED_MOVING = 0.2f;
|
||||
|
||||
/** Speed for smoothing target rotation while stationary */
|
||||
public static final float DOG_TARGET_SPEED_STATIONARY = 0.3f;
|
||||
|
||||
// === Head clamp limits ===
|
||||
|
||||
/** Maximum head yaw relative to body while moving (degrees) */
|
||||
public static final float HEAD_MAX_YAW_MOVING = 60f;
|
||||
|
||||
/** Maximum head yaw relative to body while stationary (degrees) */
|
||||
public static final float HEAD_MAX_YAW_STATIONARY = 90f;
|
||||
|
||||
/** Threshold ratio for detecting head-at-limit (triggers body snap) */
|
||||
public static final float HEAD_AT_LIMIT_RATIO = 0.85f;
|
||||
|
||||
/** Ratio of max yaw to snap body to when releasing head */
|
||||
public static final float HEAD_SNAP_RELEASE_RATIO = 0.7f;
|
||||
|
||||
// === Vertical offsets (model units, 16 = 1 block) ===
|
||||
|
||||
/** Y offset for DOG and PET BED poses (6/16 = 0.375 blocks) */
|
||||
public static final double DOG_AND_PETBED_Y_OFFSET = -6.0 / 16.0;
|
||||
|
||||
/** Y offset for Damsel sitting pose (model units) */
|
||||
public static final float DAMSEL_SIT_OFFSET = -10.0f;
|
||||
|
||||
/** Y offset for Damsel kneeling pose (model units) */
|
||||
public static final float DAMSEL_KNEEL_OFFSET = -5.0f;
|
||||
|
||||
/** Y offset for Damsel dog pose (model units) */
|
||||
public static final float DAMSEL_DOG_OFFSET = -7.0f;
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
package com.tiedup.remake.client.animation.tick;
|
||||
|
||||
import com.mojang.logging.LogUtils;
|
||||
import com.tiedup.remake.client.animation.AnimationStateRegistry;
|
||||
import com.tiedup.remake.client.animation.BondageAnimationManager;
|
||||
import com.tiedup.remake.client.animation.PendingAnimationManager;
|
||||
import com.tiedup.remake.client.animation.util.AnimationIdBuilder;
|
||||
import com.tiedup.remake.client.events.CellHighlightHandler;
|
||||
import com.tiedup.remake.client.events.LeashProxyClientHandler;
|
||||
import com.tiedup.remake.client.gltf.GltfAnimationApplier;
|
||||
import com.tiedup.remake.client.state.ClothesClientCache;
|
||||
import com.tiedup.remake.client.state.MovementStyleClientState;
|
||||
import com.tiedup.remake.client.state.PetBedClientState;
|
||||
import com.tiedup.remake.items.base.ItemBind;
|
||||
import com.tiedup.remake.items.base.PoseType;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import com.tiedup.remake.v2.bondage.IV2BondageEquipment;
|
||||
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
|
||||
import com.tiedup.remake.v2.bondage.movement.MovementStyle;
|
||||
import com.tiedup.remake.client.animation.context.AnimationContext;
|
||||
import com.tiedup.remake.client.animation.context.AnimationContextResolver;
|
||||
import com.tiedup.remake.client.animation.context.RegionBoneMapper;
|
||||
import com.tiedup.remake.state.HumanChairHelper;
|
||||
import com.tiedup.remake.state.PlayerBindState;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.player.AbstractClientPlayer;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import net.minecraftforge.event.TickEvent;
|
||||
import net.minecraftforge.event.entity.player.PlayerEvent;
|
||||
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||
import net.minecraftforge.fml.common.Mod;
|
||||
import org.slf4j.Logger;
|
||||
|
||||
/**
|
||||
* Event handler for player animation tick updates.
|
||||
*
|
||||
* <p>Simplified handler that:
|
||||
* <ul>
|
||||
* <li>Tracks tied/struggling/sneaking state for players</li>
|
||||
* <li>Plays animations via BondageAnimationManager when state changes</li>
|
||||
* <li>Handles cleanup on logout/world unload</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Registered on the FORGE event bus (not MOD bus).
|
||||
*/
|
||||
@Mod.EventBusSubscriber(
|
||||
modid = "tiedup",
|
||||
value = Dist.CLIENT,
|
||||
bus = Mod.EventBusSubscriber.Bus.FORGE
|
||||
)
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public class AnimationTickHandler {
|
||||
|
||||
private static final Logger LOGGER = LogUtils.getLogger();
|
||||
|
||||
/** Tick counter for periodic cleanup tasks */
|
||||
private static int cleanupTickCounter = 0;
|
||||
|
||||
/**
|
||||
* Client tick event - called every tick on the client.
|
||||
* Updates animations for all players when their bondage state changes.
|
||||
*/
|
||||
@SubscribeEvent
|
||||
public static void onClientTick(TickEvent.ClientTickEvent event) {
|
||||
if (event.phase != TickEvent.Phase.END) {
|
||||
return;
|
||||
}
|
||||
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
if (mc.level == null || mc.isPaused()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Process pending animations first (retry failed animations for remote players)
|
||||
PendingAnimationManager.processPending(mc.level);
|
||||
|
||||
// Periodic cleanup of stale cache entries (every 60 seconds = 1200 ticks)
|
||||
if (++cleanupTickCounter >= 1200) {
|
||||
cleanupTickCounter = 0;
|
||||
ClothesClientCache.cleanupStale();
|
||||
}
|
||||
|
||||
// Then update all player animations
|
||||
for (Player player : mc.level.players()) {
|
||||
if (player instanceof AbstractClientPlayer clientPlayer) {
|
||||
updatePlayerAnimation(clientPlayer);
|
||||
}
|
||||
// Safety: remove stale furniture animations for players no longer on seats
|
||||
BondageAnimationManager.tickFurnitureSafety(player);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update animation for a single player.
|
||||
*/
|
||||
private static void updatePlayerAnimation(AbstractClientPlayer player) {
|
||||
// Safety check: skip for removed/dead players
|
||||
if (player.isRemoved() || !player.isAlive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
PlayerBindState state = PlayerBindState.getInstance(player);
|
||||
UUID uuid = player.getUUID();
|
||||
|
||||
// Check if player has ANY V2 bondage item equipped (not just ARMS).
|
||||
// isTiedUp() only checks ARMS, but items on LEGS, HEAD, etc. also need animation.
|
||||
boolean isTied = state != null && (state.isTiedUp()
|
||||
|| V2EquipmentHelper.hasAnyEquipment(player));
|
||||
boolean wasTied =
|
||||
AnimationStateRegistry.getLastTiedState().getOrDefault(uuid, false);
|
||||
|
||||
// Pet bed animations take priority over bondage animations
|
||||
if (PetBedClientState.get(uuid) != 0) {
|
||||
// Lock body rotation to bed facing (prevents camera from rotating the model)
|
||||
float lockedRot = PetBedClientState.getFacing(uuid);
|
||||
player.yBodyRot = lockedRot;
|
||||
player.yBodyRotO = lockedRot;
|
||||
|
||||
// Clamp head rotation to ±50° from body (like vehicle)
|
||||
float headRot = player.getYHeadRot();
|
||||
float clamped =
|
||||
lockedRot +
|
||||
net.minecraft.util.Mth.clamp(
|
||||
net.minecraft.util.Mth.wrapDegrees(headRot - lockedRot),
|
||||
-50f,
|
||||
50f
|
||||
);
|
||||
player.setYHeadRot(clamped);
|
||||
player.yHeadRotO = clamped;
|
||||
|
||||
AnimationStateRegistry.getLastTiedState().put(uuid, isTied);
|
||||
return;
|
||||
}
|
||||
|
||||
// Human chair: clamp 1st-person camera only (body lock handled by MixinLivingEntityBodyRot)
|
||||
// NO return — animation HUMAN_CHAIR must continue playing below
|
||||
if (isTied && state != null) {
|
||||
ItemStack chairBind = state.getEquipment(BodyRegionV2.ARMS);
|
||||
if (HumanChairHelper.isActive(chairBind)) {
|
||||
// 1st person only: clamp yRot so player can't look behind
|
||||
// 3rd person: yRot untouched → camera orbits freely 360°
|
||||
if (
|
||||
player == Minecraft.getInstance().player &&
|
||||
Minecraft.getInstance().options.getCameraType() ==
|
||||
net.minecraft.client.CameraType.FIRST_PERSON
|
||||
) {
|
||||
float lockedRot = HumanChairHelper.getFacing(chairBind);
|
||||
float camClamped =
|
||||
lockedRot +
|
||||
net.minecraft.util.Mth.clamp(
|
||||
net.minecraft.util.Mth.wrapDegrees(
|
||||
player.getYRot() - lockedRot
|
||||
),
|
||||
-90f,
|
||||
90f
|
||||
);
|
||||
player.setYRot(camClamped);
|
||||
player.yRotO =
|
||||
lockedRot +
|
||||
net.minecraft.util.Mth.clamp(
|
||||
net.minecraft.util.Mth.wrapDegrees(
|
||||
player.yRotO - lockedRot
|
||||
),
|
||||
-90f,
|
||||
90f
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isTied) {
|
||||
// Resolve V2 equipped items
|
||||
IV2BondageEquipment equipment = V2EquipmentHelper.getEquipment(player);
|
||||
Map<BodyRegionV2, ItemStack> equipped = equipment != null
|
||||
? equipment.getAllEquipped() : Map.of();
|
||||
|
||||
// Resolve ALL V2 items with GLB models and per-item bone ownership
|
||||
java.util.List<RegionBoneMapper.V2ItemAnimInfo> v2Items =
|
||||
RegionBoneMapper.resolveAllV2Items(equipped);
|
||||
|
||||
if (!v2Items.isEmpty()) {
|
||||
// V2 path: multi-item composite animation
|
||||
java.util.Set<String> allOwnedParts = RegionBoneMapper.computeAllOwnedParts(v2Items);
|
||||
MovementStyle activeStyle = MovementStyleClientState.get(player.getUUID());
|
||||
AnimationContext context = AnimationContextResolver.resolve(player, state, activeStyle);
|
||||
GltfAnimationApplier.applyMultiItemV2Animation(player, v2Items, context, allOwnedParts);
|
||||
// Clear V1 tracking so transition back works
|
||||
AnimationStateRegistry.getLastAnimId().remove(uuid);
|
||||
} else {
|
||||
// V1 fallback
|
||||
if (GltfAnimationApplier.hasActiveState(player)) {
|
||||
GltfAnimationApplier.clearV2Animation(player);
|
||||
}
|
||||
String animId = buildAnimationId(player, state);
|
||||
String lastId = AnimationStateRegistry.getLastAnimId().get(uuid);
|
||||
if (!animId.equals(lastId)) {
|
||||
boolean success = BondageAnimationManager.playAnimation(player, animId);
|
||||
if (success) {
|
||||
AnimationStateRegistry.getLastAnimId().put(uuid, animId);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (wasTied) {
|
||||
// Was tied, now free - stop all animations
|
||||
if (GltfAnimationApplier.hasActiveState(player)) {
|
||||
GltfAnimationApplier.clearV2Animation(player);
|
||||
} else {
|
||||
BondageAnimationManager.stopAnimation(player);
|
||||
}
|
||||
AnimationStateRegistry.getLastAnimId().remove(uuid);
|
||||
}
|
||||
|
||||
AnimationStateRegistry.getLastTiedState().put(uuid, isTied);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build animation ID from player's current state (V1 path).
|
||||
*/
|
||||
private static String buildAnimationId(
|
||||
Player player,
|
||||
PlayerBindState state
|
||||
) {
|
||||
ItemStack bind = state.getEquipment(BodyRegionV2.ARMS);
|
||||
PoseType poseType = PoseType.STANDARD;
|
||||
|
||||
if (bind.getItem() instanceof ItemBind itemBind) {
|
||||
poseType = itemBind.getPoseType();
|
||||
|
||||
// Human chair mode: override DOG pose to HUMAN_CHAIR (straight limbs)
|
||||
poseType = HumanChairHelper.resolveEffectivePose(poseType, bind);
|
||||
}
|
||||
|
||||
// Derive bound state from V2 regions (works client-side, synced via capability)
|
||||
boolean armsBound = V2EquipmentHelper.isRegionOccupied(player, BodyRegionV2.ARMS);
|
||||
boolean legsBound = V2EquipmentHelper.isRegionOccupied(player, BodyRegionV2.LEGS);
|
||||
|
||||
// V1 fallback: if no V2 regions are set but player is tied, derive from ItemBind NBT
|
||||
if (!armsBound && !legsBound && bind.getItem() instanceof ItemBind) {
|
||||
armsBound = ItemBind.hasArmsBound(bind);
|
||||
legsBound = ItemBind.hasLegsBound(bind);
|
||||
}
|
||||
|
||||
boolean isStruggling = state.isStruggling();
|
||||
boolean isSneaking = player.isCrouching();
|
||||
boolean isMoving =
|
||||
player.getDeltaMovement().horizontalDistanceSqr() > 1e-6;
|
||||
|
||||
// Build animation ID with sneak and movement support
|
||||
return AnimationIdBuilder.build(
|
||||
poseType,
|
||||
armsBound,
|
||||
legsBound,
|
||||
null,
|
||||
isStruggling,
|
||||
true,
|
||||
isSneaking,
|
||||
isMoving
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Player logout event - cleanup animation data.
|
||||
*/
|
||||
@SubscribeEvent
|
||||
public static void onPlayerLogout(PlayerEvent.PlayerLoggedOutEvent event) {
|
||||
if (event.getEntity().level().isClientSide()) {
|
||||
UUID uuid = event.getEntity().getUUID();
|
||||
AnimationStateRegistry.getLastTiedState().remove(uuid);
|
||||
AnimationStateRegistry.getLastAnimId().remove(uuid);
|
||||
BondageAnimationManager.cleanup(uuid);
|
||||
GltfAnimationApplier.removeTracking(uuid);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* World unload event - clear all animation and cache data.
|
||||
* FIX: Now also clears client-side caches to prevent memory leaks and stale data.
|
||||
*/
|
||||
@SubscribeEvent
|
||||
public static void onWorldUnload(
|
||||
net.minecraftforge.event.level.LevelEvent.Unload event
|
||||
) {
|
||||
if (event.getLevel().isClientSide()) {
|
||||
// Animation state (includes BondageAnimationManager, PendingAnimationManager,
|
||||
// DogPoseRenderHandler, MCAAnimationTickCache)
|
||||
// AnimationStateRegistry.clearAll() handles GltfAnimationApplier.clearAll() transitively
|
||||
AnimationStateRegistry.clearAll();
|
||||
|
||||
// Non-animation client-side caches
|
||||
PetBedClientState.clearAll();
|
||||
MovementStyleClientState.clearAll();
|
||||
com.tiedup.remake.client.state.CollarRegistryClient.clear();
|
||||
CellHighlightHandler.clearCache();
|
||||
LeashProxyClientHandler.clearAll();
|
||||
com.tiedup.remake.client.state.ClientLaborState.clearTask();
|
||||
com.tiedup.remake.client.state.ClothesClientCache.clearAll();
|
||||
com.tiedup.remake.client.texture.DynamicTextureManager.getInstance().clearAll();
|
||||
|
||||
// C1: Player bind state client instances (prevents stale Player references)
|
||||
PlayerBindState.clearClientInstances();
|
||||
|
||||
// C2: Armor stand bondage data (entity IDs are not stable across worlds)
|
||||
com.tiedup.remake.entities.armorstand.ArmorStandBondageClientCache.clear();
|
||||
|
||||
// C3: Furniture GLB model cache (resource-backed, also cleared on F3+T reload)
|
||||
com.tiedup.remake.v2.furniture.client.FurnitureGltfCache.clear();
|
||||
|
||||
LOGGER.debug(
|
||||
"Cleared all animation and cache data due to world unload"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.tiedup.remake.client.animation.tick;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Cache for MCA villager animation tick tracking.
|
||||
* Used by MixinVillagerEntityBaseModelMCA to prevent animations from ticking
|
||||
* multiple times per game tick.
|
||||
*
|
||||
* <p>This is extracted from the mixin so it can be cleared on world unload
|
||||
* to prevent memory leaks.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public final class MCAAnimationTickCache {
|
||||
|
||||
private static final Map<UUID, Integer> lastTickMap = new HashMap<>();
|
||||
|
||||
private MCAAnimationTickCache() {
|
||||
// Utility class
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last tick value for an entity.
|
||||
* @param uuid Entity UUID
|
||||
* @return Last tick value, or -1 if not cached
|
||||
*/
|
||||
public static int getLastTick(UUID uuid) {
|
||||
return lastTickMap.getOrDefault(uuid, -1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the last tick value for an entity.
|
||||
* @param uuid Entity UUID
|
||||
* @param tick Current tick value
|
||||
*/
|
||||
public static void setLastTick(UUID uuid, int tick) {
|
||||
lastTickMap.put(uuid, tick);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached data.
|
||||
* Called on world unload to prevent memory leaks.
|
||||
*/
|
||||
public static void clear() {
|
||||
lastTickMap.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package com.tiedup.remake.client.animation.tick;
|
||||
|
||||
import com.tiedup.remake.client.animation.BondageAnimationManager;
|
||||
import com.tiedup.remake.client.animation.context.AnimationContext;
|
||||
import com.tiedup.remake.client.animation.context.AnimationContextResolver;
|
||||
import com.tiedup.remake.client.animation.context.RegionBoneMapper;
|
||||
import com.tiedup.remake.client.animation.util.AnimationIdBuilder;
|
||||
import com.tiedup.remake.client.gltf.GltfAnimationApplier;
|
||||
import com.tiedup.remake.entities.AbstractTiedUpNpc;
|
||||
import com.tiedup.remake.entities.EntityMaster;
|
||||
import com.tiedup.remake.entities.ai.master.MasterState;
|
||||
import com.tiedup.remake.items.base.ItemBind;
|
||||
import com.tiedup.remake.items.base.PoseType;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import com.tiedup.remake.v2.bondage.IV2BondageEquipment;
|
||||
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.UUID;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import net.minecraftforge.event.TickEvent;
|
||||
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||
import net.minecraftforge.fml.common.Mod;
|
||||
|
||||
/**
|
||||
* Tick handler for NPC (AbstractTiedUpNpc) bondage animations.
|
||||
*
|
||||
* <p>Same pattern as AnimationTickHandler for players, but for loaded
|
||||
* AbstractTiedUpNpc instances. Tracks last animation ID per NPC UUID and
|
||||
* triggers BondageAnimationManager.playAnimation() on state changes.
|
||||
*
|
||||
* <p>Extracted from DamselModel.setupAnim() to decouple animation
|
||||
* triggering from model rendering.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
@Mod.EventBusSubscriber(
|
||||
modid = "tiedup",
|
||||
value = Dist.CLIENT,
|
||||
bus = Mod.EventBusSubscriber.Bus.FORGE
|
||||
)
|
||||
public class NpcAnimationTickHandler {
|
||||
|
||||
/** Track last animation ID per NPC to avoid redundant updates */
|
||||
private static final Map<UUID, String> lastNpcAnimId = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* Client tick: update animations for all loaded AbstractTiedUpNpc instances.
|
||||
*/
|
||||
@SubscribeEvent
|
||||
public static void onClientTick(TickEvent.ClientTickEvent event) {
|
||||
if (event.phase != TickEvent.Phase.END) {
|
||||
return;
|
||||
}
|
||||
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
if (mc.level == null || mc.isPaused()) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (Entity entity : mc.level.entitiesForRendering()) {
|
||||
if (
|
||||
entity instanceof AbstractTiedUpNpc damsel &&
|
||||
entity.isAlive() &&
|
||||
!entity.isRemoved()
|
||||
) {
|
||||
updateNpcAnimation(damsel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update animation for a single NPC.
|
||||
*
|
||||
* <p>Dual-layer V2 path: if the highest-priority equipped V2 item has a GLB model,
|
||||
* uses {@link GltfAnimationApplier#applyV2Animation} which plays a context layer
|
||||
* (base posture) and an item layer (GLB-driven bones). Sitting and kneeling are
|
||||
* handled by the context resolver, so the V2 path now covers all postures.
|
||||
*
|
||||
* <p>V1 fallback: if no V2 GLB model is found, falls back to JSON-based
|
||||
* PlayerAnimator animations via {@link BondageAnimationManager}.
|
||||
*/
|
||||
private static void updateNpcAnimation(AbstractTiedUpNpc entity) {
|
||||
boolean inPose =
|
||||
entity.isTiedUp() || entity.isSitting() || entity.isKneeling();
|
||||
|
||||
UUID uuid = entity.getUUID();
|
||||
|
||||
if (inPose) {
|
||||
// Resolve V2 equipment map
|
||||
IV2BondageEquipment equipment = V2EquipmentHelper.getEquipment(entity);
|
||||
Map<BodyRegionV2, net.minecraft.world.item.ItemStack> equipped = equipment != null
|
||||
? equipment.getAllEquipped() : Map.of();
|
||||
RegionBoneMapper.GlbModelResult glbResult = RegionBoneMapper.resolveWinningItem(equipped);
|
||||
|
||||
if (glbResult != null) {
|
||||
// V2 path: dual-layer animation with per-item bone ownership
|
||||
RegionBoneMapper.BoneOwnership ownership =
|
||||
RegionBoneMapper.computePerItemParts(equipped, glbResult.winningItem());
|
||||
AnimationContext context = AnimationContextResolver.resolveNpc(entity);
|
||||
GltfAnimationApplier.applyV2Animation(entity, glbResult.modelLoc(),
|
||||
glbResult.animSource(), context, ownership);
|
||||
lastNpcAnimId.remove(uuid);
|
||||
} else {
|
||||
// V1 fallback: JSON-based PlayerAnimator animations
|
||||
if (GltfAnimationApplier.hasActiveState(entity)) {
|
||||
GltfAnimationApplier.clearV2Animation(entity);
|
||||
}
|
||||
|
||||
String animId = buildNpcAnimationId(entity);
|
||||
String lastId = lastNpcAnimId.get(uuid);
|
||||
if (!animId.equals(lastId)) {
|
||||
BondageAnimationManager.playAnimation(entity, animId);
|
||||
lastNpcAnimId.put(uuid, animId);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (lastNpcAnimId.containsKey(uuid) || GltfAnimationApplier.hasActiveState(entity)) {
|
||||
if (GltfAnimationApplier.hasActiveState(entity)) {
|
||||
GltfAnimationApplier.clearV2Animation(entity);
|
||||
} else {
|
||||
BondageAnimationManager.stopAnimation(entity);
|
||||
}
|
||||
lastNpcAnimId.remove(uuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build animation ID for an NPC from its current state (V1 path).
|
||||
*/
|
||||
private static String buildNpcAnimationId(AbstractTiedUpNpc entity) {
|
||||
// Determine position prefix for SIT/KNEEL poses
|
||||
String positionPrefix = null;
|
||||
if (entity.isSitting()) {
|
||||
positionPrefix = "sit";
|
||||
} else if (entity.isKneeling()) {
|
||||
positionPrefix = "kneel";
|
||||
}
|
||||
|
||||
net.minecraft.world.item.ItemStack bind = entity.getEquipment(BodyRegionV2.ARMS);
|
||||
PoseType poseType = PoseType.STANDARD;
|
||||
boolean hasBind = false;
|
||||
|
||||
if (bind.getItem() instanceof ItemBind itemBind) {
|
||||
poseType = itemBind.getPoseType();
|
||||
hasBind = true;
|
||||
}
|
||||
|
||||
// Derive bound state from V2 regions (AbstractTiedUpNpc implements IV2EquipmentHolder)
|
||||
boolean armsBound = V2EquipmentHelper.isRegionOccupied(entity, BodyRegionV2.ARMS);
|
||||
boolean legsBound = V2EquipmentHelper.isRegionOccupied(entity, BodyRegionV2.LEGS);
|
||||
|
||||
// V1 fallback: if no V2 regions set but NPC has a bind, derive from ItemBind NBT
|
||||
if (!armsBound && !legsBound && bind.getItem() instanceof ItemBind) {
|
||||
armsBound = ItemBind.hasArmsBound(bind);
|
||||
legsBound = ItemBind.hasLegsBound(bind);
|
||||
}
|
||||
|
||||
boolean isStruggling = entity.isStruggling();
|
||||
boolean isSneaking = entity.isCrouching();
|
||||
boolean isMoving =
|
||||
entity.getDeltaMovement().horizontalDistanceSqr() > 1e-6;
|
||||
|
||||
String animId = AnimationIdBuilder.build(
|
||||
poseType,
|
||||
armsBound,
|
||||
legsBound,
|
||||
positionPrefix,
|
||||
isStruggling,
|
||||
hasBind,
|
||||
isSneaking,
|
||||
isMoving
|
||||
);
|
||||
|
||||
// Master NPC sitting on human chair: use dedicated sitting animation
|
||||
if (
|
||||
entity instanceof EntityMaster masterEntity &&
|
||||
masterEntity.getMasterState() == MasterState.HUMAN_CHAIR &&
|
||||
masterEntity.isSitting()
|
||||
) {
|
||||
animId = "master_chair_sit_idle";
|
||||
}
|
||||
|
||||
return animId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all NPC animation state.
|
||||
* Called on world unload to prevent memory leaks.
|
||||
*/
|
||||
public static void clearAll() {
|
||||
lastNpcAnimId.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package com.tiedup.remake.client.animation.util;
|
||||
|
||||
import com.tiedup.remake.items.base.PoseType;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Utility class for building animation ResourceLocation IDs.
|
||||
*
|
||||
* <p>Centralizes the logic for constructing animation file names.
|
||||
* Used by BondageAnimationManager, NpcAnimationTickHandler, and AnimationTickHandler.
|
||||
*
|
||||
* <p>Animation naming convention:
|
||||
* <pre>
|
||||
* {poseType}_{bindMode}_{variant}.json
|
||||
*
|
||||
* poseType: tied_up_basic | straitjacket | wrap | latex_sack
|
||||
* bindMode: (empty for FULL) | _arms | _legs
|
||||
* variant: _idle | _struggle | (empty for static)
|
||||
* </pre>
|
||||
*
|
||||
* <p>Examples:
|
||||
* <ul>
|
||||
* <li>tiedup:tied_up_basic_idle - STANDARD + FULL + idle</li>
|
||||
* <li>tiedup:straitjacket_arms_struggle - STRAITJACKET + ARMS + struggle</li>
|
||||
* <li>tiedup:wrap_idle - WRAP + FULL + idle</li>
|
||||
* </ul>
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public final class AnimationIdBuilder {
|
||||
|
||||
private static final String NAMESPACE = "tiedup";
|
||||
|
||||
// Bind mode suffixes
|
||||
private static final String SUFFIX_ARMS = "_arms";
|
||||
private static final String SUFFIX_LEGS = "_legs";
|
||||
|
||||
// Variant suffixes
|
||||
private static final String SUFFIX_IDLE = "_idle";
|
||||
private static final String SUFFIX_WALK = "_walk";
|
||||
private static final String SUFFIX_STRUGGLE = "_struggle";
|
||||
private static final String SUFFIX_SNEAK = "_sneak";
|
||||
|
||||
private AnimationIdBuilder() {
|
||||
// Utility class - no instantiation
|
||||
}
|
||||
|
||||
/**
|
||||
* Get base animation name from pose type.
|
||||
* Delegates to {@link PoseType#getAnimationId()}.
|
||||
*
|
||||
* @param poseType Pose type
|
||||
* @return Base name string
|
||||
*/
|
||||
public static String getBaseName(PoseType poseType) {
|
||||
return poseType.getAnimationId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get suffix for bind mode derived from region flags.
|
||||
*
|
||||
* @param armsBound whether ARMS region is occupied
|
||||
* @param legsBound whether LEGS region is occupied
|
||||
* @return Suffix string: "" for FULL (both), "_arms" for arms-only, "_legs" for legs-only
|
||||
*/
|
||||
public static String getModeSuffix(boolean armsBound, boolean legsBound) {
|
||||
if (armsBound && legsBound) return ""; // FULL has no suffix
|
||||
if (armsBound) return SUFFIX_ARMS;
|
||||
if (legsBound) return SUFFIX_LEGS;
|
||||
return ""; // neither bound = no suffix (shouldn't happen in practice)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bind type name for SIT/KNEEL animations.
|
||||
* Delegates to {@link PoseType#getBindTypeName()}.
|
||||
*
|
||||
* @param poseType Pose type
|
||||
* @return Bind type name ("basic", "straitjacket", "wrap", "latex_sack")
|
||||
*/
|
||||
public static String getBindTypeName(PoseType poseType) {
|
||||
return poseType.getBindTypeName();
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Unified Build Method
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Build animation ID string for entities.
|
||||
*
|
||||
* <p>This method handles all cases:
|
||||
* <ul>
|
||||
* <li>Standing poses: tied_up_basic_idle, straitjacket_struggle, etc.</li>
|
||||
* <li>Sitting poses: sit_basic_idle, sit_free_idle, etc.</li>
|
||||
* <li>Kneeling poses: kneel_basic_idle, kneel_wrap_struggle, etc.</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param poseType The bind pose type (STANDARD, STRAITJACKET, etc.)
|
||||
* @param armsBound whether ARMS region is occupied
|
||||
* @param legsBound whether LEGS region is occupied
|
||||
* @param positionPrefix Position prefix ("sit", "kneel") or null for standing
|
||||
* @param isStruggling Whether entity is struggling
|
||||
* @param hasBind Whether entity has a bind equipped
|
||||
* @return Animation ID string (without namespace)
|
||||
*/
|
||||
public static String build(
|
||||
PoseType poseType,
|
||||
boolean armsBound,
|
||||
boolean legsBound,
|
||||
String positionPrefix,
|
||||
boolean isStruggling,
|
||||
boolean hasBind
|
||||
) {
|
||||
return build(
|
||||
poseType,
|
||||
armsBound,
|
||||
legsBound,
|
||||
positionPrefix,
|
||||
isStruggling,
|
||||
hasBind,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build animation ID string for entities with sneak support.
|
||||
*
|
||||
* @param poseType The bind pose type (STANDARD, STRAITJACKET, etc.)
|
||||
* @param armsBound whether ARMS region is occupied
|
||||
* @param legsBound whether LEGS region is occupied
|
||||
* @param positionPrefix Position prefix ("sit", "kneel") or null for standing
|
||||
* @param isStruggling Whether entity is struggling
|
||||
* @param hasBind Whether entity has a bind equipped
|
||||
* @param isSneaking Whether entity is sneaking
|
||||
* @return Animation ID string (without namespace)
|
||||
*/
|
||||
public static String build(
|
||||
PoseType poseType,
|
||||
boolean armsBound,
|
||||
boolean legsBound,
|
||||
String positionPrefix,
|
||||
boolean isStruggling,
|
||||
boolean hasBind,
|
||||
boolean isSneaking
|
||||
) {
|
||||
return build(
|
||||
poseType,
|
||||
armsBound,
|
||||
legsBound,
|
||||
positionPrefix,
|
||||
isStruggling,
|
||||
hasBind,
|
||||
isSneaking,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build animation ID string for entities with sneak and movement support.
|
||||
*
|
||||
* @param poseType The bind pose type (STANDARD, STRAITJACKET, etc.)
|
||||
* @param armsBound whether ARMS region is occupied
|
||||
* @param legsBound whether LEGS region is occupied
|
||||
* @param positionPrefix Position prefix ("sit", "kneel") or null for standing
|
||||
* @param isStruggling Whether entity is struggling
|
||||
* @param hasBind Whether entity has a bind equipped
|
||||
* @param isSneaking Whether entity is sneaking
|
||||
* @param isMoving Whether entity is moving
|
||||
* @return Animation ID string (without namespace)
|
||||
*/
|
||||
public static String build(
|
||||
PoseType poseType,
|
||||
boolean armsBound,
|
||||
boolean legsBound,
|
||||
String positionPrefix,
|
||||
boolean isStruggling,
|
||||
boolean hasBind,
|
||||
boolean isSneaking,
|
||||
boolean isMoving
|
||||
) {
|
||||
String sneakSuffix = isSneaking ? SUFFIX_SNEAK : "";
|
||||
|
||||
// Determine variant suffix based on state priority: struggle > walk > idle
|
||||
String variantSuffix;
|
||||
if (isStruggling) {
|
||||
variantSuffix = SUFFIX_STRUGGLE;
|
||||
} else if (isMoving && poseType == PoseType.DOG) {
|
||||
// DOG pose has a walking animation (tied_up_dog_walk.json)
|
||||
variantSuffix = SUFFIX_WALK;
|
||||
} else {
|
||||
variantSuffix = SUFFIX_IDLE;
|
||||
}
|
||||
|
||||
// SIT or KNEEL pose
|
||||
if (positionPrefix != null) {
|
||||
if (!hasBind) {
|
||||
// No bind: free pose (arms natural)
|
||||
return positionPrefix + "_free" + sneakSuffix + variantSuffix;
|
||||
}
|
||||
|
||||
// Has bind
|
||||
String bindTypeName;
|
||||
if (legsBound && !armsBound) {
|
||||
// LEGS-only mode = arms free
|
||||
bindTypeName = "legs";
|
||||
} else {
|
||||
// FULL or ARMS mode
|
||||
bindTypeName = getBindTypeName(poseType);
|
||||
}
|
||||
return (
|
||||
positionPrefix +
|
||||
"_" +
|
||||
bindTypeName +
|
||||
sneakSuffix +
|
||||
variantSuffix
|
||||
);
|
||||
}
|
||||
|
||||
// Standing pose (no position prefix)
|
||||
String baseName = getBaseName(poseType);
|
||||
String modeSuffix = getModeSuffix(armsBound, legsBound);
|
||||
|
||||
// LEGS-only mode: only lock legs, arms are free - no idle/struggle variants needed
|
||||
if (legsBound && !armsBound) {
|
||||
return baseName + modeSuffix;
|
||||
}
|
||||
|
||||
return baseName + modeSuffix + sneakSuffix + variantSuffix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build animation ResourceLocation for SIT or KNEEL pose.
|
||||
*
|
||||
* @param posePrefix "sit" or "kneel"
|
||||
* @param poseType Bind pose type
|
||||
* @param armsBound whether ARMS region is occupied
|
||||
* @param legsBound whether LEGS region is occupied
|
||||
* @param isStruggling Whether entity is struggling
|
||||
* @return Animation ResourceLocation
|
||||
*/
|
||||
public static ResourceLocation buildPositionAnimation(
|
||||
String posePrefix,
|
||||
PoseType poseType,
|
||||
boolean armsBound,
|
||||
boolean legsBound,
|
||||
boolean isStruggling
|
||||
) {
|
||||
String bindTypeName;
|
||||
if (legsBound && !armsBound) {
|
||||
bindTypeName = "legs";
|
||||
} else {
|
||||
bindTypeName = getBindTypeName(poseType);
|
||||
}
|
||||
|
||||
String variantSuffix = isStruggling ? SUFFIX_STRUGGLE : SUFFIX_IDLE;
|
||||
String animationName = posePrefix + "_" + bindTypeName + variantSuffix;
|
||||
return ResourceLocation.fromNamespaceAndPath(NAMESPACE, animationName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build animation ResourceLocation for SIT or KNEEL pose when NOT bound.
|
||||
*
|
||||
* @param posePrefix "sit" or "kneel"
|
||||
* @return Animation ResourceLocation for free pose
|
||||
*/
|
||||
public static ResourceLocation buildFreePositionAnimation(
|
||||
String posePrefix
|
||||
) {
|
||||
String animationName = posePrefix + "_free" + SUFFIX_IDLE;
|
||||
return ResourceLocation.fromNamespaceAndPath(NAMESPACE, animationName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package com.tiedup.remake.client.animation.util;
|
||||
|
||||
import net.minecraft.client.model.geom.ModelPart;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Utility class for DOG pose head compensation.
|
||||
*
|
||||
* <h2>Problem</h2>
|
||||
* <p>When in DOG pose, the body is rotated -90° pitch (horizontal, face down).
|
||||
* This makes the head point at the ground. We need to compensate:
|
||||
* <ul>
|
||||
* <li>Head pitch: add -90° offset so head looks forward</li>
|
||||
* <li>Head yaw: convert to zRot (roll) since yRot axis is sideways</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h2>Architecture: Players vs NPCs</h2>
|
||||
* <pre>
|
||||
* ┌─────────────────────────────────────────────────────────────────┐
|
||||
* │ PLAYERS │
|
||||
* ├─────────────────────────────────────────────────────────────────┤
|
||||
* │ 1. PlayerArmHideEventHandler.onRenderPlayerPre() │
|
||||
* │ - Offset vertical (-6 model units) │
|
||||
* │ - Rotation Y lissée (dogPoseState tracking) │
|
||||
* │ │
|
||||
* │ 2. Animation (PlayerAnimator) │
|
||||
* │ - body.pitch = -90° → appliqué au PoseStack automatiquement │
|
||||
* │ │
|
||||
* │ 3. MixinPlayerModel.setupAnim() @TAIL │
|
||||
* │ - Uses DogPoseHelper.applyHeadCompensationClamped() │
|
||||
* └─────────────────────────────────────────────────────────────────┘
|
||||
*
|
||||
* ┌─────────────────────────────────────────────────────────────────┐
|
||||
* │ NPCs │
|
||||
* ├─────────────────────────────────────────────────────────────────┤
|
||||
* │ 1. EntityDamsel.tick() │
|
||||
* │ - Uses RotationSmoother for Y rotation (10% per tick) │
|
||||
* │ │
|
||||
* │ 2. DamselRenderer.setupRotations() │
|
||||
* │ - super.setupRotations() (applique rotation Y) │
|
||||
* │ - Rotation X -90° au PoseStack (APRÈS Y = espace local) │
|
||||
* │ - Offset vertical (-7 model units) │
|
||||
* │ │
|
||||
* │ 3. DamselModel.setupAnim() │
|
||||
* │ - body.xRot = 0 (évite double rotation) │
|
||||
* │ - Uses DogPoseHelper.applyHeadCompensation() │
|
||||
* └─────────────────────────────────────────────────────────────────┘
|
||||
* </pre>
|
||||
*
|
||||
* <h2>Key Differences</h2>
|
||||
* <table>
|
||||
* <tr><th>Aspect</th><th>Players</th><th>NPCs</th></tr>
|
||||
* <tr><td>Rotation X application</td><td>Auto by PlayerAnimator</td><td>Manual in setupRotations()</td></tr>
|
||||
* <tr><td>Rotation Y smoothing</td><td>PlayerArmHideEventHandler</td><td>EntityDamsel.tick() via RotationSmoother</td></tr>
|
||||
* <tr><td>Head compensation</td><td>MixinPlayerModel</td><td>DamselModel.setupAnim()</td></tr>
|
||||
* <tr><td>Reset body.xRot</td><td>Not needed</td><td>Yes (prevents double rotation)</td></tr>
|
||||
* <tr><td>Vertical offset</td><td>-6 model units</td><td>-7 model units</td></tr>
|
||||
* </table>
|
||||
*
|
||||
* <h2>Usage</h2>
|
||||
* <p>Used by:
|
||||
* <ul>
|
||||
* <li>MixinPlayerModel - for player head compensation</li>
|
||||
* <li>DamselModel - for NPC head compensation</li>
|
||||
* </ul>
|
||||
*
|
||||
* @see RotationSmoother for Y rotation smoothing
|
||||
* @see com.tiedup.remake.mixin.client.MixinPlayerModel
|
||||
* @see com.tiedup.remake.client.model.DamselModel
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public final class DogPoseHelper {
|
||||
|
||||
private static final float DEG_TO_RAD = (float) Math.PI / 180F;
|
||||
private static final float HEAD_PITCH_OFFSET = (float) Math.toRadians(-90);
|
||||
|
||||
private DogPoseHelper() {
|
||||
// Utility class - no instantiation
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply head compensation for DOG pose (horizontal body).
|
||||
*
|
||||
* <p>When body is horizontal (-90° pitch), the head needs compensation:
|
||||
* <ul>
|
||||
* <li>xRot: -90° offset + player's up/down look (headPitch)</li>
|
||||
* <li>yRot: 0 (this axis points sideways when body is horizontal)</li>
|
||||
* <li>zRot: -headYaw (left/right look, replaces yaw)</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param head The head ModelPart to modify
|
||||
* @param hat The hat ModelPart to sync (can be null)
|
||||
* @param headPitch Player's up/down look angle in degrees
|
||||
* @param headYaw Head yaw relative to body in degrees (netHeadYaw for NPCs,
|
||||
* netHeadYaw + rotationDelta for players)
|
||||
*/
|
||||
public static void applyHeadCompensation(
|
||||
ModelPart head,
|
||||
ModelPart hat,
|
||||
float headPitch,
|
||||
float headYaw
|
||||
) {
|
||||
float pitchRad = headPitch * DEG_TO_RAD;
|
||||
float yawRad = headYaw * DEG_TO_RAD;
|
||||
|
||||
// xRot: base offset (-90° to look forward) + player's up/down look
|
||||
head.xRot = HEAD_PITCH_OFFSET + pitchRad;
|
||||
|
||||
// yRot: stays at 0 (this axis points sideways when body is horizontal)
|
||||
head.yRot = 0;
|
||||
|
||||
// zRot: used for left/right look (replaces yaw since body is horizontal)
|
||||
head.zRot = -yawRad;
|
||||
|
||||
// Sync hat layer if provided
|
||||
if (hat != null) {
|
||||
hat.copyFrom(head);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply head compensation with yaw clamping.
|
||||
*
|
||||
* <p>Same as {@link #applyHeadCompensation} but clamps yaw to a maximum angle.
|
||||
* Used for players where yaw range depends on movement state.
|
||||
*
|
||||
* @param head The head ModelPart to modify
|
||||
* @param hat The hat ModelPart to sync (can be null)
|
||||
* @param headPitch Player's up/down look angle in degrees
|
||||
* @param headYaw Head yaw relative to body in degrees
|
||||
* @param maxYaw Maximum allowed yaw angle in degrees
|
||||
*/
|
||||
public static void applyHeadCompensationClamped(
|
||||
ModelPart head,
|
||||
ModelPart hat,
|
||||
float headPitch,
|
||||
float headYaw,
|
||||
float maxYaw
|
||||
) {
|
||||
// Wrap first so 350° becomes -10° before clamping (fixes full-rotation accumulation)
|
||||
float clampedYaw = net.minecraft.util.Mth.clamp(
|
||||
net.minecraft.util.Mth.wrapDegrees(headYaw),
|
||||
-maxYaw,
|
||||
maxYaw
|
||||
);
|
||||
applyHeadCompensation(head, hat, headPitch, clampedYaw);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user