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:
NotEvil
2026-04-12 00:51:22 +02:00
parent 2e7a1d403b
commit f6466360b6
1947 changed files with 238025 additions and 1 deletions

View File

@@ -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();
}
}

View File

@@ -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");
}
}

View File

@@ -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) {}
}

View File

@@ -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;
}
}
}

View File

@@ -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";
};
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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()));
}
}

View File

@@ -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);
}
}

View File

@@ -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]));
}
}

View File

@@ -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);
}
}
}

View File

@@ -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]);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}

View File

@@ -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"
);
}
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}