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/animation/client/AnimationMetadata.java b/src/main/java/dev/amble/lib/animation/client/AnimationMetadata.java index 59a71a7..4aefe7c 100644 --- a/src/main/java/dev/amble/lib/animation/client/AnimationMetadata.java +++ b/src/main/java/dev/amble/lib/animation/client/AnimationMetadata.java @@ -20,9 +20,9 @@ * @param excess Any excess metadata not used by AmbleKit. */ @Environment(EnvType.CLIENT) -public record AnimationMetadata(@With boolean movement, @With Perspective perspective, @With boolean fpsCamera, @With boolean hideHandItems, @With boolean hideHud, @With boolean fpsCameraCopiesHead, @With +public record AnimationMetadata(@With boolean movement, @With Perspective perspective, @With boolean fpsCamera, @With boolean hideHandItems, @With boolean hideHud, @With boolean fpsCameraCopiesHead, @With boolean cumulative, @With JsonObject excess) { - public static final AnimationMetadata DEFAULT = new AnimationMetadata(true, null, true, true, false, false, new JsonObject()); + public static final AnimationMetadata DEFAULT = new AnimationMetadata(true, null, true, true, false, false, false, new JsonObject()); @Nullable public static AnimationMetadata getFor(AnimatedEntity animated) { 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..797442b 100644 --- a/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimation.java +++ b/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimation.java @@ -58,8 +58,22 @@ 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; + /** + * Loop mode for Bedrock animations: + * - LOOP: Animation repeats from the beginning when finished + * - HOLD_ON_LAST_FRAME: Animation holds on the last frame (still counted as playing) + * - NONE: Animation resets to starting position when finished + */ + public enum LoopMode { + LOOP, + HOLD_ON_LAST_FRAME, + NONE + } + + public final LoopMode loopMode; public final double animationLength; public final Map boneTimelines; public final boolean overrideBones; @@ -80,16 +94,107 @@ 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(); + } + + /** + * Checks if a Vec3d contains valid (non-NaN, non-Infinite) values. + * Invalid values can corrupt the render state and cause black screens. + */ + private static boolean isValidVec3d(Vec3d vec) { + return Double.isFinite(vec.x) && Double.isFinite(vec.y) && Double.isFinite(vec.z); + } + + /** + * 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; @@ -101,37 +206,72 @@ public void apply(ModelPart root, double runningSeconds) { if (!timeline.position.isEmpty()) { Vec3d position = timeline.position.resolve(runningSeconds); - // traverse includes self - bone.traverse().forEach(child -> { - child.pivotX += (float) position.x; - child.pivotY += (float) position.y; - child.pivotZ += (float) position.z; - }); + // Guard against NaN/Infinity corrupting render state + if (!isValidVec3d(position)) return; + + if (metadata.cumulative()) { + // traverse includes self + bone.traverse().forEach(child -> { + child.pivotX += (float) position.x; + child.pivotY += (float) position.y; + child.pivotZ += (float) position.z; + }); + } else { + bone.pivotX += (float) position.x; + bone.pivotY += (float) position.y; + bone.pivotZ += (float) position.z; + } } if (!timeline.rotation.isEmpty()) { Vec3d rotation = timeline.rotation.resolve(runningSeconds); - bone.pitch += (float) Math.toRadians((float) rotation.x); - bone.yaw += (float) Math.toRadians((float) rotation.y); - bone.roll += (float) Math.toRadians((float) rotation.z); + // Guard against NaN/Infinity corrupting render state + if (!isValidVec3d(rotation)) return; + + if (metadata.cumulative()) { + // traverse includes self + bone.traverse().forEach(child -> { + child.pitch += (float) Math.toRadians((float) rotation.x); + child.yaw += (float) Math.toRadians((float) rotation.y); + child.roll += (float) Math.toRadians((float) rotation.z); + }); + } else { + bone.pitch += (float) Math.toRadians((float) rotation.x); + bone.yaw += (float) Math.toRadians((float) rotation.y); + bone.roll += (float) Math.toRadians((float) rotation.z); + } } if (!timeline.scale.isEmpty()) { Vec3d scale = timeline.scale.resolve(runningSeconds); - bone.traverse().forEach(child -> { - child.xScale = (float) scale.x; - child.yScale = (float) scale.y; - child.zScale = (float) scale.z; - }); + // Guard against NaN/Infinity corrupting render state + if (!isValidVec3d(scale)) return; + + if (metadata.cumulative()) { + // traverse includes self + bone.traverse().forEach(child -> { + child.xScale = (float) scale.x; + child.yScale = (float) scale.y; + child.zScale = (float) scale.z; + }); + } else { + bone.xScale = (float) scale.x; + bone.yScale = (float) scale.y; + bone.zScale = (float) scale.z; + } } } catch (Exception e) { ///AmbleKit.LOGGER.error("Failed apply animation to {} in model. Skipping animation application for this bone.", boneName, e); } }); - boolean isComplete = !this.shouldLoop && runningSeconds >= this.animationLength; + // Only reset bones when the animation is complete AND has no loop mode (NONE) + // LOOP: will wrap around via getRunningSeconds + // HOLD_ON_LAST_FRAME: should stay on last frame, not reset + // NONE: should reset to starting position when finished + boolean isComplete = this.loopMode == LoopMode.NONE && runningSeconds >= this.animationLength; if (isComplete) { this.resetBones(root, true); } @@ -190,6 +330,19 @@ public void apply(ModelPart root, AnimationState state, float progress, float sp }); } + @Environment(EnvType.CLIENT) + public void apply(ModelPart root, TargetedAnimationState state, @Nullable EffectProvider provider) { + // IMPORTANT: Set animation length BEFORE calculating time values + state.setAnimationLength(this); + + float previous = state.getAnimationTimeSecs() - 0.01F; + state.tick(); + float current = state.getAnimationTimeSecs(); + + 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; @@ -205,14 +358,33 @@ public double getRunningSeconds(AnimationState state, float progress, float spee public double getRunningSeconds(AnimationState state) { float f = (float)state.getTimeRunning() / 1000.0F; - double seconds = this.shouldLoop ? f % this.animationLength : f; + double seconds; + + switch (this.loopMode) { + case LOOP: + seconds = f % this.animationLength; + break; + case HOLD_ON_LAST_FRAME: + // Clamp to animation length so it stays on last frame + seconds = Math.min(f, this.animationLength); + break; + case NONE: + default: + seconds = f; + break; + } return seconds; } public boolean isFinished(AnimationState state) { - if (this.shouldLoop) return false; + // Looping animations never finish + if (this.loopMode == LoopMode.LOOP) return false; + + // Hold on last frame animations are still considered "playing" - they don't finish + if (this.loopMode == LoopMode.HOLD_ON_LAST_FRAME) return false; + // NONE mode: animation finishes when it reaches the end return getRunningSeconds(state) >= this.animationLength; } @@ -222,11 +394,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; @@ -359,6 +534,12 @@ public Vec3d resolve(double time) { if (smoothBefore || smoothAfter) { if (before != null && after != null) { + // Guard against division by zero when keyframes have the same time + double timeDiff = after.time - before.time; + if (timeDiff == 0) { + return beforeData; + } + Integer beforePlusIndex = beforeIndex == 0 ? null : beforeIndex - 1; KeyFrame beforePlus = getAtIndex(this, beforePlusIndex); @@ -368,7 +549,7 @@ public Vec3d resolve(double time) { Vec3d beforePlusData = (beforePlus != null && beforePlus.getPost() != null) ? beforePlus.getPost().resolve(time) : beforeData; Vec3d afterPlusData = (afterPlus != null && afterPlus.getPre() != null) ? afterPlus.getPre().resolve(time) : afterData; - double t = (time - before.time) / (after.time - before.time); + double t = (time - before.time) / timeDiff; return new Vec3d( catmullRom((float) t, (float) beforePlusData.x, (float) beforeData.x, (float) afterData.x, (float) afterPlusData.x), @@ -382,9 +563,13 @@ public Vec3d resolve(double time) { } } else { if (before != null && after != null) { - double alpha = time; + // Guard against division by zero when keyframes have the same time + double timeDiff = after.time - before.time; + if (timeDiff == 0) { + return beforeData; + } - alpha = (alpha - before.time) / (after.time - before.time); + double alpha = (time - before.time) / timeDiff; return new Vec3d( beforeData.getX() + (afterData.getX() - beforeData.getX()) * alpha, diff --git a/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimationAdapter.java b/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimationAdapter.java index ce0b9bf..081b684 100644 --- a/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimationAdapter.java +++ b/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimationAdapter.java @@ -39,7 +39,29 @@ public BedrockAnimation deserialize(JsonElement json, Type typeOfT, JsonDeserial JsonObject jsonObj = json.getAsJsonObject(); double animationLength = jsonObj.has("animation_length") ? jsonObj.get("animation_length").getAsDouble() : -1.0; - boolean shouldLoop = animationLength > 0 && jsonObj.has("loop") && jsonObj.get("loop").getAsBoolean(); + + // Parse loop mode: true, "hold_on_last_frame", or none/false + BedrockAnimation.LoopMode loopMode = BedrockAnimation.LoopMode.NONE; + if (animationLength > 0 && jsonObj.has("loop")) { + JsonElement loopElement = jsonObj.get("loop"); + if (loopElement.isJsonPrimitive()) { + JsonPrimitive loopPrimitive = loopElement.getAsJsonPrimitive(); + if (loopPrimitive.isBoolean()) { + // "loop": true or "loop": false + loopMode = loopPrimitive.getAsBoolean() ? BedrockAnimation.LoopMode.LOOP : BedrockAnimation.LoopMode.NONE; + } else if (loopPrimitive.isString()) { + // "loop": "hold_on_last_frame" + String loopString = loopPrimitive.getAsString(); + if ("hold_on_last_frame".equals(loopString)) { + loopMode = BedrockAnimation.LoopMode.HOLD_ON_LAST_FRAME; + } else if ("true".equalsIgnoreCase(loopString)) { + loopMode = BedrockAnimation.LoopMode.LOOP; + } + // Any other string value defaults to NONE + } + } + } + boolean overrideBones = jsonObj.has("override_previous_animation") && jsonObj.get("override_previous_animation").getAsBoolean(); Map boneTimelines = new HashMap<>(); @@ -119,10 +141,14 @@ public BedrockAnimation deserialize(JsonElement json, Type typeOfT, JsonDeserial jsonMetadata.remove("camera_uses_head"); } + // Whether translations are cumulative (apply to children) - default false + boolean cumulative = jsonObj.has("cumulative") && jsonObj.get("cumulative").getAsBoolean(); + metadata = metadata.withCumulative(cumulative); + metadata = metadata.withExcess(jsonMetadata); } - return new BedrockAnimation(shouldLoop, animationLength, boneTimelines, overrideBones, metadata, sounds); + return new BedrockAnimation(loopMode, animationLength, boneTimelines, overrideBones, metadata, sounds); } private BedrockAnimation.BoneTimeline deserializeBoneTimeline(JsonObject bone) { diff --git a/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimationTracker.java b/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimationTracker.java index 39f06dc..9661e2f 100644 --- a/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimationTracker.java +++ b/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimationTracker.java @@ -69,9 +69,15 @@ private void tick() { } public boolean isDone() { - if (animation.shouldLoop) { + // Looping animations never finish + if (animation.loopMode == BedrockAnimation.LoopMode.LOOP) { return false; } + // Hold on last frame animations are still considered "playing" - they don't finish + if (animation.loopMode == BedrockAnimation.LoopMode.HOLD_ON_LAST_FRAME) { + return false; + } + // NONE mode: animation finishes when it reaches the end return ticks >= animation.animationLength * 20; // Convert seconds to ticks } diff --git a/src/main/java/dev/amble/lib/client/bedrock/BedrockModel.java b/src/main/java/dev/amble/lib/client/bedrock/BedrockModel.java index a07b333..23758d8 100644 --- a/src/main/java/dev/amble/lib/client/bedrock/BedrockModel.java +++ b/src/main/java/dev/amble/lib/client/bedrock/BedrockModel.java @@ -18,11 +18,16 @@ import net.minecraft.client.model.*; import net.minecraft.util.Identifier; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import java.lang.reflect.Type; import java.util.*; @Environment(EnvType.CLIENT) public class BedrockModel implements Identifiable { + private static final Logger LOGGER = LoggerFactory.getLogger(BedrockModel.class); + @SerializedName("format_version") public String version; @SerializedName("minecraft:geometry") @@ -101,10 +106,10 @@ public TexturedModelData create() { List pivot = (cube.pivot != null) ? cube.pivot : bone.pivot; if (cube.uv != null) { - subPart.uv( - cube.uv.get(0), - cube.uv.get(1) - ); + int uvX = cube.uv.get(0); + int uvY = cube.uv.get(1); + + subPart.uv(uvX, uvY); } if (cube.mirror) { subPart.mirrored(); 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 + + '}'; + } +}