diff --git a/gradle.properties b/gradle.properties index f2d5908..df643f9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -9,7 +9,7 @@ yarn_mappings=1.20.1+build.10 loader_version=0.16.10 # Mod Properties -mod_version=1.1.15 +mod_version=1.1.16 maven_group=dev.amble publication_base_name=lib archives_base_name=amblekit diff --git a/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimation.java b/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimation.java index fbba0d1..37ea818 100644 --- a/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimation.java +++ b/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimation.java @@ -58,6 +58,8 @@ public class BedrockAnimation { public static final Collection IGNORED_BONES = Set.of("camera"); public static final Collection ROOT_BONES = Set.of("root", "player"); + // Bone lookup cache: WeakHashMap allows GC of ModelPart roots when no longer referenced + private static final WeakHashMap> BONE_CACHE = new WeakHashMap<>(); public final boolean shouldLoop; public final double animationLength; @@ -80,16 +82,99 @@ public static BedrockAnimation getFor(AnimatedEntity animated) { return anim; } + /** + * Gets or builds a cached map of bone names to ModelParts for O(1) lookups. + * Uses WeakHashMap so entries are automatically cleaned up when the root ModelPart is GC'd. + */ + private static Map getBoneMap(ModelPart root) { + return BONE_CACHE.computeIfAbsent(root, r -> { + Map map = new HashMap<>(); + buildBoneMap(r, map); + return map; + }); + } + + // Cached reflection field for ModelPart.children + private static java.lang.reflect.Field CHILDREN_FIELD = null; + private static boolean CHILDREN_FIELD_ATTEMPTED = false; + + // Possible field names for ModelPart.children across different mappings + private static final String[] CHILDREN_FIELD_NAMES = {"children", "field_3683"}; + + /** + * Recursively builds a map of bone names to their ModelPart objects. + * Uses reflection to access the children map, which is more reliable than + * traversing and checking hasChild for every possible name. + */ + private static void buildBoneMap(ModelPart part, Map map) { + // Try to get the children field via reflection (cached) + if (!CHILDREN_FIELD_ATTEMPTED) { + CHILDREN_FIELD_ATTEMPTED = true; + for (String fieldName : CHILDREN_FIELD_NAMES) { + try { + CHILDREN_FIELD = ModelPart.class.getDeclaredField(fieldName); + CHILDREN_FIELD.setAccessible(true); + break; + } catch (Exception ignored) { + // Try next field name + } + } + } + + if (CHILDREN_FIELD == null) return; + + part.traverse().forEach(p -> { + try { + @SuppressWarnings("unchecked") + Map children = (Map) CHILDREN_FIELD.get(p); + map.putAll(children); + } catch (Exception ignored) { + // Skip this part + } + }); + } + + /** + * Clears the bone cache. Call this if models are reloaded. + */ + public static void clearBoneCache() { + BONE_CACHE.clear(); + } + + /** + * Gets a bone by name from the cache, falling back to slow traversal if needed. + */ + private static ModelPart getBone(ModelPart root, String boneName, Map boneMap) { + ModelPart bone = boneMap.get(boneName); + if (bone != null) return bone; + + // Cache miss - bone name wasn't in the cache, fall back to slow path and cache it + bone = root.traverse() + .filter(part -> part.hasChild(boneName)) + .findFirst() + .map(part -> part.getChild(boneName)) + .orElse(null); + + if (bone != null) { + boneMap.put(boneName, bone); + } + + return bone; + } + @Environment(EnvType.CLIENT) public void apply(ModelPart root, double runningSeconds) { this.resetBones(root, this.overrideBones); + // Get cached bone map for O(1) lookups instead of traversing every frame + Map boneMap = getBoneMap(root); + this.boneTimelines.forEach((boneName, timeline) -> { try { if (IGNORED_BONES.contains(boneName.toLowerCase())) return; - ModelPart bone = root.traverse().filter(part -> part.hasChild(boneName)).findFirst().map(part -> part.getChild(boneName)).orElse(null); + ModelPart bone = getBone(root, boneName, boneMap); if (bone == null) { if (ROOT_BONES.contains(boneName.toLowerCase())) { bone = root; @@ -190,6 +275,17 @@ public void apply(ModelPart root, AnimationState state, float progress, float sp }); } + @Environment(EnvType.CLIENT) + public void apply(ModelPart root, TargetedAnimationState state, @Nullable EffectProvider provider) { + float previous = state.getAnimationTimeSecs() - 0.01F; + state.tick(); + float current = state.getAnimationTimeSecs() - 0.01F; + + state.setAnimationLength(this); + this.apply(root, current); + this.applyEffects(provider, current, previous, root); + } + public void apply(ModelPart root, int totalTicks, float rawDelta) { float ticks = (float) ((totalTicks / 20F) % (this.animationLength)) * 20; float delta = rawDelta / 10F; @@ -222,11 +318,14 @@ public void resetBones(ModelPart root, boolean resetAll) { return; } + // Get cached bone map for O(1) lookups instead of traversing every frame + Map boneMap = getBoneMap(root); + this.boneTimelines.forEach((boneName, timeline) -> { try { if (IGNORED_BONES.contains(boneName.toLowerCase())) return; - ModelPart bone = root.traverse().filter(part -> part.hasChild(boneName)).findFirst().map(part -> part.getChild(boneName)).orElse(null); + ModelPart bone = getBone(root, boneName, boneMap); if (bone == null) { if (ROOT_BONES.contains(boneName.toLowerCase())) { bone = root; diff --git a/src/main/java/dev/amble/lib/client/bedrock/TargetedAnimationState.java b/src/main/java/dev/amble/lib/client/bedrock/TargetedAnimationState.java new file mode 100644 index 0000000..0eb9c60 --- /dev/null +++ b/src/main/java/dev/amble/lib/client/bedrock/TargetedAnimationState.java @@ -0,0 +1,311 @@ +package dev.amble.lib.client.bedrock; + +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; +import net.minecraft.entity.AnimationState; +import net.minecraft.util.math.MathHelper; + +/** + * An animation state that allows targeting a specific progress (0-1) and smoothly + * transitioning to that target. Supports forward and reverse playback. + * + *

