@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
519 lines (407 loc) • 13.8 kB
JavaScript
import { assert } from "../../../core/assert.js";
import { computeHashArray } from "../../../core/collection/array/computeHashArray.js";
import { isArrayEqual } from "../../../core/collection/array/isArrayEqual.js";
import { lerp } from "../../../core/math/lerp.js";
import { invokeObjectClone } from "../../../core/model/object/invokeObjectClone.js";
import { invokeObjectHash } from "../../../core/model/object/invokeObjectHash.js";
import { invokeObjectToJSON } from "../../../core/model/object/invokeObjectToJSON.js";
import { evaluate_two_key_curve } from "./evaluate_two_key_curve.js";
import { Keyframe } from "./Keyframe.js";
/**
* Describes change of a numeric value over time.
* Values are stored in {@link Keyframe}s, interpolation is defined by tangents on {@link Keyframe}s.
* The curve is a cubic Hermite spline.
*
* @example
* const jump_curve = AnimationCurve.from([
* Keyframe.from(0, 0), // start at height 0
* Keyframe.from(0.3, 2), // at time 0.3, jump height will be 2 meters
* Keyframe.from(1, 0) // at time 1.0, land back on the ground
* ]);
*
* jump_curve.evaluate(0.1); // what is the height at time 0.1?
*
* @example
* const curve = AnimationCurve.easeInOut();
*
* sprite.transparency = curve.evaluate(time); // smoothly animate transparency of the sprite
*
* @implements Iterable<Keyframe>
*
* @see https://en.wikipedia.org/wiki/Cubic_Hermite_spline.
* @author Alex Goldring
* @copyright Company Named Limited (c) 2025
*/
export class AnimationCurve {
/**
* Keyframes defining the curve, in chronological order.
* The curve manages the contents of this array, do not modify it directly.
* @readonly
* @type {Keyframe[]}
*/
keys = [];
/**
* Add a new keyframe into the animation.
* Keyframes can be added out of order, they will be inserted into the correct chronological position.
* @param {Keyframe} key
* @returns {number} key index where it was inserted at
*/
add(key) {
assert.defined(key, 'key');
assert.notNull(key, 'key');
assert.equal(key.isKeyframe, true, 'key.isKeyframe !== true');
const keys = this.keys;
const key_count = keys.length;
const last_key_index = key_count - 1;
// Optimization: if the curve is empty or the new key is chronologically
// after the last key, we can simply push it to the end.
if (
last_key_index < 0
|| keys[last_key_index].time <= key.time
) {
// the inserted key goes at the end
keys.push(key);
return key_count;
} else if (key_count > 0 && keys[0].time > key.time) {
keys.unshift(key);
return 0;
}
// figure out the right place to insert the key
const i = this.getKeyIndexLow(key.time) + 1;
// insert key at the right place
keys.splice(i, 0, key);
return i;
}
/**
* Insert multiple keyframes. Input doesn't need to be sorted.
* @param {Keyframe[]} keys
*/
addMany(keys) {
assert.isArray(keys, 'keys');
const key_count = keys.length;
for (let i = 0; i < key_count; i++) {
this.add(keys[i]);
}
}
/**
*
* @param {Keyframe} key
* @returns {boolean} true if the key was removed, false if the key was not found
*/
remove(key) {
const i = this.keys.indexOf(key);
if (i === -1) {
return false;
}
this.keys.splice(i, 1);
return true;
}
/**
* Remove all keys, making the curve empty.
*/
clear() {
this.keys.splice(0, this.keys.length);
}
/**
* Does this curve have any keyframes?
* @return {boolean} true iff keyframe count == 0, false otherwise
*/
isEmpty() {
return this.keys.length === 0;
}
/**
* Number of keyframes in the curve.
* @returns {number}
*/
get length() {
return this.keys.length;
}
/**
* Timestamp of the first keyframe.
* Returns 0 if there are no keyframes.
* @returns {number}
*/
get start_time() {
const keys = this.keys;
if (keys.length === 0) {
return 0;
}
const first = keys[0];
return first.time;
}
/**
* Time of the last chronological key in the curve.
* Returns 0 if there are no keyframes.
* @return {number}
*/
get end_time() {
const keys = this.keys;
const key_count = keys.length;
if (key_count === 0) {
return 0
}
// keys are sorted, so we can rely on the last key's time
return keys[key_count - 1].time;
}
/**
* Time difference between the first and the last keyframe.
* Returns 0 if there are no keyframes.
* @returns {number}
*/
get duration() {
const keys = this.keys;
const key_count = keys.length;
if (key_count < 2) {
// too few frames to compare
return 0;
}
const first = keys[0];
const last = keys[key_count - 1];
return last.time - first.time;
}
/**
* Returns index of a key that is just before or at the time specified.
* Useful for insertion and evaluation logic.
* Note: if time is past the end of last key - index of the last key will be returned instead
* @param {number} time
* @returns {number} index of the key
*/
getKeyIndexLow(time) {
assert.isNumber(time, 'time');
assert.notNaN(time, 'time');
const keys = this.keys;
const key_count = keys.length;
if (key_count === 0) {
// no keys
return 0;
}
if (time <= keys[0].time) {
// before start
return 0;
}
if (time >= keys[key_count - 1].time) {
// after the end
return key_count - 1;
}
let found_index = 0;
let i0 = 0;
let i1 = key_count - 1;
// binary search
while (i0 <= i1) {
const pivot = (i0 + i1) >>> 1;
const key = keys[pivot];
const key_time = key.time;
if (key_time <= time) {
found_index = pivot;
i0 = pivot + 1;
} else if (key_time > time) {
i1 = pivot - 1;
}
}
assert.lessThanOrEqual(keys[found_index].time, time, 'keys[found_index].time >= time');
// fast-forward to last matching frame if there are multiple matches
while (found_index + 2 < key_count && keys[found_index + 1].time === time) {
found_index++;
}
return found_index;
}
/**
* Evaluate interpolated value across the curve at a given time.
* @param {number} time time in seconds
* @return {number} value at the specified time
*/
evaluate(time) {
assert.isNumber(time, 'time');
const keys = this.keys;
const key_count = keys.length;
if (key_count === 0) {
// no keys, produce arbitrary value
return 0;
}
if (key_count === 1) {
// only have one key
return keys[0].value;
}
const i = this.getKeyIndexLow(time);
if (i >= key_count - 1) {
// past last keyframe
return keys[key_count - 1].value;
}
const keyframe0 = keys[i];
if (keyframe0.time >= time) {
// on or past the keyframe
return keyframe0.value;
}
const keyframe1 = keys[i + 1];
return evaluate_two_key_curve(time, keyframe0, keyframe1);
}
/**
* Set tangents of a key to match surrounding keys
* Produces a smoother looking curve
* @param {number} index index of keyframe
*/
alignTangents(index) {
const keys = this.keys;
const last_index = keys.length - 1;
assert.isNonNegativeInteger(index, "index");
assert.lessThanOrEqual(index, last_index, "index overflow");
const key_main = keys[index];
const has_previous = index > 0;
const has_next = index < last_index;
// TODO check out https://github.com/MonoGame/MonoGame/blob/f6ce4cbbe3ca5a93f0d1926f58ed501217a04069/MonoGame.Framework/Curve.cs#L232
if (has_previous) {
const key_previous = keys[index - 1];
const time_span = key_main.time - key_previous.time;
const value_span = key_main.value - key_previous.value;
key_main.inTangent = value_span / time_span;
}
if (has_next) {
const key_next = keys[index + 1];
const time_span = key_next.time - key_main.time;
const value_span = key_next.value - key_main.value;
key_main.outTangent = value_span / time_span;
}
}
/**
*
* @param {number} index Index of keyframe to be affected
* @param {number} weight How much smoothing to apply, 1 will be fully smoothed out and 0 will have no effect at all. Value between 0 and 1
*/
smoothTangents(index, weight) {
const keys = this.keys;
const key = keys[index];
const average = lerp(key.inTangent, key.outTangent, 0.5);
key.inTangent = lerp(key.inTangent, average, weight);
key.outTangent = lerp(key.outTangent, average, weight);
}
smoothAllTangents() {
const n = this.length;
for (let i = 0; i < n; i++) {
this.smoothTangents(i, 1);
}
}
/**
* The copy is deep
* @param {AnimationCurve} other
*/
copy(other) {
this.keys = other.keys.map(invokeObjectClone);
}
/**
*
* @return {AnimationCurve}
*/
clone() {
const curve = new AnimationCurve();
curve.copy(this);
return curve;
}
/**
*
* @param {AnimationCurve} other
* @return {boolean}
*/
equals(other) {
return isArrayEqual(this.keys, other.keys);
}
/**
*
* @return {number}
*/
hash() {
return computeHashArray(this.keys, invokeObjectHash);
}
toJSON() {
return {
keys: this.keys.map(invokeObjectToJSON)
};
}
fromJSON({ keys = [] }) {
this.clear();
const key_count = keys.length;
for (let i = 0; i < key_count; i++) {
const keyframe = new Keyframe();
keyframe.fromJSON(keys[i]);
// The 'add' sorts the keys incrementally, we can do this faster if we assume the input is already sorted.
// But to be safe we don't make that assumption
this.add(keyframe);
}
}
* [Symbol.iterator]() {
for (const key of this.keys) {
yield key;
}
}
/**
* Utility constructor
* @param {Keyframe[]} keys
* @returns {AnimationCurve}
*/
static from(keys) {
const curve = new AnimationCurve();
curve.addMany(keys);
return curve;
}
/**
* S-shaped curve that starts slowly, ramps up and flattens out again.
* Useful for pleasing transitions where exit and entry should not be abrupt.
*
* @param {number} [timeStart]
* @param {number} [valueStart]
* @param {number} [timeEnd]
* @param {number} [valueEnd]
* @return {AnimationCurve}
*/
static easeInOut(
timeStart = 0, valueStart = 0,
timeEnd = 1, valueEnd = 1
) {
return AnimationCurve.from([
Keyframe.from(timeStart, valueStart, 0, 0),
Keyframe.from(timeEnd, valueEnd, 0, 0)
]);
}
/**
* A flat-line curve with a specific start and end time
* @param {number} [timeStart]
* @param {number} [timeEnd]
* @param {number} [value]
* @return {AnimationCurve}
*/
static constant(timeStart = 0, timeEnd = 1, value = 0) {
return AnimationCurve.from([
Keyframe.from(timeStart, value, 0, 0),
Keyframe.from(timeEnd, value, 0, 0)
]);
}
/**
* Curve with two keyframes connected by a straight line
* @param {number} [timeStart]
* @param {number} [valueStart]
* @param {number} [timeEnd]
* @param {number} [valueEnd]
* @return {AnimationCurve}
*/
static linear(
timeStart = 0, valueStart = 0,
timeEnd = 1, valueEnd = 1
) {
const time_delta = timeEnd - timeStart;
// tangent needs to be normalized
const tangent = time_delta === 0 ? 0 : (valueEnd - valueStart) / time_delta;
return AnimationCurve.from([
Keyframe.from(timeStart, valueStart, 0, tangent),
Keyframe.from(timeEnd, valueEnd, tangent, 0)
]);
}
}
/**
* Useful for fast type checks
* @readonly
* @type {boolean}
*/
AnimationCurve.prototype.isAnimationCurve = true;
/**
* @deprecated use `getKeyIndexLow` instead
*/
AnimationCurve.prototype.getKeyIndexByTime = AnimationCurve.prototype.getKeyIndexLow;