UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

485 lines (386 loc) • 12.7 kB
import { assert } from "../../../core/assert.js"; import { binarySearchHighIndex } from "../../../core/collection/array/binarySearchHighIndex.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"; /** * * @param {number} time * @param {Keyframe} keyframe * @return {number} */ function compareKeyframeToTime(time, keyframe) { return time - keyframe.time; } /** * 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, see https://en.wikipedia.org/wiki/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? * * @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; 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 { // figure out the right place to insert the key // TODO make use of this.getKeyIndexByTime instead const i = binarySearchHighIndex(keys, key.time, compareKeyframeToTime, 0, last_key_index); // 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} */ 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. * @param {number} time * @returns {number} index of the key */ getKeyIndexByTime(time) { const keys = this.keys; const key_count = keys.length; let i0 = 0; let i1 = key_count - 1; if (time <= keys[0].time) { // before start return 0; } // binary search while (i0 <= i1) { const pivot = (i0 + i1) >>> 1; const key = keys[pivot]; const key_time = key.time; if (key_time < time) { i0 = pivot + 1; } else if (key_time > time) { i1 = pivot - 1; } else { i0 = pivot; break; } } if (i0 > i1) { // swap i0 = i1; } // fast-forward to last matching frame if there are multiple matches while (i0 + 1 < key_count - 1 && keys[i0 + 1].time === time) { i0++; } return i0; } /** * 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.getKeyIndexByTime(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]); this.add(keyframe); } } /** * 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;