How I Used Typescript Mixins In Skirmish's Bracket Engine

Dec. 9, 2020

Kyle Kaniecki

During the early phases of my first, initial rework of Skirmish's tournament bracket view, I knew I had to break the functionality of the entire system into multiple components, but how would I do that?

My first initial gut reaction was to use some kind of polymorphism where you would add objects to a base view classes "functionality" array that would execute some code. Some quick pseudocode would look something like:

class WebGLView {
  constructor(private _canvas: HTMLCanvasElement) {}

  setup() {
    // Do some basic setup, like creating a renderer and camera for the renderer
  }

  destroy() {
    // Do some teardown -- maybe remove event listeners, etc
  }

  animate() {
    // Do some rendering code here to render a basic scene
    requestAnimationFrame(this.animate);
  }
}


class ZoomWebGLView extends WebGLView {
  setup() {
    super.setup();
    // Do a little bit more setup here for zooming and panning
  }
}

But, very quickly, I realized that this wouldn't work well as the bracket engine scaled. If we wanted to add many things to a view, like zooming, mouse events, tween animations, and shadow effects, our inheritance tree would get extremely convoluted and deep. This would make refactoring a nightmare if anything needed to change in the base class. Many files would likely change, and implicit dependencies would happen between parent and child classes as more features were added. I knew there would likely be a better way, one which is much more plug and play, rather than long chains of inheritance. So, after a couple days of research, I stumbled upon the concept of anonymous classes in Typescript.

For those of you who haven't heard, these types of classes are extremely good for the "plugin" architecture and that is what I was looking for. This also allows for other developers to create their own plug-ins for the engine and publish them on our site for others to use (after approval, and that is also an idea for the future that is percolating). Just to catch you up, the basic concept is represented here.

// with-tween-animations-mixin.ts
export function withTweenAnimations<TBase extends Constructor<WebGLView>>(
  Base?: TBase
) {
  return class extends Base {
    // Add some class attributes that are needed for tween animiations
    tweenManager = new TweenManager();

    toggleAnimations(value: boolean) {
      this.animationsDisabled = value;
    }

    destroy() {
      super.destroy();
      this.tweenManager.destroy();
    }

    animate(time?: number) {
      super.animate(time);
      if (!this.animationsDisabled) {
        this.tweenManager.update(time);
      }
    }
  };
}

// my-new-animation-view.ts
export class MySuperCoolBracketView extends withTweenAnimations(WebGLView) { }

As you can see, we actually add functionality to the class that is passed to us without adding any classes to the inheritance tree. This allows me to do some very interesting things with dynamically adding functionality at runtime to an angular component. But more importantly, it allows me to quickly swap in or out functionality to a webgl view without changing any other files in the codebase. For completeness sake, let's give the inheritance the benefit of the doubt and give a similar example.

// tween-animation-view.ts
export class TweenAnimationView extends WebGLView {

  toggleAnimations(value: boolean) {
    this.animationsDisabled = value;
  }

  destroy() {
    super.destroy();
    this.tweenManager.destroy();
  }
  animate(time?: number) {
    super.animate(time);
    if (!this.animationsDisabled) {
      this.tweenManager.update(time);
    }
  }
}

// my-super-cool-bracket-view.ts
export class MySuperCoolBracketView extends TweenAnimationView {}

Here, we add functionality through straight up inheritance. A bracket view is a tween view, which is a webGL view. However, the problem occurs when you try to add more functionality to the bracket view. With the inheritance example, what files would have to change? Well, the cool bracket view, the zoom view, as well as the new view you're creating would have to change. Each would have to update their base class to insert the new base class. Also, let's say we went to add mouse events to the view. Is a view that listens for mouse events explicitly a view that requires tweens? No, not necessarily. That is an implicit dependency, and a huge code smell. So let's look at what would change with the anonymous class example to show the better alternative.

// with-mouse-events-mixin.ts
export function withMouseEvents<TBase extends Constructor<WebGLComponent>>(
  Base?: TBase
) {
  return class extends Base {

    destroy() {
      super.destroy();
      // Maybe destroy the raycaster
    }

   animate() {
       // detect any intersections
   }
  };
}

// my-super-cool-bracket-view.ts
export class MySuperCoolBracketView extends withMouseEvents(withTweenAnimations(WebGLView)) { }

Ok, so check that out! We automatically added mouse events to our super cool bracket view without updating anything with the tween view mixin. And this makes sense when looking at it in an abstract way. A mouse events view isn't explicitly dependent on tween animations, but rather some random webgl view that we will anonymously add data and functionality to. The concrete class MySuperCoolBracketView inheriting the functionality essentially doesn't even know the functionality is added. Very cool.


comments

Please enter a valid display name
Please enter a valid email
Your email will not be displayed publicly
Please enter a comment