Index: binaries/data/mods/mod/gui/common/animateGUI.js =================================================================== --- binaries/data/mods/mod/gui/common/animateGUI.js +++ binaries/data/mods/mod/gui/common/animateGUI.js @@ -0,0 +1,480 @@ +/** + * Simple GUI animator addon + * GUIObject can be a string or a GUI object + * Use: + * animate (GUIObject, settings) + * animate.complete (GUIObject, [completeQueue]) + * animate.end (GUIObject, [endQueue]) + * animate.chain (GUIObject, chainSettingsList, [defaultSettings]) + * + * Example of settings: + * + * let settings = { + * "color": "255 255 255 12", + * "color": { + * "r": 255, "g": 255, "b": 255, "a": 12 + * }, + * "textcolor": "140 140 140 28", + * "textcolor": { + * "r": 140, "g": 140, "b": 140, "a": 28 + * }, + * "size": "rleft%left rtop%top rright%right rbottom%bottom", + * "size": { + * "left": 0, "top": 10, "right": 250, "bottom": 300, + * "rleft": 50, "rtop": 50, "rright": 50, "rbottom": 100, + * }, + * "duration": 1000, + * "delay": 100, + * "curve": "linear", + * "onStart": function () { warn("animation has started") }, + * "onTick": function () { warn("animation has ticked") }, + * "onComplete": function () { warn("animation has completed") }, + * "onStartNoDelay": false, + * "onTickNoDelay": false, + * "queue": false, + * }; + * + * "textcolor" only works with objects of type="text" + * "color" only works with if the object sprite is defined as sprite="color: R G B A" + * "size" always works + * + * AnimateGUIObject.default for defaults that can be changed + * AnimateGUIObject.curves for types of curves that can be changed + * + * Each setting can be set independent from the other (including individual values of each setting) + * In case of two animations with the same setting the new animation setting will overrite the old one + */ + +function AnimateGUIObject(GUIObject, settings) +{ + this.GUIObject = GUIObject; + this.settings = deepfreeze(settings); + this.queue = clone((settings.queue === undefined) ? AnimateGUIObject.default.queue : this.settings.queue); + this.values = {}; + this.values.delay = clone((this.settings.delay === undefined) ? (AnimateGUIObject.default.delay) : Math.max(0, this.settings.delay)); + this.values.duration = clone((this.settings.duration === undefined) ? AnimateGUIObject.default.duration : Math.max(0, this.settings.duration)); + // Stores if something has already been marked as done + this.done = {}; + // Stores the settings parsed data, filled when an AnimateGUIObject.prototype.run is called + this.properties = {}; + // Stores the settings parsed actions, filled when AnimateGUIObject.prototype.onStart is called + this.attributes = {}; + this.parseSettingsProperty("size", "sizeStringToObject"); + this.parseSettingsProperty("color", "rgbaStringToObject"); + this.parseSettingsProperty("textcolor", "rgbaStringToObject"); + return this; +} + +AnimateGUIObject.prototype.elements = { + "size": { + "types": deepfreeze(["left", "top", "right", "bottom", "rleft", "rtop", "rright", "rbottom"]) + }, + "color": { + "types": deepfreeze(["r", "g", "b", "a"]) + }, + "textcolor": { + "types": deepfreeze(["r", "g", "b", "a"]) + } +} + +AnimateGUIObject.prototype.setProperty = function (property, object) +{ + switch (property) + { + case "size": this.GUIObject.size = object; break; + case "color": this.GUIObject.sprite = "color: " + this.rgbaObjectToString(object); break; + case "textcolor": this.GUIObject.textcolor = this.rgbaObjectToString(object); break; + } +} +AnimateGUIObject.prototype.getProperty = function (property) +{ + switch (property) + { + case "size": return this.getSize(); + case "color": return this.getColor(); + case "textcolor": return this.getTextcolor(); + } +} + + +// Filled when AnimateGUIObject.prototype.run is first called +AnimateGUIObject.prototype.parseSettingsDefaults = function () +{ + if (this.done.parseSettingsDefaults) + return; + + this.done.parseSettingsDefaults = true; + const time = Date.now(); + this.name = this.GUIObject.name; + this.values.onStart = (this.settings.onStart === undefined) ? () => { } : this.settings.onStart; + this.values.start = time + this.values.delay; + this.values.end = this.values.start + this.values.duration; + this.values.curve = clone((this.settings.curve === undefined || AnimateGUIObject.curves[this.settings.curve] === undefined) ? AnimateGUIObject.default.curve : this.settings.curve); + this.values.curveFunction = AnimateGUIObject.curves[this.values.curve]; + this.running = time < this.values.end; + this.started = time > this.values.start; + +}; + +// Parse property and make sure that the final object is a copy not a reference +AnimateGUIObject.prototype.parseSettingsProperty = function (property, propertyStringParser) +{ + if (this.settings[property] !== undefined) + this.properties[property] = (typeof this.settings[property] === "string") ? this[propertyStringParser](this.settings[property]) : Object.assign({}, this.settings[property]); +}; + +AnimateGUIObject.prototype.parseAction = function (property, parametersList) +{ + if (this.properties[property] === undefined) + return; + + let original = this.getProperty(property); + let attribute = {}; + attribute.parameters = {}; + parametersList.filter(type => this.properties[property][type] !== undefined) + .forEach(type => attribute.parameters[type] = x => original[type] + x * (this.properties[property][type] - original[type])); + + attribute.calc = (x) => + { + let object = this.getProperty(property); + Object.keys(attribute.parameters).forEach(type => object[type] = attribute.parameters[type](x)); + this.setProperty(property, object); + }; + this.attributes[property] = attribute; +} + +AnimateGUIObject.prototype.sizeStringToObject = function (text) +{ + const types = this.elements["size"].types.slice(0, 4); + const rtypes = this.elements["size"].types.slice(4, 8); + const slist = this.splitSpaces(text).map((t) => t.split("%")); + let size = {}; + types.forEach((type, index) => + { + const para = slist[index]; + if (para.length === 2) + { + size[rtypes[index]] = parseInt(para[0]); + if (para[1] !== "") + size[type] = this.evalMathExpression(para[1]); + } + else + size[type] = this.evalMathExpression(para[0]); + }); + return size; +}; + +// Can be either input RGB or RGBA +AnimateGUIObject.prototype.rgbaStringToObject = function (text) +{ + let color = this.splitSpaces(text); + return color.length === 4 ? + { "r": parseInt(color[0]), "g": parseInt(color[1]), "b": parseInt(color[2]), "a": parseInt(color[3]) } : + { "r": parseInt(color[0]), "g": parseInt(color[1]), "b": parseInt(color[2]) }; +} + +// Can be either input {R,G,B} or {R,G,B,A} +AnimateGUIObject.prototype.rgbaObjectToString = function (color) +{ + return parseInt(color.r) + " " + parseInt(color.g) + " " + parseInt(color.b) + (color.a === undefined ? "" : " " + parseInt(color.a)); +} + +// This makes a copy (keeps current state) +AnimateGUIObject.prototype.getSize = function () +{ + return this.GUIObject.size; +} + +/// string input format : "color: R G B A" or "color: R G B" +AnimateGUIObject.prototype.getColor = function () +{ + let color = this.rgbaStringToObject(this.GUIObject.sprite.split(":")[1]); + if (this.properties.color["a"] !== undefined && color["a"] === undefined) + color["a"] = 255; + return color; +} + +// String input format as {r:value ,g:value ,b:value ,a:value} (values are ) +// This makes a copy (keeps current state) +AnimateGUIObject.prototype.getTextcolor = function () +{ + let color = this.GUIObject.textcolor; + this.elements["textcolor"].types.forEach(type => color[type] = parseInt(255 * color[type])); + if (this.properties.textcolor["a"] !== undefined && color["a"] === undefined) + color["a"] = 255; + return color; +} + +// Run for each tick +AnimateGUIObject.prototype.run = function (time) +{ + this.parseSettingsDefaults(); + this.running = time < this.values.end; + this.started = time >= this.values.start; + this.onStart(); + this.update(time); + this.onTick(); + this.onComplete(); + return this.running; +}; + +// Updates animation object attributes +AnimateGUIObject.prototype.update = function (time) +{ + if (!this.started) + return; + + const uniformTime = this.running ? (time - this.values.start) / this.values.duration : 1; + const x = this.values.curveFunction(uniformTime); + + for (let attribute of Object.keys(this.attributes)) + this.attributes[attribute].calc(x) +}; + +// There can be onStart with/without delay and will always execute once per animation +AnimateGUIObject.prototype.onStart = function () +{ + if (!this.values.onStart || (!this.started && !this.settings.onStartNoDelay) || this.done.onStart) + return; + + this.values.onStart(this.GUIObject); + this.done.onStart = true; + // Parse action is called here given that this.values.onStart() could + // want to modify the object's initial values before the animation starts + for (let property of Object.keys(this.properties)) + this.parseAction(property, this.elements[property].types); +}; + +/** + * There can be onTick with/without delay or no onTick at all + * and will always execute once per animation at maximum. + */ +AnimateGUIObject.prototype.onTick = function () +{ + if (!this.settings.onTick || (!this.started && !this.settings.onTickNoDelay)) + return; + + this.settings.onTick(this.GUIObject); +}; + +/** + * There can be onComplete with/without delay or no onTick at all + * and will always execute once per animation at maximum + */ +AnimateGUIObject.prototype.onComplete = function () +{ + if (!this.settings.onComplete || this.running || this.done.onComplete) + return; + + this.settings.onComplete(this.GUIObject); + this.done.onComplete = true; +}; + +// Checks if the animation has any attribute/property that still modifies the GUIObject +AnimateGUIObject.prototype.isAlive = function () +{ + return Object.keys(this.properties).length !== 0; +} + +// Removes properties/attributes from the old animation that the new animation has +AnimateGUIObject.prototype.removeIntersections = function (newAnimateGUIObject) +{ + for (let property of Object.keys(this.properties)) + this.removePropertyIntersections(newAnimateGUIObject, property) + return this; +}; + +AnimateGUIObject.prototype.removePropertyIntersections = function (newAnimateGUIObject, property) +{ + if (this.properties[property] === undefined || newAnimateGUIObject.properties[property] === undefined) + return; + + for (let type of Object.keys(this.properties[property])) + { + if (this.properties[property][type] === undefined || newAnimateGUIObject.properties[property][type] === undefined) + continue; + + delete this.properties[property][type]; + if (this.attributes[property] !== undefined && this.attributes[property].parameters !== undefined) + delete this.attributes[property].parameters[type]; + } + + if (Object.keys(this.properties[property]).length !== 0) + return; + + delete this.properties[property]; + delete this.attributes[property]; +}; + +// Jump to the end of the animation, onStart/onTick/onComplete behaviour doesn't change. +AnimateGUIObject.prototype.complete = function () +{ + this.value.end = Date.now(); +} + +// Evaluates +,- expressions +AnimateGUIObject.prototype.evalMathExpression = function (text) +{ + let val = 0; + let vals = text.split(/((?:\+|\-)+)/); + if (vals[0] == "") vals.shift(); else vals.unshift("+"); + + for (let i = 0; i * 2 < vals.length; ++i) + val += parseInt(vals[i] + vals[i + 1]); + + return val; +}; + +// Splits text with spaces taking into account repeated spaces. +AnimateGUIObject.prototype.splitSpaces = function (text) +{ + return text.split(" ").filter(t => t !== ""); +}; + +AnimateGUIObject.curves = { + "ease-in-out": x => x * x * x * (x * (x * 6 - 15) + 10), + "ease-in-out-2": x => 3 * x * x * (1 - x) + x * x * x, + "linear": x => x +}; + +AnimateGUIObject.default = { + "duration": 150, + "curve": "ease-in-out", + "delay": 0, + "queue": false, +}; + + +// Manages all the animations +function AnimateGUI() +{ + this.GUIObjects = {}; + this.GUIObjectsQueue = {}; +}; + +AnimateGUI.prototype.parseGUIObject = function (GUIObject) +{ + return typeof GUIObject === "string" ? Engine.GetGUIObjectByName(GUIObject) : GUIObject; +} + +AnimateGUI.prototype.addAnimation = function (GUIObject, settings) +{ + const _GUIObject = this.parseGUIObject(GUIObject); + let newAnimation = new AnimateGUIObject(_GUIObject, settings); + + if (this.GUIObjects[_GUIObject.name] === undefined) + { + // If no animation running. + this.GUIObjects[_GUIObject.name] = [newAnimation]; + delete this.GUIObjectsQueue[_GUIObject.name]; + } + else if (!newAnimation.queue) + { + // If animation(s) running and new animation doesn't queue. + // Delete parts of the other animations that conflic with the new one. + this.GUIObjects[_GUIObject.name] = this.GUIObjects[_GUIObject.name].filter((animation) => + { + return animation.removeIntersections(newAnimation).isAlive(); + }); + this.GUIObjects[_GUIObject.name].push(newAnimation); + delete this.GUIObjectsQueue[_GUIObject.name]; + } + else + { + // If animaton(s) running and new animation does queue. + if (this.GUIObjectsQueue[_GUIObject.name] === undefined) + this.GUIObjectsQueue[_GUIObject.name] = []; + this.GUIObjectsQueue[_GUIObject.name].push(newAnimation); + } + return this; +}; + +AnimateGUI.prototype.animationsInQueue = function (objectName) +{ + // If there are animations running + if (this.GUIObjects[objectName].length !== 0) + return false; + // If there are no animations running and no animations pending + else if (this.GUIObjectsQueue[objectName] === undefined) + delete this.GUIObjects[objectName]; + // If there are no animations running and animations pending + else + { + let nextAnimation = this.GUIObjectsQueue[objectName].shift(); + if (nextAnimation === undefined) + delete this.GUIObjectsQueue[objectName]; + else + { + this.GUIObjects[objectName] = [nextAnimation]; + return true; + } + } + return false; +} + +AnimateGUI.prototype.onTick = function () +{ + const time = Date.now(); + for (let objectName of Object.keys(this.GUIObjects)) + { + // Repeat loop in case there are multiple animation queued that have delay:0 and duration:0 (AnimateGUI.prototype.complete case) + do { this.GUIObjects[objectName] = this.GUIObjects[objectName].filter(animation => animation.run(time)) } + while (this.animationsInQueue(objectName)) + } +}; + +AnimateGUI.prototype.complete = function (GUIObject, completeQueue) +{ + const GUIObjectName = this.parseGUIObject(GUIObject).name; + if (this.GUIObjects[GUIObjectName] === undefined) + return; + + for (let animation of Object.keys(this.GUIObjects[GUIObjectName])) + this.GUIObjects[GUIObjectName][animation].complete(); + + if (completeQueue !== true || this.GUIObjectsQueue[GUIObjectName] === undefined) + return; + + for (let animation of this.GUIObjectsQueue[GUIObjectName]) + { + animation.value.delay = 0; + animation.value.duration = 0; + } + return this; +} + +AnimateGUI.prototype.end = function (GUIObject, endQueue) +{ + const GUIObjectName = this.parseGUIObject(GUIObject).name; + delete this.GUIObjects[GUIObjectName]; + if (endQueue === true) + delete this.GUIObjectsQueue[GUIObjectName]; + + return this; +} + + +const animate = function (GUIObject, settings) { animate.gui.addAnimation(GUIObject, settings); } +animate.onTick = function () { animate.gui.onTick(); } +animate.gui = new AnimateGUI(); + +// Ends animation as if had reached end time. onStart/onTick/onComplete called as usual +// Optional argument to complete all remaining queues. +animate.complete = function (GUIObject, completeQueue) { animate.gui.complete(GUIObject, completeQueue); } + +// Ends animation at given time of command. onStart/onTick/onComplete not called. +// Optional argument to end all remaining queues. +animate.end = function (GUIObject, endQueue) { animate.gui.end(GUIObject, endQueue); } + +/** + * Makes a chained animation + * @param {Object} GUIObject + * @param {List} chainSettingsList + * @param {Object} defaultSettings + */ +animate.chain = function (GUIObject, chainSettingsList, defaultSettings) +{ + const _defaultSettings = defaultSettings === undefined ? {} : defaultSettings; + for (let settings of chainSettingsList) + animate(GUIObject, Object.assign({}, _defaultSettings, settings)); +} \ No newline at end of file