UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

519 lines (407 loc) • 13.8 kB
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;