Thursday 26 July 2018

View Independent Animations

For some simple animations (squares moving through a scenario for example) one could hesitate between drawing these squares to a canvas or using DOM divs. The animation logic should be independent of the Canvas/Divs, which would be just 2 different views. I've adapted the TweenJS sample that I posted some days ago so that now it can either draw on a Canvas or move divs around. The new sample is here. The top animation is using the Canvas, and the one at the bottom is using Divs.

The idea is simple. I keep the animation logic in an AnimationSystem class that holds a list of the items to animate. requestAnimationFrame will ask AnimationSystem to get updated, which means that it will call to the differen AnimationItems to update its position and draw to the screen. I have an AnimationItem base class and a derived TweenJSAnimationItem that contain all the animation logic, they correspond to the "Model-Controller". They delegate the drawing logic to a View object, so we have a Canvas View and a DOM View. As these View objects have a single function, drawing, I've opted for directly using functions for them, rather than classes. As these functions need state (the canvas context or the DIV being animated) I'm wrapping them in closures. Let's see their code:

//callable interface
interface DrawItemFunc{
    (currentPosition:Vector, size:Number, color:string, alpha:number):void
}


class ViewFunctions{
    //Factory function returning a closure that traps the context, height and width
    static SystemCanvasViewFactory(height:number, width:number, ctx:CanvasRenderingContext2D):()=>void{
        return function SystemCanvasView(){
            ctx.clearRect(0,0, width, height);
        };
    }

    static SystemDOMView(){
        //nothing to do here, only at the ItemView level
    }

    //------------------------------------------

    //Factory function returning a closure trapping the canvas context
    static ItemCanvasViewFactory(ctx:CanvasRenderingContext2D):DrawItemFunc{
        return function ItemCanvasView(currentPosition:Vector, size:Number, color:string, alpha:number){
            ctx.beginPath();
            ctx.rect(currentPosition.x, currentPosition.y, size, size);
            ctx.closePath();
            ctx.fillStyle = color;
            ctx.globalAlpha = alpha;
            ctx.fill();
        };
    }

    //Factory function returning a closure trapping the DOM item
    static ItemDOMViewFactory(domDiv):DrawItemFunc{
        return function ItemDOMView(currentPosition:Vector, size:Number, color:string, alpha:number){
            Object.assign(domDiv.style,{
                //position: "absolute",
                //border: "0.1px solid red",
                backgroundColor: color,
                opacity: alpha,
                left: currentPosition.x + "px",
                top: currentPosition.y + "px",
                width: size + "px",
                height: size + "px"
            });
        };
    }
}

Notice that for the transparency, in the Canvas world we talk about context.globalAlpha, while for DOM items we use the style.opacity property.

For the canvas case, before drawing each AnimationItem via the ItemCanvasView closure, I need to clear up the canvas, this is done by the view associated to the AnimationSystem itself. For the DOM case, as we move items rather than redrawing them, there's nothing to do at the AnimationSystem level.

The AnimationItem and TweenJSAnimationItem contain the animation logic and end up invoking the View closures (pointed by the drawingFunc field).

abstract class AnimationItem{
 currentPosition:Vector;
 size:number;
 color:string;
 alpha:number;
    
 moving:boolean;
 drawingFunc:DrawItemFunc;
 resolveCurrentAnimationFunc:any;

 public abstract update():void;
 
 public draw():void{
  this.drawingFunc(this.currentPosition, this.size, this.color, this.alpha); 
 }
 
 public animate():Promise{
  this.moving = true;
        return new Promise((res, rej) => {
            this.resolveCurrentAnimationFunc = res;
        });
    }
}

class TweenJSAnimationItem extends AnimationItem{
    tweenGroup: any;
    movementTween: TWEEN.Tween;
    tweenStep:number;

    constructor(currentPosition:Vector, size:number=5, color:string="red", 
        alpha:number=1, drawingFunc:DrawItemFunc, logger)
        {
        
        super();
        this.drawingFunc = drawingFunc;
        this.currentPosition = currentPosition;
        this.size = size;
        this.color = color;
        this.alpha = alpha;
        this.moving = false;
        this.tweenStep = 0;
        this.logger = logger;
    }

    public animateDiagonal(endPosition:Vector):Promise{
        this.tweenStep = 0;
        this.tweenGroup = new TWEEN.Group();
        this.movementTween = new TWEEN.Tween(this.currentPosition, this.tweenGroup) // Create a new tween that modifies 'coords'.
            .to({ x: endPosition.x, y: endPosition.y }, 40) // Move to (300, 200) in 1 second.
            .easing(TWEEN.Easing.Quadratic.Out)
            .onComplete(() => {
                console.log("movementTween completed");
                this.moving = false;
                //cleaning up
                this.tweenGroup = null;
                this.movementTween = null;
                //I think this is not necessary, the engine itself takes care of it once the Tween is finished
                //TWEEN.remove(this.movementTween);
                this.resolveCurrentAnimationFunc();
            });
        
        let growTween = new TWEEN.Tween(this, this.tweenGroup) // Create a new tween that modifies 'coords'.
            .to({ size: this.size * 3, alpha: 0.4}, 20)
            //.easing(TWEEN.Easing.Quadratic.Out)
            .onComplete(() => console.log("growTween completed, " + this.size)); 

        let shrinkTween = new TWEEN.Tween(this, this.tweenGroup) // Create a new tween that modifies 'coords'.
            .to({ size: this.size, alpha: 1}, 20)
            //.easing(TWEEN.Easing.Quadratic.Out)
            .onComplete(() => console.log("shrinkTween completed, " + this.size)); 

        growTween.chain(shrinkTween);

        this.movementTween.start(0); // Start the tween immediately.
        growTween.start(0)
        
        return this.animate();
    }

    public animateFall(endY:number):Promise{
        this.tweenStep = 0;
        this.tweenGroup = new TWEEN.Group();
        this.movementTween = new TWEEN.Tween(this.currentPosition, this.tweenGroup) // Create a new tween that modifies 'coords'.
            .to({ x: this.currentPosition.x, y: endY }, 40) // Move to (300, 200) in 1 second.
            .easing(TWEEN.Easing.Quadratic.Out)
            .onComplete(() => {
                console.log("movementTween completed");
                this.moving = false;
                this.resolveCurrentAnimationFunc();
            });
        
        

        this.movementTween.start(0); // Start the tween immediately.

        return this.animate();
    }

    public update():void{
        if(!this.moving){
            return;
        }

        this.tweenGroup.update(this.tweenStep++);
    }

    //implemente in the base class
 // public draw():void{
    // }

   
}

Notice also that for drawing on the canvas we'll invoke ItemCanvasViewFactory just once, as the same instance of the view closure (trapping the canvas context) will be used by all the AnimationItem's. On the contrary, for the divs animation, each AnimationItem will use a different view closure that traps a different div, so we'll invoke ItemDOMViewFactory once for each AnimationItem in the system.

No comments:

Post a Comment