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,487 @@ +/** + * 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 (GUIObject) { warn("animation of " + GUIObject.name + " has started"); }, + * "onTick" : function (GUIObject) { warn("animation has ticked"); }, + * "onComplete" : function (GUIObject) { warn("animation has completed"); }, + * "onStartNoDelay": false, + * "onTickNoDelay" : false, + * "queue" : false, + * }; + * + * "textcolor" only works with objects that can have text (duh) + * "color" only works if the object parameter 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 = deepfreeze({ + "size" : { "types": ["left", "top", "right", "bottom", "rleft", "rtop", "rright", "rbottom"] }, + "color" : { "types": ["r", "g", "b", "a"] }, + "textcolor": { "types": ["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; + let 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 attribute = { + "parameters": {}, + "calc" : x => + { + let object = this.getProperty(property); + Object.keys(attribute.parameters).forEach(type => object[type] = attribute.parameters[type](x)); + this.setProperty(property, object); + } + } + + let original = this.getProperty(property); + parametersList.filter(type => this.properties[property][type] !== undefined) + .forEach(type => attribute.parameters[type] = x => original[type] + x * (this.properties[property][type] - original[type])); + + this.attributes[property] = attribute; +} + +AnimateGUIObject.prototype.sizeStringToObject = function (text) +{ + let types = this.elements["size"].types.slice(0, 4); + let rtypes = this.elements["size"].types.slice(4, 8); + let slist = this.splitSpaces(text).map((t) => t.split("%")); + let size = {}; + types.forEach((type, index) => + { + let 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; + + let uniformTime = this.running ? (time - this.values.start) / this.values.duration : 1; + let x = this.values.curveFunction(uniformTime); + + for (let attribute in 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); + 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 in 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,this); +}; + +/** + * 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); + 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() +{ + // key = objectName , value = [animation1,animation2,animation3,...] + // If a value is [] it should the entry is deleted + this.running = new Map(); + this.queue = new Map(); +}; + +AnimateGUI.prototype.parseGUIObject = function (GUIObject) +{ + return typeof GUIObject === "string" ? Engine.GetGUIObjectByName(GUIObject) : GUIObject; +} + +AnimateGUI.prototype.addAnimation = function (GUIObject, settings) +{ + let object = this.parseGUIObject(GUIObject); + let newAnimation = new AnimateGUIObject(object, settings); + + if (!this.running.has(object.name)) + { + // If no animation running. + this.running.set(object.name,[newAnimation]); + this.queue.delete(object.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. + let animations = this.running.get(object.name).filter(animation => + animation.removeIntersections(newAnimation).isAlive() + ); + animations.push(newAnimation) + this.running.set(object.name, animations); + this.queue.delete(object.name); + } + else + { + // If animaton(s) running and new animation does queue. + if (this.queue.has(object.name)) + this.queue.get(object.name).push(newAnimation); + else + this.queue.set(object.name,[newAnimation]); + } + return this; +}; + +AnimateGUI.prototype.animationsInQueue = function (objectName) +{ + if (this.running.has(objectName) && this.running.get(objectName).length == 0) + this.running.delete(objectName); + + if (this.running.has(objectName)) + { // If there are animations running + return false; + } + else if (!this.queue.has(objectName)) + { // If there are no animations running and no animations pending + this.running.delete(objectName); + return false; + } + else + { // If there are no animations running and animations pending + + let nextAnimation = this.queue.get(objectName).shift(); + if (nextAnimation === undefined) + { + this.queue.delete(objectName); + return false; + } + else + { + this.running.set(objectName, [nextAnimation]); + return true; + } + } +} + +AnimateGUI.prototype.onTick = function () +{ + let time = Date.now(); + for (let name of this.running.keys()) + { + // Repeat loop in case there are multiple animation queued that + // have delay:0 and duration:0 (AnimateGUI.prototype.complete case) + do { this.running.set(name, this.running.get(name).filter(animation => animation.run(time))) } + while (this.animationsInQueue(name)) + } +}; + +AnimateGUI.prototype.complete = function (GUIObject, completeQueue) +{ + let name = this.parseGUIObject(GUIObject).name; + if (!this.running.has(name)) + return; + + for (let animation of this.running.get(name)) + animation.complete(); + + if (!completeQueue || !this.queue.has(name)) + return; + + for (let animation of this.queue.get(name)) + { + animation.values.delay = 0; + animation.values.duration = 0; + } + return this; +} + +AnimateGUI.prototype.end = function (GUIObject, endQueue) +{ + let name = this.parseGUIObject(GUIObject).name; + this.running.delete(name); + if (endQueue === true) + this.queue.delete(name); + + return this; +} + + +let 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) +{ + for (let settings of chainSettingsList) + animate(GUIObject, Object.assign({}, !!defaultSettings ? defaultSettings : {}, settings)); +}