Repository: playcanvas/engine (JavaScript)

Issue:
Make anim events fire for reversed anims.
### Description

Make anim events work when an animation is running in reverse due to using a negative speed setting.

### Steps to Reproduce

In the Animation Events example, if you swap the sign on the speed, the anim events don't fire:

`113: modelEntity.anim.assignAnimation("Walk", walkTrack, undefined, -0.62);`

Anim event set at time 0 is fired at the start and end of a non looping animation
Thread: https://forum.playcanvas.com/t/anim-system-issues/26559/

Project: https://playcanvas.com/project/956782

Anim Event is set on both non looping animations at time 0

The event is fired at the start of the animation and at the end


Discussion: forum link https://forum.playcanvas.com/t/anim-events-for-reversed-anims/23346/3

Hey, just wanted to check if there has been any progress on this issue? I frequently run into it when linking sounds to animations. 
Unfortunately, no progress on this



Fix (patch):
diff --git a/src/framework/anim/evaluator/anim-clip.js b/src/framework/anim/evaluator/anim-clip.js
index a58e7db805d..5edba2d97bc 100644
--- a/src/framework/anim/evaluator/anim-clip.js
+++ b/src/framework/anim/evaluator/anim-clip.js
@@ -9,6 +9,12 @@ import { AnimSnapshot } from './anim-snapshot.js';
  * @ignore
  */
 class AnimClip {
+    static eventFrame = {
+        start: 0,
+        end: 0,
+        residual: 0
+    };
+
     /**
      * Create a new animation clip.
      *
@@ -65,7 +71,11 @@ class AnimClip {
     }
 
     set speed(speed) {
+        const signChanged = Math.sign(speed) !== Math.sign(this._speed);
         this._speed = speed;
+        if (signChanged) {
+            this.alignCursorToCurrentTime();
+        }
     }
 
     get speed() {
@@ -104,35 +114,100 @@ class AnimClip {
         return this._eventCursor;
     }
 
-    alignCursorToCurrentTime() {
-        this._eventCursor = 0;
-        // move the event cursor to the event that should fire after the current time
-        while (this._track.events[this._eventCursor] && this._track.events[this._eventCursor].time < this.time) {
-            this._eventCursor++;
+    get eventCursorEnd() {
+        return this.isReverse ? 0 : this._track.events.length - 1;
+    }
+
+    get nextEvent() {
+        return this._track.events[this._eventCursor];
+    }
+
+    get isReverse() {
+        return this._speed < 0;
+    }
+
+    nextEventAheadOfTime(time) {
+        if (!this.nextEvent) return false;
+        return this.isReverse ? this.nextEvent.time <= time : this.nextEvent.time >= time;
+    }
+
+    nextEventBehindTime(time) {
+        if (!this.nextEvent) return false;
+        if (time === this.track.duration) {
+            return this.isReverse ? this.nextEvent.time >= time : this.nextEvent.time <= time;
         }
+        return this.isReverse ? this.nextEvent.time > time : this.nextEvent.time < time;
     }
 
-    activeEventsForFrame(frameStartTime, frameEndTime) {
-        if (frameStartTime === 0) {
-            this.eventCursor = 0;
+    resetEventCursor() {
+        this._eventCursor = this.isReverse ? this._track.events.length - 1 : 0;
+    }
+
+    moveEventCursor() {
+        this._eventCursor += (this.isReverse ? -1 : 1);
+        if (this._eventCursor >= this.track.events.length) {
+            this._eventCursor = 0;
+        } else if (this._eventCursor < 0) {
+            this._eventCursor = this.track.events.length - 1;
         }
-        let clippedFrameDuration;
+    }
+
+    clipFrameTime(frameEndTime) {
+        const eventFrame = AnimClip.eventFrame;
+        eventFrame.start = 0;
+        eventFrame.end = frameEndTime;
+        eventFrame.residual = 0;
+
         // if this frame overlaps with the end of the track, we should clip off the end of the frame time then check that clipped time later
-        if (frameEndTime > this.track.duration) {
-            clippedFrameDuration = frameEndTime - this.track.duration;
-            frameEndTime = this.track.duration;
+        if (this.isReverse) {
+            if (frameEndTime < 0) {
+                eventFrame.start = this.track.duration;
+                eventFrame.end = 0;
+                eventFrame.residual = frameEndTime + this.track.duration;
+            }
+        } else {
+            if (frameEndTime > this.track.duration) {
+                eventFrame.start = 0;
+                eventFrame.end = this.track.duration;
+                eventFrame.residual = frameEndTime - this.track.duration;
+            }
         }
+    }
 
-        // check whether the next event occurs during the current frame. If the frame end time is at the end of the track then test that inclusively too
-        while (this.track.events[this.eventCursor] && this.track.events[this.eventCursor].time >= frameStartTime && (frameEndTime === this.track.duration ? this.track.events[this.eventCursor].time <= frameEndTime : this.track.events[this.eventCursor].time < frameEndTime)) {
-            const event = this.track.events[this.eventCursor];
-            this._eventHandler.fire(event.name, { track: this.track, ...event });
-            this.eventCursor++;
+    alignCursorToCurrentTime() {
+        this.resetEventCursor();
+        while (this.nextEventBehindTime(this._time) && this._eventCursor !== this.eventCursorEnd) {
+            this.moveEventCursor();
         }
+    }
 
-        // if we had to clip the current frame, then we should check the start of the track for events during that clipped duration
-        if (Number.isFinite(clippedFrameDuration)) {
-            this.activeEventsForFrame(0, clippedFrameDuration);
+    fireNextEvent() {
+        this._eventHandler.fire(this.nextEvent.name, { track: this.track, ...this.nextEvent });
+        this.moveEventCursor();
+    }
+
+    fireNextEventInFrame(frameStartTime, frameEndTime) {
+        if (this.nextEventAheadOfTime(frameStartTime) && this.nextEventBehindTime(frameEndTime)) {
+            this.fireNextEvent();
+            return true;
+        }
+        return false;
+    }
+
+    activeEventsForFrame(frameStartTime, frameEndTime) {
+        // get frame start and end times clipped to the track duration with the residual duration stored
+        const eventFrame = AnimClip.eventFrame;
+        this.clipFrameTime(frameEndTime);
+        // fire all events that should fire during this clipped frame
+        const initialCursor = this.eventCursor;
+        while (this.fireNextEventInFrame(frameStartTime, eventFrame.end)) {
+            if (initialCursor === this.eventCursor) {
+                break;
+            }
+        }
+        // recurse the process until the residual duration is 0
+        if (this.loop && Math.abs(eventFrame.residual) > 0) {
+            this.activeEventsForFrame(eventFrame.start, eventFrame.residual);
         }
     }