Refactor V2 animation, furniture, and GLTF rendering
Broad consolidation of the V2 bondage-item, furniture-entity, and
client-side GLTF pipeline.
Parsing and rendering
- Shared GLB parsing helpers consolidated into GlbParserUtils
(accessor reads, weight normalization, joint-index clamping,
coordinate-space conversion, animation parse, primitive loop).
- Grow-on-demand Matrix4f[] scratch pool in GltfSkinningEngine and
GltfLiveBoneReader — removes per-frame joint-matrix allocation
from the render hot path.
- emitVertex helper dedups three parallel loops in GltfMeshRenderer.
- TintColorResolver.resolve has a zero-alloc path when the item
declares no tint channels.
- itemAnimCache bounded to 256 entries (access-order LRU) with
atomic get-or-compute under the map's monitor.
Animation correctness
- First-in-joint-order wins when body and torso both map to the
same PlayerAnimator slot; duplicate writes log a single WARN.
- Multi-item composites honor the FullX / FullHeadX opt-in that
the single-item path already recognized.
- Seat transforms converted to Minecraft model-def space so
asymmetric furniture renders passengers at the correct offset.
- GlbValidator: IBM count / type / presence, JOINTS_0 presence,
animation channel target validation, multi-skin support.
Furniture correctness and anti-exploit
- Seat assignment synced via SynchedEntityData (server is
authoritative; eliminates client-server divergence on multi-seat).
- Force-mount authorization requires same dimension and a free
seat; cross-dimension distance checks rejected.
- Reconnection on login checks for seat takeover before re-mount
and force-loads the target chunk for cross-dimension cases.
- tiedup_furniture_lockpick_ctx carries a session UUID nonce so
stale context can't misroute a body-item lockpick.
- tiedup_locked_furniture survives death without keepInventory
(Forge 1.20.1 does not auto-copy persistent data on respawn).
Lifecycle and memory
- EntityCleanupHandler fans EntityLeaveLevelEvent out to every
per-entity state map on the client.
- DogPoseRenderHandler re-keyed by UUID (stable across dimension
change; entity int ids are recycled).
- PetBedRenderHandler, PlayerArmHideEventHandler, and
HeldItemHideHandler use receiveCanceled + sentinel sets so
Pre-time mutations are restored even when a downstream handler
cancels the render.
Tests
- JUnit harness with 76+ tests across GlbParserUtils, GltfPoseConverter,
FurnitureSeatGeometry, and FurnitureAuthPredicate.
This commit is contained in:
@@ -7,31 +7,25 @@ import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
|
||||
/**
|
||||
* Central registry for player animation state tracking.
|
||||
* Client-side animation state tracking + world-unload cleanup facade.
|
||||
*
|
||||
* <p>Holds per-player state maps that were previously scattered across
|
||||
* AnimationTickHandler. Provides a single clearAll() entry point for
|
||||
* world unload cleanup.
|
||||
* <p>Holds {@link #lastTiedState} (the per-player edge-detector used by
|
||||
* {@link com.tiedup.remake.client.animation.tick.AnimationTickHandler} to
|
||||
* spot the "just untied" transition) and chains cleanup via
|
||||
* {@link #clearAll()} across every animation-related cache on world unload.</p>
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public final class AnimationStateRegistry {
|
||||
|
||||
/** Track last tied state per player */
|
||||
/** Track last tied state per player (edge-detect on untie transition). */
|
||||
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.
|
||||
@@ -39,7 +33,6 @@ public final class AnimationStateRegistry {
|
||||
public static void clearAll() {
|
||||
// Animation state tracking
|
||||
lastTiedState.clear();
|
||||
lastAnimId.clear();
|
||||
|
||||
// Animation managers
|
||||
BondageAnimationManager.clearAll();
|
||||
@@ -50,6 +43,9 @@ public final class AnimationStateRegistry {
|
||||
|
||||
// Render state
|
||||
com.tiedup.remake.client.animation.render.DogPoseRenderHandler.clearState();
|
||||
com.tiedup.remake.client.animation.render.PetBedRenderHandler.clearAll();
|
||||
com.tiedup.remake.client.animation.render.HeldItemHideHandler.clearAll();
|
||||
com.tiedup.remake.client.animation.render.PlayerArmHideEventHandler.clearAll();
|
||||
|
||||
// NPC animation state
|
||||
com.tiedup.remake.client.animation.tick.NpcAnimationTickHandler.clearAll();
|
||||
|
||||
@@ -353,8 +353,18 @@ public class BondageAnimationManager {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Per-player dedup set so we log the factory-access failure at most once per UUID. */
|
||||
private static final java.util.Set<UUID> layerFailureLogged =
|
||||
java.util.concurrent.ConcurrentHashMap.newKeySet();
|
||||
|
||||
/**
|
||||
* Get the animation layer for a player from PlayerAnimationAccess.
|
||||
*
|
||||
* <p>Throws during the factory-race window for remote players (the factory
|
||||
* hasn't yet initialized their associated data). This is the expected path
|
||||
* for the {@link PendingAnimationManager} retry loop, so we log at DEBUG
|
||||
* and at most once per UUID — a per-tick log would flood during busy
|
||||
* multiplayer.</p>
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private static ModifierLayer<IAnimation> getPlayerLayer(
|
||||
@@ -367,11 +377,13 @@ public class BondageAnimationManager {
|
||||
FACTORY_ID
|
||||
);
|
||||
} catch (Exception e) {
|
||||
LOGGER.error(
|
||||
"Failed to get animation layer for player: {}",
|
||||
player.getName().getString(),
|
||||
e
|
||||
);
|
||||
if (layerFailureLogged.add(player.getUUID())) {
|
||||
LOGGER.debug(
|
||||
"Animation layer not yet available for player {} (will retry): {}",
|
||||
player.getName().getString(),
|
||||
e.toString()
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -521,7 +533,7 @@ public class BondageAnimationManager {
|
||||
return false;
|
||||
}
|
||||
|
||||
ModifierLayer<IAnimation> layer = getFurnitureLayer(player);
|
||||
ModifierLayer<IAnimation> layer = getOrCreateFurnitureLayer(player);
|
||||
if (layer != null) {
|
||||
layer.setAnimation(new KeyframeAnimationPlayer(animation));
|
||||
// Reset grace ticks since we just started/refreshed the animation
|
||||
@@ -577,9 +589,11 @@ public class BondageAnimationManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the furniture ModifierLayer for a player.
|
||||
* Get the furniture ModifierLayer for a player (READ-ONLY).
|
||||
* Uses PlayerAnimationAccess for local/factory-registered players,
|
||||
* falls back to NPC cache for remote players.
|
||||
* falls back to NPC cache for remote players. Returns null if no layer
|
||||
* has been created yet — callers that need to guarantee a layer should use
|
||||
* {@link #getOrCreateFurnitureLayer}.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
@javax.annotation.Nullable
|
||||
@@ -606,6 +620,61 @@ public class BondageAnimationManager {
|
||||
return npcFurnitureLayers.get(player.getUUID());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the furniture ModifierLayer for a player. Mirrors
|
||||
* {@link #getOrCreateLayer} but for the FURNITURE layer priority.
|
||||
*
|
||||
* <p>For the local player (factory-registered), returns the factory layer.
|
||||
* For remote players, creates a new layer on first call and caches it in
|
||||
* {@link #npcFurnitureLayers} — remote players don't own a factory layer,
|
||||
* so without a fallback they can't receive any furniture seat pose.</p>
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
@javax.annotation.Nullable
|
||||
private static ModifierLayer<IAnimation> getOrCreateFurnitureLayer(
|
||||
Player player
|
||||
) {
|
||||
if (player instanceof AbstractClientPlayer clientPlayer) {
|
||||
try {
|
||||
ModifierLayer<IAnimation> layer = (ModifierLayer<
|
||||
IAnimation
|
||||
>) PlayerAnimationAccess.getPlayerAssociatedData(
|
||||
clientPlayer
|
||||
).get(FURNITURE_FACTORY_ID);
|
||||
if (layer != null) {
|
||||
return layer;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Fall through to fallback-create below.
|
||||
}
|
||||
|
||||
// Remote players: fallback-create via the animation stack.
|
||||
if (clientPlayer instanceof IAnimatedPlayer animated) {
|
||||
return npcFurnitureLayers.computeIfAbsent(
|
||||
clientPlayer.getUUID(),
|
||||
k -> {
|
||||
ModifierLayer<IAnimation> newLayer =
|
||||
new ModifierLayer<>();
|
||||
animated
|
||||
.getAnimationStack()
|
||||
.addAnimLayer(FURNITURE_LAYER_PRIORITY, newLayer);
|
||||
LOGGER.debug(
|
||||
"Created furniture animation layer for remote player via stack: {}",
|
||||
clientPlayer.getName().getString()
|
||||
);
|
||||
return newLayer;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return npcFurnitureLayers.get(clientPlayer.getUUID());
|
||||
}
|
||||
|
||||
// Non-player entities: use NPC cache (read-only; NPC furniture animation
|
||||
// is not currently produced by this codebase).
|
||||
return npcFurnitureLayers.get(player.getUUID());
|
||||
}
|
||||
|
||||
/**
|
||||
* Safety tick for furniture animations. Call once per client tick per player.
|
||||
*
|
||||
@@ -731,6 +800,7 @@ public class BondageAnimationManager {
|
||||
}
|
||||
}
|
||||
furnitureGraceTicks.remove(entityId);
|
||||
layerFailureLogged.remove(entityId);
|
||||
LOGGER.debug("Cleaned up animation layers for entity: {}", entityId);
|
||||
}
|
||||
|
||||
@@ -744,6 +814,7 @@ public class BondageAnimationManager {
|
||||
cache.clear();
|
||||
}
|
||||
furnitureGraceTicks.clear();
|
||||
layerFailureLogged.clear();
|
||||
LOGGER.info("Cleared all NPC animation layers");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,9 @@ import com.tiedup.remake.v2.bondage.PoseTypeHelper;
|
||||
import com.tiedup.remake.util.HumanChairHelper;
|
||||
import com.tiedup.remake.state.PlayerBindState;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
|
||||
import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import net.minecraft.client.player.AbstractClientPlayer;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
@@ -35,11 +36,13 @@ import net.minecraftforge.fml.common.Mod;
|
||||
public class DogPoseRenderHandler {
|
||||
|
||||
/**
|
||||
* DOG pose state tracking per player.
|
||||
* Stores: [0: smoothedTarget, 1: currentRot, 2: appliedDelta, 3: isMoving (0/1)]
|
||||
* DOG pose state per player, keyed by UUID (stable across dimension
|
||||
* change, unlike the int entity id which gets reassigned when the
|
||||
* entity re-enters the level). Stores: [0: smoothedTarget, 1: currentRot,
|
||||
* 2: appliedDelta, 3: isMoving (0/1)]
|
||||
*/
|
||||
private static final Int2ObjectMap<float[]> dogPoseState =
|
||||
new Int2ObjectOpenHashMap<>();
|
||||
private static final Map<UUID, float[]> dogPoseState =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
// Array indices for dogPoseState
|
||||
private static final int IDX_TARGET = 0;
|
||||
@@ -51,16 +54,16 @@ public class DogPoseRenderHandler {
|
||||
* 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);
|
||||
public static float getAppliedRotationDelta(UUID playerUuid) {
|
||||
float[] state = dogPoseState.get(playerUuid);
|
||||
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);
|
||||
public static boolean isDogPoseMoving(UUID playerUuid) {
|
||||
float[] state = dogPoseState.get(playerUuid);
|
||||
return state != null && state[IDX_MOVING] > 0.5f;
|
||||
}
|
||||
|
||||
@@ -72,6 +75,13 @@ public class DogPoseRenderHandler {
|
||||
dogPoseState.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the state for a single entity leaving the level.
|
||||
*/
|
||||
public static void onEntityLeave(UUID entityUuid) {
|
||||
dogPoseState.remove(entityUuid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Before player render: Apply vertical offset and rotation for DOG/HUMAN_CHAIR poses.
|
||||
* HIGH priority ensures this runs before arm/item hiding handlers.
|
||||
@@ -115,15 +125,15 @@ public class DogPoseRenderHandler {
|
||||
.getPoseStack()
|
||||
.translate(0, RenderConstants.DOG_AND_PETBED_Y_OFFSET, 0);
|
||||
|
||||
int playerId = player.getId();
|
||||
UUID playerUuid = player.getUUID();
|
||||
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);
|
||||
float[] s = dogPoseState.get(playerUuid);
|
||||
if (s == null) {
|
||||
s = new float[] { player.yBodyRot, player.yBodyRot, 0f, 0f };
|
||||
dogPoseState.put(playerId, s);
|
||||
dogPoseState.put(playerUuid, s);
|
||||
}
|
||||
|
||||
// Human chair: lock rotation state — body must not turn
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.tiedup.remake.client.animation.render;
|
||||
|
||||
import com.tiedup.remake.core.TiedUpMod;
|
||||
import com.tiedup.remake.state.PlayerBindState;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.player.LocalPlayer;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
@@ -13,9 +15,20 @@ 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)
|
||||
* <p>Behavior:</p>
|
||||
* <ul>
|
||||
* <li><b>Tied up</b> (legacy V1 state): hide hands completely — hands are behind back</li>
|
||||
* <li><b>Mittens</b> (legacy V1 item): hide hands + items (Forge limitation: RenderHandEvent
|
||||
* controls hand + item together)</li>
|
||||
* <li><b>V2 item in HANDS or ARMS region</b>: hide hands + items. An armbinder, handcuffs,
|
||||
* gloves, or any item whose {@link com.tiedup.remake.v2.bondage.IV2BondageItem} declares
|
||||
* HANDS/ARMS as an occupied or blocked region triggers this. Artists don't need to do
|
||||
* anything special — declaring the region in the item JSON is enough.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>This is the pragmatic alternative to rendering the full GLB item in first-person
|
||||
* (audit P1-05): the user decided that a player whose arms are restrained shouldn't see
|
||||
* their arms at all, matching the third-person silhouette where the arms are bound.</p>
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
@Mod.EventBusSubscriber(
|
||||
@@ -38,13 +51,22 @@ public class FirstPersonHandHideHandler {
|
||||
}
|
||||
|
||||
PlayerBindState state = PlayerBindState.getInstance(player);
|
||||
if (state == null) {
|
||||
if (state != null && (state.isTiedUp() || state.hasMittens())) {
|
||||
// Legacy V1 state or item.
|
||||
event.setCanceled(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Tied or Mittens: hide hands completely
|
||||
// (Forge limitation: RenderHandEvent controls hand + item together)
|
||||
if (state.isTiedUp() || state.hasMittens()) {
|
||||
// V2: any item occupying or blocking HANDS/ARMS hides both arms in first-person.
|
||||
// isRegionBlocked includes the blocked-regions whitelist from equipped items,
|
||||
// so an armbinder on ARMS that also blocks HANDS hides both even if the HANDS
|
||||
// slot itself is empty.
|
||||
if (
|
||||
V2EquipmentHelper.isRegionOccupied(player, BodyRegionV2.HANDS) ||
|
||||
V2EquipmentHelper.isRegionOccupied(player, BodyRegionV2.ARMS) ||
|
||||
V2EquipmentHelper.isRegionBlocked(player, BodyRegionV2.HANDS) ||
|
||||
V2EquipmentHelper.isRegionBlocked(player, BodyRegionV2.ARMS)
|
||||
) {
|
||||
event.setCanceled(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ 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;
|
||||
|
||||
@@ -37,8 +38,17 @@ public class HeldItemHideHandler {
|
||||
private static final Int2ObjectMap<ItemStack[]> storedItems =
|
||||
new Int2ObjectOpenHashMap<>();
|
||||
|
||||
@SubscribeEvent
|
||||
// LOW priority + isCanceled guard: skip mutation when any earlier-
|
||||
// priority canceller fired. Paired Post uses receiveCanceled = true
|
||||
// and the storedItems map as a sentinel so held items still get
|
||||
// restored even when Forge would otherwise skip Post on a cancelled
|
||||
// Pre.
|
||||
@SubscribeEvent(priority = EventPriority.LOW)
|
||||
public static void onRenderPlayerPre(RenderPlayerEvent.Pre event) {
|
||||
if (event.isCanceled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Player player = event.getEntity();
|
||||
if (!(player instanceof AbstractClientPlayer)) {
|
||||
return;
|
||||
@@ -77,7 +87,7 @@ public class HeldItemHideHandler {
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
@SubscribeEvent(receiveCanceled = true)
|
||||
public static void onRenderPlayerPost(RenderPlayerEvent.Post event) {
|
||||
Player player = event.getEntity();
|
||||
if (!(player instanceof AbstractClientPlayer)) {
|
||||
@@ -90,4 +100,14 @@ public class HeldItemHideHandler {
|
||||
player.setItemInHand(InteractionHand.OFF_HAND, items[1]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop tracked state for an entity leaving the level. */
|
||||
public static void onEntityLeave(int entityId) {
|
||||
storedItems.remove(entityId);
|
||||
}
|
||||
|
||||
/** Drop all tracked state; called on world unload. */
|
||||
public static void clearAll() {
|
||||
storedItems.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ import com.tiedup.remake.v2.bondage.PoseTypeHelper;
|
||||
import com.tiedup.remake.util.HumanChairHelper;
|
||||
import com.tiedup.remake.state.PlayerBindState;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import net.minecraft.client.player.AbstractClientPlayer;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
@@ -21,7 +24,10 @@ 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.
|
||||
* Runs at LOW priority — observes earlier cancellations from HIGH/NORMAL/LOW
|
||||
* mods but precedes LOWEST-tier cancellers. The co-ordering with
|
||||
* DogPoseRenderHandler is state-based (checking {@code isDogOrChairPose}),
|
||||
* not priority-based.
|
||||
*
|
||||
* <p>Extracted from PlayerArmHideEventHandler for single-responsibility.
|
||||
*/
|
||||
@@ -34,10 +40,31 @@ import net.minecraftforge.fml.common.Mod;
|
||||
public class PetBedRenderHandler {
|
||||
|
||||
/**
|
||||
* Before player render: Apply vertical offset and forced pose for pet bed.
|
||||
* Players whose forced pose we mutated in {@link #onRenderPlayerPre}.
|
||||
* {@link #onRenderPlayerPost} only restores the pose for players in this
|
||||
* set, keeping the mutation/restore pair atomic even when another mod
|
||||
* cancels Pre (so our Pre returned early without mutating) — otherwise
|
||||
* Post would null-out a forced pose we never set, potentially clobbering
|
||||
* state owned by another mod.
|
||||
*/
|
||||
@SubscribeEvent(priority = EventPriority.HIGH)
|
||||
private static final Set<UUID> FORCED_POSE_PLAYERS =
|
||||
ConcurrentHashMap.newKeySet();
|
||||
|
||||
/**
|
||||
* Before player render: Apply vertical offset and forced pose for pet bed.
|
||||
*
|
||||
* <p>LOW priority + {@code isCanceled} guard: skip mutation when any
|
||||
* earlier-priority canceller fired. The paired Post uses
|
||||
* {@code receiveCanceled = true} + {@link #FORCED_POSE_PLAYERS} so
|
||||
* mutations still get restored even if a LOWEST-tier canceller runs
|
||||
* after our Pre.</p>
|
||||
*/
|
||||
@SubscribeEvent(priority = EventPriority.LOW)
|
||||
public static void onRenderPlayerPre(RenderPlayerEvent.Pre event) {
|
||||
if (event.isCanceled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Player player = event.getEntity();
|
||||
if (!(player instanceof AbstractClientPlayer)) {
|
||||
return;
|
||||
@@ -47,7 +74,7 @@ public class PetBedRenderHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
java.util.UUID petBedUuid = player.getUUID();
|
||||
UUID petBedUuid = player.getUUID();
|
||||
byte petBedMode = PetBedClientState.get(petBedUuid);
|
||||
|
||||
if (petBedMode == 1 || petBedMode == 2) {
|
||||
@@ -62,6 +89,7 @@ public class PetBedRenderHandler {
|
||||
if (petBedMode == 2) {
|
||||
// SLEEP: force STANDING pose to prevent vanilla sleeping rotation
|
||||
player.setForcedPose(net.minecraft.world.entity.Pose.STANDING);
|
||||
FORCED_POSE_PLAYERS.add(petBedUuid);
|
||||
|
||||
// Compensate for vanilla sleeping Y offset
|
||||
player
|
||||
@@ -93,17 +121,44 @@ public class PetBedRenderHandler {
|
||||
|
||||
/**
|
||||
* After player render: Restore forced pose for pet bed SLEEP mode.
|
||||
*
|
||||
* <p>Only restores when Pre actually mutated the pose (tracked via
|
||||
* {@link #FORCED_POSE_PLAYERS}). If Pre was cancelled upstream or
|
||||
* mode flipped between Pre and Post, we never touched this player's
|
||||
* forced pose — so nulling it out here would clobber another mod's
|
||||
* state. Symmetric with the LOWEST priority + cancel guard on Pre.</p>
|
||||
*/
|
||||
@SubscribeEvent
|
||||
@SubscribeEvent(receiveCanceled = true)
|
||||
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) {
|
||||
UUID playerUuid = player.getUUID();
|
||||
if (FORCED_POSE_PLAYERS.remove(playerUuid)) {
|
||||
player.setForcedPose(null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain tracked state for an entity leaving the level.
|
||||
* Called from {@code EntityCleanupHandler} to prevent stale UUIDs from
|
||||
* lingering when players disconnect mid-render-cycle. Fires for every
|
||||
* departing entity — non-player UUIDs are simply absent from the set,
|
||||
* so {@code remove} is a cheap no-op.
|
||||
*/
|
||||
public static void onEntityLeave(UUID entityUuid) {
|
||||
FORCED_POSE_PLAYERS.remove(entityUuid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain all tracked state. Called from
|
||||
* {@link com.tiedup.remake.client.animation.AnimationStateRegistry#clearAll}
|
||||
* on world unload so a UUID added between Pre and a world-unload event
|
||||
* doesn't linger into the next world.
|
||||
*/
|
||||
public static void clearAll() {
|
||||
FORCED_POSE_PLAYERS.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import com.tiedup.remake.state.PlayerBindState;
|
||||
import com.tiedup.remake.v2.BodyRegionV2;
|
||||
import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
|
||||
import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
|
||||
import it.unimi.dsi.fastutil.ints.IntOpenHashSet;
|
||||
import it.unimi.dsi.fastutil.ints.IntSet;
|
||||
import net.minecraft.client.model.PlayerModel;
|
||||
import net.minecraft.client.player.AbstractClientPlayer;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
@@ -16,6 +18,7 @@ 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;
|
||||
|
||||
@@ -40,18 +43,36 @@ public class PlayerArmHideEventHandler {
|
||||
|
||||
/**
|
||||
* Stored layer visibility to restore after rendering.
|
||||
* Key: Player entity ID (int), Value: [hat, jacket, leftSleeve, rightSleeve, leftPants, rightPants]
|
||||
* Key: Player entity ID (int), Value: [hat, jacket, leftSleeve, rightSleeve, leftPants, rightPants].
|
||||
* Presence in the map is also the sentinel for "Post must restore layers".
|
||||
*/
|
||||
private static final Int2ObjectMap<boolean[]> storedLayers =
|
||||
new Int2ObjectOpenHashMap<>();
|
||||
|
||||
/**
|
||||
* Entity ids whose arm visibility we hid in Pre, so Post only restores
|
||||
* what we touched. Unconditional restore would clobber arm-hide state
|
||||
* set by other mods on the shared {@link PlayerModel}.
|
||||
*/
|
||||
private static final IntSet hiddenArmEntities = new IntOpenHashSet();
|
||||
|
||||
/**
|
||||
* Before player render:
|
||||
* - Hide arms for wrap/latex_sack poses
|
||||
* - Hide outer layers based on clothes settings
|
||||
*
|
||||
* <p>LOW priority + {@code isCanceled} guard: skip mutation when any
|
||||
* earlier-priority canceller fired. Paired Post uses
|
||||
* {@code receiveCanceled = true} + sentinel maps so mutations get
|
||||
* restored even if a downstream canceller skips the normal Post path
|
||||
* (Forge gates Post firing on the final canceled state).</p>
|
||||
*/
|
||||
@SubscribeEvent
|
||||
@SubscribeEvent(priority = EventPriority.LOW)
|
||||
public static void onRenderPlayerPre(RenderPlayerEvent.Pre event) {
|
||||
if (event.isCanceled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Player player = event.getEntity();
|
||||
if (!(player instanceof AbstractClientPlayer clientPlayer)) {
|
||||
return;
|
||||
@@ -82,6 +103,7 @@ public class PlayerArmHideEventHandler {
|
||||
model.rightArm.visible = false;
|
||||
model.leftSleeve.visible = false;
|
||||
model.rightSleeve.visible = false;
|
||||
hiddenArmEntities.add(player.getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,9 +129,12 @@ public class PlayerArmHideEventHandler {
|
||||
}
|
||||
|
||||
/**
|
||||
* After player render: Restore arm visibility and layer visibility.
|
||||
* After player render: restore visibility only for state we actually
|
||||
* mutated. {@code receiveCanceled=true} so we fire even when a
|
||||
* downstream canceller cancelled the paired Pre — otherwise mutations
|
||||
* stay applied forever.
|
||||
*/
|
||||
@SubscribeEvent
|
||||
@SubscribeEvent(receiveCanceled = true)
|
||||
public static void onRenderPlayerPost(RenderPlayerEvent.Post event) {
|
||||
Player player = event.getEntity();
|
||||
if (!(player instanceof AbstractClientPlayer)) {
|
||||
@@ -118,11 +143,13 @@ public class PlayerArmHideEventHandler {
|
||||
|
||||
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 ARM VISIBILITY (only if we hid them) ===
|
||||
if (hiddenArmEntities.remove(player.getId())) {
|
||||
model.leftArm.visible = true;
|
||||
model.rightArm.visible = true;
|
||||
model.leftSleeve.visible = true;
|
||||
model.rightSleeve.visible = true;
|
||||
}
|
||||
|
||||
// === RESTORE WEARER LAYERS ===
|
||||
boolean[] savedLayers = storedLayers.remove(player.getId());
|
||||
@@ -130,4 +157,16 @@ public class PlayerArmHideEventHandler {
|
||||
ClothesRenderHelper.restoreWearerLayers(model, savedLayers);
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop tracked state for an entity leaving the level. */
|
||||
public static void onEntityLeave(int entityId) {
|
||||
storedLayers.remove(entityId);
|
||||
hiddenArmEntities.remove(entityId);
|
||||
}
|
||||
|
||||
/** Drop all tracked state; called on world unload. */
|
||||
public static void clearAll() {
|
||||
storedLayers.clear();
|
||||
hiddenArmEntities.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,8 @@ 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.
|
||||
* Magic numbers shared across render handlers: DOG pose rotation
|
||||
* smoothing, head clamp limits, vertical offsets.
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public final class RenderConstants {
|
||||
|
||||
@@ -21,6 +21,7 @@ import com.tiedup.remake.v2.bondage.capability.V2EquipmentHelper;
|
||||
import com.tiedup.remake.v2.bondage.movement.MovementStyle;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.player.AbstractClientPlayer;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
@@ -58,6 +59,29 @@ public class AnimationTickHandler {
|
||||
/** Tick counter for periodic cleanup tasks */
|
||||
private static int cleanupTickCounter = 0;
|
||||
|
||||
/**
|
||||
* Per-player retry counter for the cold-cache furniture animation loop.
|
||||
* A GLB without a {@code Player_*} armature (legacy V1-only furniture)
|
||||
* can never yield a seat animation, so
|
||||
* {@link BondageAnimationManager#hasFurnitureAnimation} stays false
|
||||
* forever and the retry would spam at 20 Hz until dismount. Cap at
|
||||
* {@link #MAX_FURNITURE_RETRIES}; reset on successful apply or on
|
||||
* dismount so the next mount starts fresh.
|
||||
*/
|
||||
private static final Map<UUID, Integer> furnitureRetryCounters =
|
||||
new ConcurrentHashMap<>();
|
||||
private static final int MAX_FURNITURE_RETRIES = 60; // ~3 seconds at 20 Hz — covers slow-disk GLB load
|
||||
|
||||
/**
|
||||
* Drain the retry counter for a specific entity leaving the level.
|
||||
* Called from {@code EntityCleanupHandler.onEntityLeaveLevel} so a
|
||||
* remote player getting unloaded (chunk unload, dimension change,
|
||||
* kicked) doesn't leak a counter until the next world unload.
|
||||
*/
|
||||
public static void removeFurnitureRetry(UUID uuid) {
|
||||
furnitureRetryCounters.remove(uuid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Client tick event - called every tick on the client.
|
||||
* Updates animations for all players when their bondage state changes.
|
||||
@@ -89,6 +113,47 @@ public class AnimationTickHandler {
|
||||
}
|
||||
// Safety: remove stale furniture animations for players no longer on seats
|
||||
BondageAnimationManager.tickFurnitureSafety(player);
|
||||
// Cold-cache retry: if the player is seated on furniture but has no
|
||||
// active pose (GLB was not yet loaded at mount time, or the GLB cache
|
||||
// entry was a transient failure), retry until the cache warms.
|
||||
// FurnitureGltfCache memoizes failures via Optional.empty(), so
|
||||
// retries after a genuine parse failure return instantly with no
|
||||
// reparse. Bounded at MAX_FURNITURE_RETRIES so a legacy V1-only
|
||||
// GLB (no Player_* armature → seatSkeleton==null → no animation
|
||||
// ever possible) doesn't spam retries at 20 Hz forever.
|
||||
// Single read of getVehicle() — avoids a re-read where the
|
||||
// vehicle could change between instanceof and cast.
|
||||
com.tiedup.remake.v2.furniture.EntityFurniture furniture =
|
||||
player.getVehicle() instanceof
|
||||
com.tiedup.remake.v2.furniture.EntityFurniture f ? f : null;
|
||||
boolean hasAnim = BondageAnimationManager.hasFurnitureAnimation(
|
||||
player
|
||||
);
|
||||
UUID playerUuid = player.getUUID();
|
||||
if (furniture != null && !hasAnim) {
|
||||
int retries = furnitureRetryCounters.getOrDefault(
|
||||
playerUuid,
|
||||
0
|
||||
);
|
||||
if (retries < MAX_FURNITURE_RETRIES) {
|
||||
furnitureRetryCounters.put(playerUuid, retries + 1);
|
||||
com.tiedup.remake.v2.furniture.EntityFurniture.startFurnitureAnimationClient(
|
||||
furniture,
|
||||
player
|
||||
);
|
||||
if (retries + 1 == MAX_FURNITURE_RETRIES) {
|
||||
LOGGER.debug(
|
||||
"[FurnitureAnim] Giving up on furniture animation retry for {} after {} attempts — GLB likely has no Player_* armature.",
|
||||
player.getName().getString(),
|
||||
MAX_FURNITURE_RETRIES
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Dismounted or successfully applied — drop the counter so a
|
||||
// later re-mount starts fresh.
|
||||
furnitureRetryCounters.remove(playerUuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,11 +266,15 @@ public class AnimationTickHandler {
|
||||
context,
|
||||
allOwnedParts
|
||||
);
|
||||
// Clear legacy tracking so transition back works
|
||||
AnimationStateRegistry.getLastAnimId().remove(uuid);
|
||||
} else if (GltfAnimationApplier.hasActiveState(player)) {
|
||||
// Clear any residual V2 composite animation when the player
|
||||
// is still isTiedUp() but has no GLB-bearing items — e.g.
|
||||
// a non-GLB item keeps the tied state, or a GLB item was
|
||||
// removed while another V2 item remains on a non-animated
|
||||
// region. Leaving the composite in place locks the arms in
|
||||
// the pose of an item the player no longer wears.
|
||||
GltfAnimationApplier.clearV2Animation(player);
|
||||
}
|
||||
// No V2 items with GLB models — nothing to animate.
|
||||
// Items without data-driven GLB definitions are not animated.
|
||||
} else if (wasTied) {
|
||||
// Was tied, now free - stop all animations
|
||||
if (GltfAnimationApplier.hasActiveState(player)) {
|
||||
@@ -213,9 +282,9 @@ public class AnimationTickHandler {
|
||||
} else {
|
||||
BondageAnimationManager.stopAnimation(player);
|
||||
}
|
||||
AnimationStateRegistry.getLastAnimId().remove(uuid);
|
||||
}
|
||||
|
||||
|
||||
AnimationStateRegistry.getLastTiedState().put(uuid, isTied);
|
||||
}
|
||||
|
||||
@@ -227,9 +296,9 @@ public class AnimationTickHandler {
|
||||
if (event.getEntity().level().isClientSide()) {
|
||||
UUID uuid = event.getEntity().getUUID();
|
||||
AnimationStateRegistry.getLastTiedState().remove(uuid);
|
||||
AnimationStateRegistry.getLastAnimId().remove(uuid);
|
||||
BondageAnimationManager.cleanup(uuid);
|
||||
GltfAnimationApplier.removeTracking(uuid);
|
||||
furnitureRetryCounters.remove(uuid);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,6 +315,7 @@ public class AnimationTickHandler {
|
||||
// DogPoseRenderHandler, MCAAnimationTickCache)
|
||||
// AnimationStateRegistry.clearAll() handles GltfAnimationApplier.clearAll() transitively
|
||||
AnimationStateRegistry.clearAll();
|
||||
furnitureRetryCounters.clear();
|
||||
|
||||
// Non-animation client-side caches
|
||||
PetBedClientState.clearAll();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package com.tiedup.remake.client.animation.tick;
|
||||
|
||||
import java.util.HashMap;
|
||||
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;
|
||||
|
||||
@@ -12,12 +12,17 @@ import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
* multiple times per game tick.
|
||||
*
|
||||
* <p>This is extracted from the mixin so it can be cleared on world unload
|
||||
* to prevent memory leaks.
|
||||
* to prevent memory leaks. Uses {@link ConcurrentHashMap} because reads
|
||||
* happen on the render thread (from the mixin's {@code setupAnim} tail
|
||||
* injection) while {@link #clear} is called from the main thread on world
|
||||
* unload — a plain {@code HashMap} could observe a torn state or throw
|
||||
* {@link java.util.ConcurrentModificationException} during the race.</p>
|
||||
*/
|
||||
@OnlyIn(Dist.CLIENT)
|
||||
public final class MCAAnimationTickCache {
|
||||
|
||||
private static final Map<UUID, Integer> lastTickMap = new HashMap<>();
|
||||
private static final Map<UUID, Integer> lastTickMap =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
private MCAAnimationTickCache() {
|
||||
// Utility class
|
||||
@@ -48,4 +53,9 @@ public final class MCAAnimationTickCache {
|
||||
public static void clear() {
|
||||
lastTickMap.clear();
|
||||
}
|
||||
|
||||
/** Drop the tick-dedup entry for an entity leaving the level. */
|
||||
public static void remove(UUID entityUuid) {
|
||||
lastTickMap.remove(entityUuid);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,33 @@ public class NpcAnimationTickHandler {
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* Client tick: update animations for all loaded AbstractTiedUpNpc instances.
|
||||
* NPCs currently in a posed state (tied / sitting / kneeling). Values hold
|
||||
* the live entity reference so the per-tick fast path doesn't need to
|
||||
* resolve UUID → Entity (the client level doesn't expose a UUID index).
|
||||
* Populated by {@link #fullSweep}; cleared by {@link #updateNpcAnimation}
|
||||
* on pose exit, and by {@link #remove} on {@code EntityLeaveLevelEvent}.
|
||||
*/
|
||||
private static final Map<UUID, AbstractTiedUpNpc> ACTIVE_NPCS =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* Tick-count gate for the periodic full-entity sweep. A low-frequency
|
||||
* O(N) fallback catches NPCs that entered the posed state via paths the
|
||||
* fast path hasn't seen yet (e.g. just-spawned, just-loaded-into-chunk,
|
||||
* state flipped by a packet we didn't mirror into ACTIVE_NPCS). 20 ticks
|
||||
* ≈ 1 second — latency is invisible in practice.
|
||||
*/
|
||||
private static int sweepCounter = 0;
|
||||
private static final int FULL_SWEEP_INTERVAL_TICKS = 20;
|
||||
|
||||
/**
|
||||
* Client tick: update animations for posed NPCs.
|
||||
*
|
||||
* <p>Fast path (19 of every 20 ticks): iterate only {@link #ACTIVE_NPCS}
|
||||
* — typically 1–5 entries — so the cost is O(|active|) instead of
|
||||
* O(|all client entities|). Full sweep (every 20th tick): re-scan
|
||||
* {@code entitiesForRendering()} to discover NPCs that entered the pose
|
||||
* via an untracked path.</p>
|
||||
*/
|
||||
@SubscribeEvent
|
||||
public static void onClientTick(TickEvent.ClientTickEvent event) {
|
||||
@@ -63,6 +89,39 @@ public class NpcAnimationTickHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
sweepCounter++;
|
||||
if (sweepCounter >= FULL_SWEEP_INTERVAL_TICKS) {
|
||||
sweepCounter = 0;
|
||||
fullSweep(mc);
|
||||
} else {
|
||||
fastTick();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fast path: iterate only tracked posed NPCs. Entities that have died or
|
||||
* been removed from the level are dropped from the set here so stale
|
||||
* references don't linger between full sweeps.
|
||||
*/
|
||||
private static void fastTick() {
|
||||
// ConcurrentHashMap.values() iterator is weakly consistent, so
|
||||
// concurrent remove() during iteration (from updateNpcAnimation) is
|
||||
// explicitly supported by the JDK contract.
|
||||
for (AbstractTiedUpNpc npc : ACTIVE_NPCS.values()) {
|
||||
if (!npc.isAlive() || npc.isRemoved()) {
|
||||
ACTIVE_NPCS.remove(npc.getUUID());
|
||||
continue;
|
||||
}
|
||||
updateNpcAnimation(npc);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback sweep: O(N) over all rendered entities. Adds newly-posed NPCs
|
||||
* to {@link #ACTIVE_NPCS} via {@link #updateNpcAnimation}; also runs the
|
||||
* same logic for already-tracked NPCs, which is idempotent.
|
||||
*/
|
||||
private static void fullSweep(Minecraft mc) {
|
||||
for (Entity entity : mc.level.entitiesForRendering()) {
|
||||
if (
|
||||
entity instanceof AbstractTiedUpNpc damsel &&
|
||||
@@ -84,6 +143,15 @@ public class NpcAnimationTickHandler {
|
||||
*
|
||||
* <p>Legacy fallback: if no GLB model is found, falls back to JSON-based
|
||||
* PlayerAnimator animations via {@link BondageAnimationManager}.
|
||||
*
|
||||
* <p><b>For future contributors</b>: this method is the sole writer of the
|
||||
* {@link #ACTIVE_NPCS} fast-path set. Any new code that flips an NPC into a
|
||||
* posed state (new packet handler, new AI transition, etc.) should call
|
||||
* this method directly — otherwise the NPC will not be animated until the
|
||||
* next 1 Hz full sweep picks it up (~1 s visible latency). If the worst-
|
||||
* case latency matters for your use case, call
|
||||
* {@link #updateNpcAnimation(AbstractTiedUpNpc)} yourself to register the
|
||||
* NPC immediately.</p>
|
||||
*/
|
||||
private static void updateNpcAnimation(AbstractTiedUpNpc entity) {
|
||||
boolean inPose =
|
||||
@@ -91,6 +159,16 @@ public class NpcAnimationTickHandler {
|
||||
|
||||
UUID uuid = entity.getUUID();
|
||||
|
||||
// Track/untrack in ACTIVE_NPCS so the fast-tick path sees state
|
||||
// transitions as soon as they're observed here. Idempotent put/remove
|
||||
// — no double-tracking and no missed removal even if two code paths
|
||||
// race to the same update.
|
||||
if (inPose) {
|
||||
ACTIVE_NPCS.put(uuid, entity);
|
||||
} else {
|
||||
ACTIVE_NPCS.remove(uuid);
|
||||
}
|
||||
|
||||
if (inPose) {
|
||||
// Resolve V2 equipment map
|
||||
IV2BondageEquipment equipment = V2EquipmentHelper.getEquipment(
|
||||
@@ -215,5 +293,17 @@ public class NpcAnimationTickHandler {
|
||||
*/
|
||||
public static void clearAll() {
|
||||
lastNpcAnimId.clear();
|
||||
ACTIVE_NPCS.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an individual NPC's animation state.
|
||||
* Called from {@link com.tiedup.remake.client.events.EntityCleanupHandler}
|
||||
* on {@code EntityLeaveLevelEvent} so the per-UUID map doesn't accumulate
|
||||
* stale entries from dead/unloaded NPCs between world unloads.
|
||||
*/
|
||||
public static void remove(java.util.UUID uuid) {
|
||||
lastNpcAnimId.remove(uuid);
|
||||
ACTIVE_NPCS.remove(uuid);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user