/* Author: Maple * Jan. 24 2026 * */ package org.openautonomousconnection.oacswing.animated; import lombok.Getter; import lombok.Setter; import java.awt.*; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; public class AnimationPath extends ArrayList { @Getter @Setter private int inbetweens; private int animationIterator = 0; private int subFrameIterator = 0; public AnimationPath(int inbetweens, Collection keyFrames) { this.addAll(keyFrames); this.inbetweens = inbetweens; } public AnimationPath(int inbetweens, KeyFrame... keyFrames) { this.addAll(Arrays.stream(keyFrames).toList()); this.inbetweens = inbetweens; } public AnimationPath(int inbetweens) { this.inbetweens = inbetweens; } /** * Get next keyframe according to current path iterator, depending on the current frame without increasing the animation iterators * @return next keyframe in order, depending on the current frame */ public KeyFrame peekNext() { int tempSubIterator = this.subFrameIterator, tempAnimationIterator = this.animationIterator; if(tempSubIterator >= this.inbetweens) { tempSubIterator = 0; tempAnimationIterator++; } if(tempAnimationIterator >= this.size()) return null; tempSubIterator++; KeyFrame current = this.get(tempAnimationIterator); KeyFrame next = this.getNextInOrder(); return getKeyFrame(current, next, tempSubIterator); } /** * Get next keyframe according to current path iterator, depending on the current frame, and increase the animation iterators * @return next keyframe in order, depending on the current frame */ public KeyFrame getNext() { if(this.subFrameIterator >= this.inbetweens) { this.subFrameIterator = 0; this.animationIterator++; } if(this.animationIterator >= this.size()) return null; this.subFrameIterator++; KeyFrame current = this.get(this.animationIterator); KeyFrame next = this.getNextInOrder(); return getKeyFrame(current, next, this.subFrameIterator); } private KeyFrame getKeyFrame(KeyFrame current, KeyFrame next, int subIterator) { CombinedPathMethod method; KeyFrame.PathMethod currentMethod = current.pathMethod(), nextMethod = next.pathMethod(); // Translate EASE_IN_AND_OUT to its respective currently relevant counterpart if(currentMethod.equals(KeyFrame.PathMethod.EASE_OUT) || currentMethod.equals(KeyFrame.PathMethod.EASE_IN_AND_OUT)) currentMethod = KeyFrame.PathMethod.EASE_OUT; if(nextMethod.equals(KeyFrame.PathMethod.EASE_IN) || nextMethod.equals(KeyFrame.PathMethod.EASE_IN_AND_OUT)) nextMethod = KeyFrame.PathMethod.EASE_IN; method = switch (currentMethod) { case LINEAR -> switch (nextMethod) { case LINEAR -> CombinedPathMethod.LINEAR; case EASE_IN -> CombinedPathMethod.LINEAR_EASE_IN; default -> throw new IllegalStateException("Unexpected value: " + nextMethod); }; case EASE_OUT -> switch (nextMethod) { case LINEAR -> CombinedPathMethod.EASE_OUT_LINEAR; case EASE_IN -> CombinedPathMethod.EASE_OUT_AND_IN; case EASE_OUT -> CombinedPathMethod.EASE_OUT; default -> throw new IllegalStateException("Unexpected value: " + nextMethod); }; default -> throw new IllegalStateException("Unexpected value: " + currentMethod); }; return this.inBetween(current, next, method, subIterator); } /** * Get next keyframe according to current path iterator, not depending on the current frame * @return next keyframe in order, not depending on the current frame */ public KeyFrame getNextInOrder() { int i = this.animationIterator + 1; if(i >= this.size()) i--; return this.get(i); } /** * Get previous keyframe according to current path iterator, not depending on the current frame * @return previous keyframe in order, not depending on the current frame */ public KeyFrame getPreviousInOrder() { int i = this.animationIterator - 1; if(i < 0) i++; return this.get(i); } /** * @return true if there are any more frames coming */ public boolean anyMore() { return this.animationIterator + 1 < this.size(); } /** * Reset animation path iterator to start */ public void reset() { this.subFrameIterator = 0; this.animationIterator = 0; } /** * Utility method needed to get in-betweens * @param point point to multiply * @param scalar scalar to multiply with * @return scalar product point */ private static Point multiply(Point point, double scalar) { int x = Math.toIntExact(Math.round(point.x * scalar)); int y = Math.toIntExact(Math.round(point.y * scalar)); return new Point(x, y); } /** * Add two points together; also needed for in-betweens * @param point augend * @param addend addend * @return sum of both points */ private static Point add(Point point, Point addend) { return new Point(point.x + addend.x, point.y + addend.y); } /** * Subtracts one point from another; also needed for in-betweens * @param point minuend * @param subtrahend subtrahend * @return sum of both points */ private static Point subtract(Point point, Point subtrahend) { return new Point(point.x - subtrahend.x, point.y - subtrahend.y); } // Unused right now /** * Calculate point between both points * @param p1 first point * @param p2 second point * @return point in the middle between both points */ private static Point middle(Point p1, Point p2) { return new Point( multiply( add(p1, p2), 0.5 ) ); } private static Point onLine(Point p1, Point p2, double scalar) { return add( p1, multiply( subtract(p2, p1), scalar ) ); } /** * Find in-between with given scalar * @param kf1 first frame * @param kf2 next frame * @param subIterator how far the animation path has proceeded * @return in-between frame */ private KeyFrame inBetween(KeyFrame kf1, KeyFrame kf2, CombinedPathMethod method, int subIterator) { double scalar = this.linear(subIterator); double remainder = kf1.transition() - kf2.transition(); double transition = 0.01; if(remainder > 0) { if(kf1.transition() > kf2.transition()) transition = kf1.transition() + remainder / 2; else transition = 1 - kf2.transition() - remainder / 2; } double threshold = Math.min(kf1.transition(), 1 - kf2.transition()); boolean thresholdReached = scalar > threshold; // Create point on the line between both keyframes if(thresholdReached) { kf1 = new KeyFrame( onLine(kf1.position(), kf2.position(), threshold), (int) (kf1.width() + (kf2.width() - kf1.width()) * threshold), (int) (kf1.height() + (kf2.height() - kf1.height()) * threshold) ); } scalar = switch (method) { case LINEAR -> scalar; case LINEAR_EASE_IN -> thresholdReached ? this.easeIn(subIterator) * (1 - transition) : scalar; case EASE_OUT -> easeOut(subIterator); case EASE_OUT_LINEAR -> thresholdReached ? 2 * (scalar - 0.5) : this.easeOut(subIterator); case EASE_OUT_AND_IN -> thresholdReached ? this.easeOut(subIterator) : this.easeIn(subIterator) * (1 - transition); }; // Position Point difference = subtract(kf2.position(), kf1.position()); Point pos = add(kf1.position(), multiply(difference, scalar)); // Scale int width, height; int dW = kf2.width() - kf1.width(); int dH = kf2.height() - kf1.height(); width = Math.toIntExact(Math.round(kf1.width() + dW * scalar)); height = Math.toIntExact(Math.round(kf1.height() + dH * scalar)); return new KeyFrame(pos, width, height, kf2.pathMethod()); } @Override public AnimationPath clone() { return new AnimationPath(this.inbetweens, new ArrayList<>(this)); } private double linear(double subIterator) { return subIterator / this.inbetweens; } private double easeIn(double subIterator) { return this.linear(subIterator - 1) * this.linear(subIterator - 1) + 1; } private double easeOut(double subIterator) { return 2 * this.linear(subIterator) * this.linear(subIterator); } /** * Enumerator purely to describe two combined PathMethods, like a 2-dimensional PathMethod enum */ private enum CombinedPathMethod { LINEAR, LINEAR_EASE_IN, EASE_OUT, EASE_OUT_LINEAR, EASE_OUT_AND_IN; } }