In the last JavaScript Animation post, we left off with code for the AnimationManager class. So far we can do is call addMovement, specifying a direction, the total number of pixels to move, and the number of pixels to move during each "frame". Then, when startAnimation is called, the manager will automatically daisy chain calls to the Window object's setInterval method. When the element specified in each movement instruction has reached it's destination, the next movement instruction is executed. Not bad for a start. But this doesn't yet allow us to do two rather useful things.
- Move in two directions at once, i.e., diagonally or along a curve.
- Move two elements at once.
But before we add new functionality, let's go over what we have so far.
A quick note before we begin. I prefer to use the object literal syntax for class declaration. In case you aren't familiar with it, all it means is that instead of adding member variables and functions to the hidden prototype
object one at a time...
Foo.prototype.someMethod = function(){ ... };
Foo.prototype.some_int = 1;
I add them all at once...
Foo.prototype = {
some_int : 1,
someMethod : function(){ ... }
}
Not that different, but it is easier to read, is a bit more compact (fewer characters=smaller files), and much easier to type since you don't have to constantly be writing out
prototype
. Now there is a downside: since most of the JavaScript IDE plugins I've seen are not able to parse class structures when written this way, so they can't help in outlining the class for quick navigation. For example, the "Outline" view in Eclipse becomes useless, so you might want to stick with the more verbose style if that will slow you down too much.
The AnimationManager
class begins with a few member variable declarations.
function AnimationManager(){
this.initialize();
}
AnimationManager.prototype = {
movements : undefined,
intervals : undefined,
current_movement_index : undefined,
The first two are arrays that will hold the movements we ask the manager to perform and the interval ids returned by the various calls to the Window object's
setInterval
method They are not initialized when declared, because though the AnimationManager class will most often be instantiated as a Singleton, you may want more than one at some point and we don't want them all sharing the same array. (I will talk more about this later). So instead they are initializedin a method called, you guessed it, initialize.
initialize : function(){
this.movements = new Array();
this.intervals = new Array();
},
To those of you familiar with the Prototype JavaScript library, the initialize method is essentially a replacement for the classes constructor method. So when you call
Class.create({ // class definition using object literal syntax })
the
initialize()
method will be called as soon as the class is instantiated. But since I am not using Prototype here, we will accomplish more or less the same thing by calling
initialize()
in the contructor.
So on to the method that tells AnimationManager
what to do. Since there are no private methods in JavaScript, we can't hide the rest of the class, but this is really the only method aside from startAnimation
and stopAnimation
that another class or script would ever have to access. (At least until we start adding functionality later.) The addMovement
method takes four arguments,
element
- the element we want to move around,direction
- the direction (up, down, left, right) we wantelement
to move,pixel_count
- how many pixels to move it,increment
- how many pixels to move it from frame to frame of the animation.
The first three are self-explanatory, but increment might be a little confusing if you still haven't grasped the whole "frame" thing.More on that in a bit. For now, all we need to know is that these four values are put together into an anonymous object and stored (in order) along with any other instructions the manager may receive. But what about the other two variables being stored in the object -
start_x
and start_y
? What are they for? We'll get to that in a moment. addMovement : function( element, direction, pixel_count, increment ){
if( element ){
this.movements.push( {
element : element,
start_x : 0,
start_y : 0,
direction : direction,
pixel_count : pixel_count,
increment : increment
});
}else{
// element doesn't exist
return false;
}
},
Next we have nextMovement
, which takes no arguments, but knows a couple of things about its containing class.
nextMovement : function(){
if( this.current_movement_index < this.movements.length ){
var movement = this.movements[this.current_movement_index];
movement.start_x= this.findPosX( movement.element );
movement.start_y = this.findPosY( movement.element );
...
The member variable
current_movement_index
is simply that, an index of the movement currently being executed. The list that it is associated with is movements
, the array we stored all our movments in as they were added by an outside script or class. The first line of nextMovement
asks if there are still movements left to be executed. If so, we go on to calculate and store the values for start_x
and start_y
. These are simply the starting x and y positions (in pixels) for the element we are about to start moving. These are used to check how much progress has been made with each successive call of executeMovement
. Again, more about them later. Next we come to one of the more confusing parts of the whole project. It's actually not that complicated once you get the hang of it, but it can present a bit of a conceptual hurdle.
When you call a JavaScript function, the variable scope of that function is determined not by its declaration, but by its caller. So even if we create the class Foo
and declare and define the method bar()
as a member function, we cannot guarantee that, when it is called, a this
pointer inside the scope of the method will actually point to an instance of the class Foo
. In practice, this really only applies to callbacks.
Here is an example.
function Foo(){};
Foo.prototype = {
class_name : "Foo",
bar : function(){
alert( "bar belongs to " + this.class_name );
}
}
var foo = new Foo();
some_button.onclick = foo.bar;
When
some_button
is clicked, you will get the following alert message,bar belongs to undefined
Why? Because when
some_button
is clicked, and it calls Foo
's bar
method, bar
is executed inside some_button
's scope, and some_button
does not have a member variable named class_name
. Basically some_button
has stolen and co-opted bar()
, and made it it's own.
But what, you may ask, does this have to do with the AnimationManager
? Well let's have a look. First we will write the method in the same way as we did the Foo.bar()
example.
...
this.intervals[this.current_movement_index] = window.setInterval( function(){
if( !this.executeMovement( this, new Array() ) ){
window.clearInterval( this.intervals[this.current_movement_index] );
this.current_movement_index++;
this.nextMovement( this, new Array() );
}
}, 30 );
return true;
}
},
This looks like a fairly straightforward example of a recursive function call. Each time setInterval
is called, we check to see if executeMovement
is through with its work, and if it is, we clear the interval and queue up the next movment. But run it and you won't get past the second line. The problem is this
. Inside the anonymous function that will be run over and over by setInterval
, the this
pointer refers to the anonymous function object we created, which has no idea about what executeMovement
or intervals
might mean. It would simply tell the interpreter that they are undefined
.
Now you might suggest pointing setInterval
to a member function instead, but this would still result in the same error, only it would happen further up the call stack. In the same way some_button
stole the scope of bar()
away from Foo
, so too would Window.setInterval
steal the member function's scope away from AnimationManager
.
So how do we deal with this
? We need to maintain scope or we'll have to put the entire functionality of AnimationManager
inside setInterval
. The solution turns out to be an otherwise obscure native function called apply()
. apply
is a member of the native JavaScript function
class, so any function
object can use it. It takes two arguments.
thisArg
- the object you wantthis
to resolve to,arguments
- an array of arguments passed to the function you are calling.
So going back to the
Foo.bar
example, if we use apply()
, it would look like this.function Foo(){};
Foo.prototype = {
class_name : "Foo",
bar : function(){
alert( "bar belongs to " + this.class_name );
}
}
var foo = new Foo();
some_button.onclick = function(){
Foo.prototype.bar.apply( foo, new Array() );
}
Now if we click on
some_button
, the alert will say,bar belongs to Foo
So applying (pardon the pun) the same principle to AnimationManager
, we can write it like this.
...
var ani_mgr = this;
this.intervals[this.current_movement_index] = window.setInterval( function(){
if( !AnimationManager.prototype.executeMovement.apply( ani_mgr, new Array() ) ){
window.clearInterval( ani_mgr.intervals[ani_mgr.current_movement_index] );
ani_mgr.current_movement_index++;
AnimationManager.prototype.nextMovement.apply( ani_mgr, new Array() );
}
}, 30 );
}
},
Now when
executeMovement
is called, the this
pointer refers to the instance of AnimationManager
we assigned to the local variable ani_mgr
. We solve the same problem inside the anonymous function by substituting ani_mgr
for this
.
Notice that we set the time between executions of setInterval
to 30ms, as per the formula mentioned in the
first post in the series.
Next we have executeMovement
. It acts mainly as a router. It determines the direction of the movement being executed, and makes a call to the appropriate member function. If the element is to be moved to the right, it calls - you guessed it - moveRight. This is done using a simple switch-case
enclosure, but to make things a bit more readable and yet avoid using cpu intensive string comparisons, I created a series of static member variables that can be used kind of like an Enumeration. The variable declarations are at the end of the class declaration, since static members are not generally declared as a part of the prototype
object, and so do not have to be associated with a particular instance of the class.
executeMovement : function(){
var movement = this.movements[this.current_movement_index];
var element = movement.element;
if( element ){
switch( movement.direction ){
case AnimationManager.UP:
return this.moveUp( movement );
break;
case AnimationManager.RIGHT:
return this.moveRight( movement );
break;
case AnimationManager.DOWN:
return this.moveDown( movement );
break;
case AnimationManager.LEFT:
return this.moveLeft( movement );
break;
default: return false;
}
}else{
console.info( "no element for executeMovement" );
}
},
Next up we have the machinery of the AnimationManager
.
moveUp
- moves the element upmoveRight
- moves the element rightmoveDown
- moves the element downmoveLeft
- moves the element left
moveUp : function( movement ){
var destination = movement.start_y - movement.pixel_count;
var current_y = this.findPosY( movement.element );
if( movement.element && destination < current_y ){
movement.element.style.top = ( current_y - movement.increment ) + "px";
return true;
}else
return false;
},
moveRight : function( movement ){
var destination = movement.start_x + movement.pixel_count;
var current_x = this.findPosX( movement.element );
if( movement.element && current_x < destination ){
movement.element.style.left = ( current_x + movement.increment ) + "px";
return true;
}else
return false;
},
moveDown : function( movement ){
var destination = movement.start_y + movement.pixel_count;
var current_y = this.findPosY( movement.element );
if( movement.element && destination > current_y ){
movement.element.style.top = ( current_y + movement.increment ) + "px";
return true;
}else
return false;
},
moveLeft : function( movement ){
var destination = movement.start_x - movement.pixel_count;
var current_x = this.findPosX( movement.element );
if( movement.element && destination < current_x ){
movement.element.style.left = ( current_x - movement.increment ) + "px";
return true;
}else
return false;
},
I ordered them this way because they match the clockwise directional order you see in CSS classes: top, right, bottom, left. Better to be consistent than confused.
All four direction methods function in the same way: check the current position of the element against the place it's supposed to end up. If it hasn't reached it's destination yet move it increment
pixels. The destination is determined based on the direction the element is supposed to move. If its moving to the right, executeMovement
calls moveRight
. Moving to the right means moving in a positive direction along the x-axis, so we can simply add the the pixel_count
provided by addMovement
to the element's start_x
. So,
var destination = movement.start_x + movement.pixel_count;
To get the
current_x
position of the element, we use findPosX
, which is based on the work of Peter-Paul Koch over at quirksmode.org. It is a cross-browser method for getting the pixel position of any element on the page. It is a little slow for work like this because it has to deal with the quirks inherent to the various browsers, but it works well, so I'm not about to mess with it at the moment. We'll go over the code for it later. var current_x = this.findPosX( movement.element );
Now that
moveRight
still has work to do, it goes ahead and does it.
if( movement.element && current_x < destination ){
movement.element.style.left = ( current_x + movement.increment ) + "px";
return true;
}else
return false;
},
When the moving is done, the method returns
true
. Back in nextMovement
, it tests the return value of the move to determine whether that move should be repeated,or whether the next move should be executed. So returning
true
means do it again, while returning false
means the element has reached it's destination andcurrent_movement_index
should be incremented by one before calling nextMovement
again.
Next we come to the on/off switch for AnimationManager
. After adding movements, we have to call startAnimation
to get things actually moving. It simply setscurrent_movement_index
to 0 (just in case we've already run through some movements), and gets things started by calling nextMovement
. If at some point we want
to stop on a dime, we can call stopAnimation
, which will make sure any and all setInterval
calls are cleared using clearInterval
.
startAnimation : function(){
this.current_movement_index = 0;
this.nextMovement();
},
stopAnimation : function(){
for( var i=0; i<this.intervals.length; i++ ){
window.clearInterval( this.intervals[i] );
}
},
Finnally, we have the Peter-Paul Koch-based methods for finding the x and y position of an element. Actually what it finds is the left
and top
positions of an element, but they are effectively the same thing. I won't go into how he does it, because you should have a look at his site anyway. Its an invaluable resource for web developers. Check it out.
findPosX : function( element ){
var curleft = 0;
if(element.offsetParent){
while(1){
curleft += element.offsetLeft;
if(!element.offsetParent)
break;
element = element.offsetParent;
}
}else if(element.x){
curleft += element.x;
}
return curleft;
},
findPosY : function( element ){
var curTop = 0;
if(element.offsetParent)
while(1){
curTop += element.offsetTop;
if(!element.offsetParent)
break;
element = element.offsetParent;
}
else if(element.y)
curTop += element.y;
return curTop;
}
Finally we have the static values for UP
, DOWN
, LEFT
and RIGHT
. Nothing mysterious here, but this technique comes in handy
for making your code more human readable. Any time you can easily replace numbers with words you should, because a wise man once said, we read our code far more often than we write it.
AnimationManager.UP = 0;
AnimationManager.RIGHT = 1;
AnimationManager.DOWN = 2;
AnimationManager.LEFT = 3;
So that's what we have so far. But what's missing? Some were mentioned at the start.
- Move in multiple directions simultaneously
- Move more than one element at a time
But there are a few more I can think of.
- Pause and Play - Currently, stopping the animation and starting it again will reset the whole thing. We need to be able to pause everything, then start again from were it was.
- Dynamically change movement instructions - If we're going to make this interactive, it can't just have a bunch of hard coded instructions that can't be changed once it gets moving.
We need to be able to modify the instructions based on user interactions. - Test for collisions - Flash allows us to test whether two things on the stage are in contact with one another. We need to be able to do this with just JavaScript, HTML and CSS.
So next time we will make some changes and some additions to
AnimationManager
, as well as create some new wrapper and helper classes that will make the new functionalityeffective, efficient and scalable.
No comments:
Post a Comment