Usage example: + *

{@code
+ * TargetedAnimationState state = new TargetedAnimationState();
+ * state.setAnimationLength(1000); // 1 second animation
+ *
+ * // To play animation forward to completion:
+ * state.setTargetProgress(1.0f);
+ *
+ * // To play animation in reverse:
+ * state.setTargetProgress(0.0f);
+ *
+ * // Each tick, call:
+ * state.tick(deltaTimeMs);
+ *
+ * // Get the current animation time for rendering:
+ * long animTime = state.getAnimationTimeMs();
+ * }
+ * + * Even though this implements animation state, it does not change the built-in + * animation time directly. Instead, use getAnimationTimeMs() to retrieve the + * current time based on progress. + */ +public class TargetedAnimationState extends AnimationState { + + /** + * The target progress to animate toward (0-1) + */ + private float targetProgress = 0f; + + /** + * The current progress of the animation (0-1) + */ + private float currentProgress = 0f; + + /** + * The total length of the animation in milliseconds + */ + private long animationLengthMs = 1000L; + + /** + * Speed multiplier for transitioning (1.0 = normal speed) + */ + private float transitionSpeed = 1.0f; + + /** + * Whether the animation is currently transitioning + */ + private boolean running = false; + + /** + * The last time the animation was updated (in milliseconds) + */ + private long lastUpdateTime = 0L; + + public TargetedAnimationState() { + } + + /** + * Creates a TargetedAnimationState with a specified animation length. + * + * @param animationLengthMs The total animation length in milliseconds + */ + public TargetedAnimationState(long animationLengthMs) { + this.animationLengthMs = animationLengthMs; + } + + /** + * Sets the target progress to 1.0 (fully played). + */ + public void playForward() { + setTargetProgress(1.0f); + } + + /** + * Sets the target progress to 0.0 (reversed to start). + */ + public void playReverse() { + setTargetProgress(0.0f); + } + + /** + * Gets the current target progress. + * + * @return The target progress (0-1) + */ + public float getTargetProgress() { + return targetProgress; + } + + /** + * Sets the target progress for the animation. + * The current progress will smoothly transition toward this value. + * + * @param target The target progress (0-1), will be clamped + */ + public void setTargetProgress(float target) { + this.targetProgress = MathHelper.clamp(target, 0f, 1f); + if (!this.running && this.currentProgress != this.targetProgress) { + this.running = true; + this.lastUpdateTime = System.currentTimeMillis(); + } + } + + /** + * Gets the current animation progress. + * + * @return The current progress (0-1) + */ + public float getCurrentProgress() { + return currentProgress; + } + + /** + * Sets the current progress directly without animation. + * + * @param progress The progress to set (0-1), will be clamped + */ + public void setCurrentProgress(float progress) { + this.currentProgress = MathHelper.clamp(progress, 0f, 1f); + } + + /** + * Gets the total animation length in milliseconds. + * + * @return The animation length in milliseconds + */ + public long getAnimationLength() { + return animationLengthMs; + } + + /** + * Sets the total animation length in milliseconds. + * + * @param lengthMs The animation length in milliseconds + */ + public void setAnimationLength(long lengthMs) { + this.animationLengthMs = Math.max(1L, lengthMs); + } + + @Environment(EnvType.CLIENT) + public void setAnimationLength(BedrockAnimation animation) { + setAnimationLength((long) (animation.animationLength * 1000L)); + } + + /** + * Gets the transition speed multiplier. + * + * @return The speed multiplier + */ + public float getTransitionSpeed() { + return transitionSpeed; + } + + /** + * Sets the transition speed multiplier. + * + * @param speed The speed multiplier (1.0 = normal, 2.0 = double speed, etc.) + */ + public void setTransitionSpeed(float speed) { + this.transitionSpeed = Math.max(0.001f, speed); + } + + /** + * Updates the animation state. Call this every tick or frame. + * Uses system time to calculate delta. + */ + public void tick() { + long currentTime = System.currentTimeMillis(); + if (lastUpdateTime == 0L) { + lastUpdateTime = currentTime; + } + long deltaMs = currentTime - lastUpdateTime; + lastUpdateTime = currentTime; + + tick(deltaMs); + } + + /** + * Updates the animation state with a specific delta time. + * + * @param deltaMs The time elapsed since last update in milliseconds + */ + public void tick(long deltaMs) { + if (!running || currentProgress == targetProgress) { + if (currentProgress == targetProgress) { + running = false; + } + return; + } + + // Calculate how much progress to add based on delta time + float progressDelta = (deltaMs * transitionSpeed) / (float) animationLengthMs; + + if (targetProgress > currentProgress) { + // Moving forward + currentProgress = Math.min(currentProgress + progressDelta, targetProgress); + } else { + // Moving backward (reverse) + currentProgress = Math.max(currentProgress - progressDelta, targetProgress); + } + + // Check if we've reached the target + if (currentProgress == targetProgress) { + running = false; + } + } + + /** + * Gets the current animation time in milliseconds based on current progress. + * Use this value when applying animations. + * + * @return The animation time in milliseconds + */ + public long getAnimationTimeMs() { + return (long) (currentProgress * animationLengthMs); + } + + /** + * Gets the current animation time in seconds based on current progress. + * + * @return The animation time in seconds + */ + public float getAnimationTimeSecs() { + return currentProgress * (animationLengthMs / 1000f); + } + + /** + * Returns whether the animation is currently transitioning toward the target. + * + * @return true if currently animating + */ + public boolean isRunning() { + return running; + } + + /** + * Returns whether the animation has reached its target. + * + * @return true if current progress equals target progress + */ + public boolean isAtTarget() { + return currentProgress == targetProgress; + } + + /** + * Returns whether the animation is at the start (progress = 0). + * + * @return true if at the beginning + */ + public boolean isAtStart() { + return currentProgress == 0f; + } + + /** + * Returns whether the animation is at the end (progress = 1). + * + * @return true if at the end + */ + public boolean isAtEnd() { + return currentProgress == 1f; + } + + /** + * Resets the animation to the start position without animating. + */ + public void reset() { + this.currentProgress = 0f; + this.targetProgress = 0f; + this.running = false; + this.lastUpdateTime = 0L; + } + + /** + * Stops the animation at its current position. + */ + public void stop() { + this.targetProgress = this.currentProgress; + this.running = false; + } + + /** + * Jumps directly to the target progress without animating. + */ + public void jumpToTarget() { + this.currentProgress = this.targetProgress; + this.running = false; + } + + @Override + public String toString() { + return "TargetedAnimationState{" + + "targetProgress=" + targetProgress + + ", currentProgress=" + currentProgress + + ", animationLengthMs=" + animationLengthMs + + ", transitionSpeed=" + transitionSpeed + + ", running=" + running + + ", lastUpdateTime=" + lastUpdateTime + + '}'; + } +}