API Documentation for: 1.0.0
Show:

File:MotionGuidePlugin.js

/*
 * MotionGuidePlugin
 * Visit http://createjs.com/ for documentation, updates and examples.
 *
 * Copyright (c) 2010 gskinner.com, inc.
 *
 * Permission is hereby granted, free of charge, to any person
 * obtaining a copy of this software and associated documentation
 * files (the "Software"), to deal in the Software without
 * restriction, including without limitation the rights to use,
 * copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the
 * Software is furnished to do so, subject to the following
 * conditions:
 *
 * The above copyright notice and this permission notice shall be
 * included in all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
 * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
 * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
 * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
 * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
 * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
 * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
 * OTHER DEALINGS IN THE SOFTWARE.
 */

/**
 * @module TweenJS
 */

// namespace:
this.createjs = this.createjs||{};

(function() {
	"use strict";

	/**
	 * A TweenJS plugin for working with motion guides. Defined paths which objects can follow or orient along.
	 *
	 * To use the plugin, install the plugin after TweenJS has loaded. To define a path, add
	 *
	 * 		createjs.MotionGuidePlugin.install();
	 *
	 * <h4>Example</h4>
	 *
	 * 		// Using a Motion Guide
	 * 		createjs.Tween.get(target).to({guide:{ path:[0,0, 0,200,200,200, 200,0,0,0] }},7000);
	 * 		// Visualizing the line
	 * 		graphics.moveTo(0,0).curveTo(0,200,200,200).curveTo(200,0,0,0);
	 *
	 * Each path needs pre-computation to ensure there's fast performance. Because of the pre-computation there's no
	 * built in support for path changes mid tween. These are the Guide Object's properties:<UL>
	 * 		<LI> path: Required, Array : The x/y points used to draw the path with a moveTo and 1 to n curveTo calls.</LI>
	 * 		<LI> start: Optional, 0-1 : Initial position, default 0 except for when continuing along the same path.</LI>
	 * 		<LI> end: Optional, 0-1 : Final position, default 1 if not specified.</LI>
	 * 		<LI> orient: Optional, string : "fixed"/"auto"/"cw"/"ccw"<UL>
	 *				<LI>"fixed" forces the object to face down the path all movement (relative to start rotation),</LI>
	 * 				<LI>"auto" rotates the object along the path relative to the line.</LI>
	 * 				<LI>"cw"/"ccw" force clockwise or counter clockwise rotations including Adobe Flash/Animate-like
	 * 				behaviour. This may override your end rotation value.</LI>
	 * 		</UL></LI>
	 * </UL>
	 * Guide objects should not be shared between tweens even if all properties are identical, the library stores
	 * information on these objects in the background and sharing them can cause unexpected behaviour. Values
	 * outside 0-1 range of tweens will be a "best guess" from the appropriate part of the defined curve.
	 *
	 * @class MotionGuidePlugin
	 * @constructor
	 */
	function MotionGuidePlugin() {
		throw("MotionGuidePlugin cannot be instantiated.")
	}
	var s = MotionGuidePlugin;


// static properties:
	/**
	 * @property priority
	 * @protected
	 * @static
	 */
	s.priority = 0; // high priority, should run sooner

	/**
	 * READ-ONLY. A unique identifying string for this plugin. Used by TweenJS to ensure duplicate plugins are not installed on a tween.
	 * @property ID
	 * @type {String}
	 * @static
	 * @readonly
	 */
	s.ID = "MotionGuide";

// static methods
	/**
	 * Installs this plugin for use with TweenJS. Call this once after TweenJS is loaded to enable this plugin.
	 * @method install
	 * @static
	 */
	s.install = function() {
		createjs.Tween._installPlugin(MotionGuidePlugin);
		return createjs.Tween.IGNORE;
	};

	/**
	 * Called by TweenJS when a new property initializes on a tween.
	 * See {{#crossLink "SamplePlugin/init"}}{{/crossLink}} for more info.
	 * @method init
	 * @param {Tween} tween
	 * @param {String} prop
	 * @param {any} value
	 * @return {any}
	 * @static
	 */
	s.init = function(tween, prop, value) {
		if(prop == "guide") {
			tween._addPlugin(s);
		}
	};

	/**
	 * Called when a new step is added to a tween (ie. a new "to" action is added to a tween).
	 * See {{#crossLink "SamplePlugin/step"}}{{/crossLink}} for more info.
	 * @method step
	 * @param {Tween} tween
	 * @param {TweenStep} step
	 * @param {Object} props
	 * @static
	 */
	s.step = function(tween, step, props) {
		for (var n in props) {
			if(n !== "guide") { continue; }

			var guideData = step.props.guide;
			var error = s._solveGuideData(props.guide, guideData);
			guideData.valid = !error;

			var end = guideData.endData;
			tween._injectProp("x", end.x);
			tween._injectProp("y", end.y);

			if(error || !guideData.orient) { break; }

			var initRot = step.prev.props.rotation === undefined ? (tween.target.rotation || 0) : step.prev.props.rotation;

			guideData.startOffsetRot = initRot - guideData.startData.rotation;

			if(guideData.orient == "fixed") {
				// controlled rotation
				guideData.endAbsRot = end.rotation + guideData.startOffsetRot;
				guideData.deltaRotation = 0;
			} else {
				// interpreted rotation

				var finalRot = props.rotation === undefined ? (tween.target.rotation || 0) : props.rotation;
				var deltaRot = (finalRot - guideData.endData.rotation) - guideData.startOffsetRot;
				var modRot = deltaRot % 360;

				guideData.endAbsRot = finalRot;

				switch(guideData.orient) {
					case "auto":
						guideData.deltaRotation = deltaRot;
						break;
					case "cw":
						guideData.deltaRotation = ((modRot + 360) % 360) + (360 * Math.abs((deltaRot/360) |0));
						break;
					case "ccw":
						guideData.deltaRotation = ((modRot - 360) % 360) + (-360 * Math.abs((deltaRot/360) |0));
						break;
				}
			}

			tween._injectProp("rotation", guideData.endAbsRot);
		}
	};

	/**
	 * Called before a property is updated by the tween.
	 * See {{#crossLink "SamplePlugin/change"}}{{/crossLink}} for more info.
	 * @method change
	 * @param {Tween} tween
	 * @param {TweenStep} step
	 * @param {String} prop
	 * @param {any} value
	 * @param {Number} ratio
	 * @param {Boolean} end
	 * @return {any}
	 * @static
	 */
	s.change = function(tween, step, prop, value, ratio, end) {
		var guideData = step.props.guide;

		if(
				!guideData ||							// Missing data
				(step.props === step.prev.props) || 	// In a wait()
				(guideData === step.prev.props.guide) 	// Guide hasn't changed
		) {
			return; // have no business making decisions
		}
		if(
				(prop === "guide" && !guideData.valid) ||		// this data is broken
				(prop == "x" || prop == "y") ||					// these always get over-written
				(prop === "rotation" && guideData.orient)		// currently over-written
		){
			return createjs.Tween.IGNORE;
		}

		s._ratioToPositionData(ratio, guideData, tween.target);
	};

// public methods
	/**
	 * Provide potentially useful debugging information, like running the error detection system, and rendering the path
	 * defined in the guide data.
	 *
	 * NOTE: you will need to transform your context 2D to the local space of the guide if you wish to line it up.
	 * @param {Object} guideData All the information describing the guide to be followed.
	 * @param {DrawingContext2D} [ctx=undefined] The context to draw the object into.
	 * @param {Array} [higlight=undefined] Array of ratio positions to highlight
	 * @returns {undefined|String}
	 */
	s.debug = function(guideData, ctx, higlight) {
		guideData = guideData.guide || guideData;

		// errors
		var err = s._findPathProblems(guideData);
		if(err) {
			console.error("MotionGuidePlugin Error found: \n" + err);
		}

		// drawing
		if(!ctx){ return err; }

		var i;
		var path = guideData.path;
		var pathLength = path.length;
		var width = 3;
		var length = 9;

		ctx.save();
		//ctx.resetTransform();

		ctx.lineCap = "round";
		ctx.lineJoin = "miter";
		ctx.beginPath();

		// curve
		ctx.moveTo(path[0], path[1]);
		for(i=2; i < pathLength; i+=4) {
			ctx.quadraticCurveTo(
				path[i], path[i+1],
				path[i+2], path[i+3]
			);
		}

		ctx.strokeStyle = "black";
		ctx.lineWidth = width*1.5;
		ctx.stroke();
		ctx.strokeStyle = "white";
		ctx.lineWidth = width;
		ctx.stroke();
		ctx.closePath();

		// highlights
		var hiCount = higlight.length;
		if(higlight && hiCount) {
			var tempStore = {};
			var tempLook = {};
			s._solveGuideData(guideData, tempStore);

			for(var i=0; i<hiCount; i++){
				tempStore.orient = "fixed";
				s._ratioToPositionData(higlight[i], tempStore, tempLook);

				ctx.beginPath();

				ctx.moveTo(tempLook.x, tempLook.y);
				ctx.lineTo(
					tempLook.x + Math.cos(tempLook.rotation * 0.0174533) * length,
					tempLook.y + Math.sin(tempLook.rotation * 0.0174533) * length
				);

				ctx.strokeStyle = "black";
				ctx.lineWidth = width*1.5;
				ctx.stroke();
				ctx.strokeStyle = "red";
				ctx.lineWidth = width;
				ctx.stroke();
				ctx.closePath();
			}
		}

		// end draw
		ctx.restore();

		return err;
	};

// private methods
	/**
	 * Calculate and store optimization data about the desired path to improve performance and accuracy of positions.
	 * @param {Object} source The guide data provided to the tween call
	 * @param {Object} storage the guide data used by the step calls and plugin to do the job, will be overwritten
	 * @returns {undefined|String} Can return an error if unable to generate the data.
	 * @private
	 */
	s._solveGuideData = function(source, storage) {
		var err = undefined;
		if(err = s.debug(source)) { return err; }

		var path = storage.path = source.path;
		var orient = storage.orient = source.orient;
		storage.subLines = [];
		storage.totalLength = 0;
		storage.startOffsetRot = 0;
		storage.deltaRotation = 0;
		storage.startData = {ratio: 0};
		storage.endData = {ratio: 1};
		storage.animSpan = 1;

		var pathLength = path.length;

		var precision = 10;
		var sx,sy, cx,cy, ex,ey, i,j, len, temp = {};

		sx = path[0];		sy = path[1];

		// get the data for each curve
		for(i=2; i < pathLength; i+=4) {
			cx = path[i];			cy = path[i+1];
			ex = path[i+2];			ey = path[i+3];

			var subLine = {
				weightings: [],
				estLength: 0,
				portion: 0
			};

			var subX = sx, subY = sy;
			// get the distance data for each point
			for(j=1; j <= precision;j++) {	// we need to evaluate t = 1 not t = 0
				s._getParamsForCurve(sx,sy, cx,cy, ex,ey, j/precision, false, temp);

				var dx = temp.x - subX, dy = temp.y - subY;
				len = Math.sqrt(dx*dx + dy*dy);
				subLine.weightings.push(len);
				subLine.estLength += len;

				subX = temp.x;
				subY = temp.y;
			}

			// figure out full lengths
			storage.totalLength += subLine.estLength;

			// use length to figure out proportional weightings
			for(j=0; j < precision; j++) {
				len = subLine.estLength;
				subLine.weightings[j] = subLine.weightings[j] / len;
			}

			storage.subLines.push(subLine);
			sx = ex;
			sy = ey;
		}

		// use length to figure out proportional weightings
		len = storage.totalLength;
		var l = storage.subLines.length;
		for(i=0; i<l; i++) {
			storage.subLines[i].portion = storage.subLines[i].estLength / len;
		}

		// determine start and end data
		var startRatio = isNaN(source.start) ? 0 : source.start;
		var endRatio = isNaN(source.end) ? 1 : source.end;
		s._ratioToPositionData(startRatio, storage, storage.startData);
		s._ratioToPositionData(endRatio, storage, storage.endData);

		// this has to be done last else the prev ratios will be out of place
		storage.startData.ratio = startRatio;
		storage.endData.ratio = endRatio;
		storage.animSpan = storage.endData.ratio - storage.startData.ratio;
	};

	/**
	 * Convert a percentage along the line into, a local line (start, control, end) t-value for calculation.
	 * @param {Number} ratio The (euclidean distance) percentage into the whole curve.
	 * @param {Object} guideData All the information describing the guide to be followed.
	 * @param {Object} output Object to save output properties of x,y, and rotation onto.
	 * @returns {Object} The output object, useful for isolated calls.
	 * @private
	 */
	s._ratioToPositionData = function(ratio, guideData, output) {
		var lineSegments = guideData.subLines;

		var i,l, t, test, target;

		var look = 0;
		var precision = 10;
		var effRatio = (ratio * guideData.animSpan) + guideData.startData.ratio;

		// find subline
		l = lineSegments.length;
		for(i=0; i<l; i++) {
			test = lineSegments[i].portion;
			if(look + test >= effRatio){ target = i; break; }
			look += test;
		}
		if(target === undefined) { target = l-1;  look -= test; }

		// find midline weighting
		var subLines = lineSegments[target].weightings;
		var portion = test;
		l = subLines.length;
		for(i=0; i<l; i++) {
			test = subLines[i] * portion;
			if(look + test >= effRatio){ break; }
			look += test;
		}

		// translate the subline index into a position in the path data
		target = (target*4) + 2;
		// take the distance we've covered in our ratio, and scale it to distance into the weightings
		t = (i/precision) + (((effRatio-look) / test) * (1/precision));

		// position
		var pathData = guideData.path;
		s._getParamsForCurve(
			pathData[target-2],			pathData[target-1],
			pathData[target],			pathData[target+1],
			pathData[target+2],			pathData[target+3],
			t,
			guideData.orient,
			output
		);

		if(guideData.orient) {
			if(ratio >= 0.99999 && ratio <= 1.00001 && guideData.endAbsRot !== undefined) {
				output.rotation = guideData.endAbsRot;
			} else {
				output.rotation += guideData.startOffsetRot + (ratio * guideData.deltaRotation);
			}
		}

		return output;
	};

	/**
	 * For a given quadratic bezier t-value, what is the position and rotation. Save it onto the output object.
	 * @param {Number} sx Start x.
	 * @param {Number} sy Start y.
	 * @param {Number} cx Control x.
	 * @param {Number} cy Control y.
	 * @param {Number} ex End x.
	 * @param {Number} ey End y.
	 * @param {Number} t T value (parametric distance into curve).
	 * @param {Boolean} orient Save rotation data.
	 * @param {Object} output Object to save output properties of x,y, and rotation onto.
	 * @private
	 */
	s._getParamsForCurve = function(sx,sy, cx,cy, ex,ey, t, orient, output) {
		var inv = 1 - t;

		// finding a point on a bezier curve
		output.x =	inv*inv * sx + 2 * inv * t * cx + t*t * ex;
		output.y =	inv*inv * sy + 2 * inv * t * cy + t*t * ey;

		// finding an angle on a bezier curve
		if(orient) {
			// convert from radians back to degrees
			output.rotation = 57.2957795 * Math.atan2(
				(cy - sy)*inv + (ey - cy)*t,
				(cx - sx)*inv + (ex - cx)*t
			);
		}
	};

	/**
	 * Perform a check to validate path information so plugin can avoid later error checking.
	 * @param {Object} guideData All the information describing the guide to be followed.
	 * @returns {undefined|String} The problem found, or undefined if no problems.
	 * @private
	 */
	s._findPathProblems = function(guideData) {
		var path = guideData.path;
		var valueCount = (path && path.length) || 0;	// ensure this is a number to simplify later logic
		if(valueCount < 6 || (valueCount-2) % 4) {
			var message =	"\tCannot parse 'path' array due to invalid number of entries in path. ";
			message +=		"There should be an odd number of points, at least 3 points, and 2 entries per point (x & y). ";
			message +=		"See 'CanvasRenderingContext2D.quadraticCurveTo' for details as 'path' models a quadratic bezier.\n\n";
			message +=		"Only [ "+ valueCount +" ] values found. Expected: "+ Math.max(Math.ceil((valueCount-2)/4)*4+2, 6); //6, 10, 14,...
			return message;
		}

		for(var i=0; i<valueCount; i++) {
			if(isNaN(path[i])){
				return "All data in path array must be numeric";
			}
		}

		var start = guideData.start;
		if(isNaN(start) && !(start === undefined)/* || start < 0 || start > 1*/) {	// outside 0-1 is unpredictable, but not breaking
			return "'start' out of bounds. Expected 0 to 1, got: "+ start;
		}
		var end = guideData.end;
		if(isNaN(end) && (end !== undefined)/* || end < 0 || end > 1*/) {	// outside 0-1 is unpredictable, but not breaking
			return "'end' out of bounds. Expected 0 to 1, got: "+ end;
		}

		var orient = guideData.orient;
		if(orient) { // mirror the check used elsewhere
			if(orient != "fixed" && orient != "auto" && orient != "cw" && orient != "ccw") {
				return 'Invalid orientation value. Expected ["fixed", "auto", "cw", "ccw", undefined], got: '+ orient;
			}
		}

		return undefined;
	};

	createjs.MotionGuidePlugin = MotionGuidePlugin;

}());