Component Architecture

Czech technical University in Prague
Faculty of Information Technology
Department of Software Engineering
© Adam Vesecký, MI-APH, 2019

ECSA and PIXI

ECSA

  • Entity - a single game entity in a scene graph
  • Attribute - a dynamic attribute of an entity
  • Component - a functional object attached to an entity
  • System - a global component
  • Message - a communication object

PIXI architecture

ECSA library

  • a minimalist library implementing ECSA pattern with the most important amenities
  • located in libs/pixi-component
  • a few component-oriented games will be gradually put into src/games
  • features:
    • builder
    • scene manager
    • bindings for PIXI objects
    • messaging pattern
    • reactive components
    • states, flags and tags for objects
    • global keyboard/pointer event handlers
    • simple debugging window

Architecture

Architecture

PIXI.Application

  • PIXI application wrapper, initialized by Game Loop

PIXI.Ticker

  • ticker used internally to update PIXI state from an explicit game loop

GameLoop

  • game loop and initializer, the main entry point

Scene

  • scene manager, provides  queries over game objects  and manages global components

GameObject

  • a game object, contains methods for managing its state and components
  • it's a PIXI object (container, sprite,...) extended by new functions and attributes

GameObjectProxy

  • proxy object that extends PIXI objects by new functions
  • used only internally

Component

  • functional behavior of a game object
  • global components (systems) are attached to the stage

PIXI object bindings

PIXI object bindings

  • instead of creating  PIXI.Container, PIXI.Sprite,... , we can create  ECSA.Container, ECSA.Sprite,...
  • those objects contain the same features as their PIXI counterparts (after typecasting) + features from the component library
  • they can be treated in the same way as regular PIXI objects
  • they use  GameObjectProxy  as a provider of the new features
  • any functional behavior can be implemented in components, having them manipulate with game objects they are attached to

Step-by-step tutorial

Simple setup

  1. import the ECSA library
  2. get the canvas
  3. call the init function
  4. perform all additional steps (resource loading, game model initialization,...)
  5. access the engine.scene object
  • init parameters: width, height, resolution, scene config (optional), and an indicator whether the canvas should be resized to fill the whole screen
  • import * as ECSA from '../libs/pixi-component';

     

    let engine = new ECSA.GameLoop();

    let canvas = (document.getElementById('gameCanvas'as HTMLCanvasElement);

    engine.init(canvas, 8006001, null, true);

    engine.app.loader

      .reset()

      .add('spritesheet''./assets/spritesheet.png')

      .load(onAssetsLoaded);

     

    const onAssetsLoaded = () => {

      engine.scene.clearScene();

    }

New object

  • new ECSA.Sprite() instead of new PIXI.Sprite()
    • don't use ECSA.Sprite.from() since it is pointing to PIXI.Sprite.from() which creates PIXI objects, not objects from ECSA
  • engine.app is a link to PIXI.Application
  • stage.asContainer().addChild() instead of stage.addChild()
    • the stage needs to be cast to a PIXI object in order to make its attributes accessible via TypeScript intellisense
    • in JavaScript, we would use stage.addChild() directly

let sprite = new ECSA.Sprite('mySprite', PIXI.Texture.from('spritesheet'));

sprite.position.set(engine.app.screen.width / 2engine.app.screen.height / 2);

sprite.anchor.set(0.5);

engine.scene.stage.asContainer().addChild(sprite);

Components

  • every functional behavior will be implemented in components
  • every component is attached to a particular game object
  • global components are attached to the stage
  • id  - sequential identifier
  • name  - component name
  • owner  - pointer to the attached game object
  • frequency  - update frequency, by default it's each frame
  • removeWhenFinished  - removes itself, true by default
  • onInit()  - called once when attached to an object
  • onMessage()  - called when a subscribed message arrives
  • onUpdate()  - all dynamic behavior should be put in here
  • onRemove()  - called before a removal from its object
  • onFinish()  - called when  finish()  is invoked
  • subscribe()  - subscribes for a message of a given type
  • unsubscribe()  - unsubscribes itself
  • sendMessage()  - emits a message
  • finish()  - finishes the execution

Component lifecycle

  • components are not added to objects instantly but at the beginning of the loop
  • components can be reused (removed from and added to another object)
  • a component can only have one game object attached at the same time
  • components can receive messages regardless of whether they are running or not
  • components can't receive messages they had sent by themselves
  • Component.finish() stops the component from execution and removes it from the game object (unless removeWhenFinished == false)
  • if a game object is to be removed, all of its components are finalized and removed as well

Simple component

  1. create a component class
  2. implement some of those methods: onInit, onMessage, onRemove, onUpdate, onFinish
  3. attach the component to a game object, using either scene.addGlobalComponent(cmp) or myGameObject.addComponent(cmp)
  4. class RotationComponent extends ECSA.Component {

      onUpdate(delta: numberabsolute: number) {

        this.owner.asContainer().rotation += 0.01 * delta;

      }

    }

     

    // this will assign a component to the stage element

    engine.scene.addGlobalComponent(new RotationComponent());

Component for a group of objects

  • the component can download all objects in its onInit function, assuming that no more objects will be added to the scene later on
  • class RotationComponent extends ECSA.Component {

      objects: ECSA.GameObject[];

     

      onInit() {

        this.subscribe('GAME_OVER');

        this.objects = this.scene.findObjectsByTag('tag_rotating');

      }

     

      onMessage(msg: ECSA.Message) {

        if(msg.action === 'GAME_OVER') { this.finish(); }

      }

     

      onUpdate(delta: numberabsolute: number) {

        this.objects.forEach(obj => obj.asContainer().rotation += 0.01 * delta);

      }

     

      onFinish() { this.objects = null; } // important to prevent memory leaks

    }

Game Object

  • GameObject is an interface, providing PIXI objects with additional functions
  • id  - sequential identifier
  • name  - object name
  • stateId  - numeric state
  • pixiObj  - link to itself, cast to  PIXI.Container
  • parentGameObject  - link to the parent object
  • scene  - link to the scene
  • as<container>()  - casts itself to a PIXI object
  • addComponent()  - inserts a new component
  • assignAttribute()  - inserts a new attribute
  • getAttribute()  - gets an attribute by its name
  • addTag()  - inserts a new tag
  • removeTag()  - removes a tag
  • hasFlag()  - returns true if given flag is set
  • setFlag()  - sets a bit-flag
  • remove()  - removes itself from the scene

Attributes, tags, flags, states

let newObject = new ECSA.Sprite('arrow', arrowTexture);


// we can store any number of attributes of any type

newObject.assignAttribute(Attributes.SPEED, DEFAULT_ARROW_SPEED);


// we can store as much tags as we want, defining common groups/layers/etc

newObject.addTag('projectile');


// we can store flags within a range of 1-128

newObject.setFlag(FLAG_COLLIDABLE);


// we have only one number for states; for more complex states, we can use attributes

newObject.stateId = STATE_MOVING;

Scene

  • Scene serves as a message bus and a slightly optimized query manager
  • app  - pointer to  PIXI.Application
  • name  - name of the scene
  • stage  - PIXI stage cast to  GameObject  interface
  • invokeWithDelay()  - setTimeout() alternative, functions are invoked after update()
  • addGlobalComponent()  - adds a component to the stage
  • assignGlobalAttribute()  - adds an attribute to the stage
  • getGlobalAttribute()  - gets an attribute from the stage
  • getObjectById()  - gets a game object by id
  • findObjectsByQuery()  - looks for game objects by query
  • findObjectsByName()  - looks for game objects by name
  • findObjectsByTag()  - looks for game objects by tag
  • findObjectsByFlag()  - looks for game objects by flag
  • findObjectsByState()  - looks for game objects by state
  • sendMessage()  - sends a message to components
  • clearScene()  - clears up the whole scene

Scene config

  • to make all queries fast, all components and objects are stored in hash-maps, sets and arrays
  • in order not to allocate too much memory for unused features, those need to be enabled

new ECSA.GameLoop().init(canvas, 8006001, {

  debugEnabled: true,

  flagsSearchEnabled: true,

  statesSearchEnabled: true,

  namesSearchEnabled: true,

  tagsSearchEnabled: true,

  notifyAttributeChanges: true,

  notifyFlagChanges: true,

  notifyStateChanges: true,

  notifyTagChanges: true

}, true);

  • debugEnabled  - displays debugging window
  • flagsSearchEnabled  - searching by flags
  • statesSearchEnabled  - searching by states
  • namesSearchEnabled  - searching by names
  • tagsSearchEnabled  - searching by tags
  • notifyAttributeChanges  - sends a message whenever an attribute has changed
  • notifyFlagChanges  - sends a message whenever a flag has changed
  • notifyStateChanges  - sends a message whenever a state has changed
  • notifyTagChanges  - sends a message whenever a tag has changed

Scene queries

    let droids = scene.findObjectsByTag('droid');

    let charged = scene.findObjectsByFlag(FLAG_CHARGED);

    let idle = scene.findObjectsByState(STATE_IDLE);


    let chargedIdleDroids = scene.findObjectsByQuery({

      ownerTag: 'droid',

      ownerFlag: FLAG_CHARGED,

      ownerState: STATE_IDLE

    });

Message

  • Message is used to communicate among components
  • every component contains sendMessage(action: string, data: any) method
  • we can also use scene.sendMessage(msg: Message) when sending from outside a component
  • in order to receive messages of a given type, the component first needs to register itself via subscribe(action)
  • if any component sets expired = true, the message will not be passed any further
  • action  - message type id
  • component  - component that has sent the message
  • gameObject  - contextual object
  • expired  - if true, won't be processed further
  • data  - any data payload

Message sending

  • message passing is very simple, yet it's a bit tricky to determine which components should be responsible for sending/handling of what messages
  • export class GameManager extends ECSA.Component {

      ... some code here

     

      gameOver() {

        this.player.stateId = States.DEAD;

        this.sendMessage(Messages.GAME_OVER, this.score);

     

        // wait 3 seconds and reset the game

        this.scene.invokeWithDelay(3000, () => {

          this.factory.resetGame(this.scenethis.model);

        });

      }

    }

Default messages

  • important note:  if we add a game object to the scene  AFTER  we have added/changed attributes, components etc., the only message we will receive is  OBJECT_ADDED
  • we can subscribe for following messages that are sent by the Scene:
    • ANY  - gets all mesages (good for debugging)
    • OBJECT_ADDED  - object has been added to the scene
    • OBJECT_REMOVED  - object has been removed from the scene
    • COMPONENT_ADDED  - component has been added to an object
    • COMPONENT_REMOVED  - component has been removed
    • ATTRIBUTE_ADDED  - attribute has been added,  notifyAttributeChanges  must be enabled
    • ATTRIBUTE_CHANGED  - attribute has changed,  notifyAttributeChanges  must be enabled
    • ATTRIBUTE_REMOVED  - attr. has been removed,  notifyAttributeChanges  must be enabled
    • STATE_CHANGED  - state has changed,  notifyStateChanges  must be enabled
    • FLAG_CHANGED  - flag has changed,  notifyFlagChanges  must be enabled
    • TAG_ADDED  - tag has been added,  notifyTagChanges  must be enabled
    • TAG_REMOVED  - tag has been removed,  notifyTagChanges  must be enabled
    • SCENE_CLEAR  - scene cleared up

Utilities

Vector

  • helper for 2D vector calculations
  • Vectors are mutable here!

Builder

  • a versatile builder for game objects
  • anchor  - sets an anchor
  • virtualAnchor  - sets an anchor only for calculating positions
  • relativePos  - relative position on the screen within <0,1> range
  • withAttribute  - inserts an attribute
  • withComponent  - inserts a component by passing an object or a function
  • withParent  - assigns a parent object
  • asContainer  - creates  ECSA.Container
  • asSprite  - creates  ECSA.Sprite
  • asText  - creates  ECSA.Text
  • build  - builds a new game object (the boolean parameter indicates whether the data in the builder should be cleared up or not)
  • buildInto  - passes parameters to an existing object

Builder example

  • notice that in case of building multiple objects, we need to pass components via an arrow function -> this will create a new component during each building process
  • build function accepts one parameter - whether it should clean up its data when it's done
A simple builder

new ECSA.Builder(scene)

.relativePos(0.50.92)

.anchor(0.51)

.withAttribute(Attributes.RANGE, 25)

.withFlag(FLAG_COLLIDABLE)

.withFlag(FLAG_RANGE)

.withState(STATE_IDLE)

.withComponent(new TowerComponent())

.withComponent(new AimControlComponent())

.withComponent(new ProjectileSpawner())

.asSprite(PIXI.Texture.from(Assets.TEX_TOWER), 'tower')

.withParent(rootObject)

.build();

4 objects by using one builder

let unitBuilder = new ECSA.Builder(scene)

.anchor(0.5)

.withComponent(() => new UnitController())

.asSprite(PIXI.Texture.from(Assets.TEX_UNIT), 'unit')

.withParent(rootObject);

 

unitBuilder.relativePos(00).build(false)

unitBuilder.relativePos(0.50).build(false)

unitBuilder.relativePos(10.5).build(false)

unitBuilder.relativePos(11).build(false);

Key-Input-Component

  • a simple keyboard handler that only stores currently pressed keys
  • it doesn't send any messages, has to be manually polled
  • export class CannonInputController extends CannonController {

      onUpdate(delta: numberabsolute: number) {

        // assuming that we added this component to the stage

        let cmp = this.scene.findGlobalComponentByName<KeyInputComponent>(ECSA.KeyInputComponent.name);

     

        if (cmp.isKeyPressed(ECSA.Keys.KEY_LEFT)) {

          this.rotate(DIRECTION_LEFT, delta);

        }

     

        if (cmp.isKeyPressed(ECSA.Keys.KEY_RIGHT)) {

          this.rotate(DIRECTION_RIGHT, delta);

        }

     

        if (cmp.isKeyPressed(ECSA.Keys.KEY_UP)) {

          this.tryFire(absolute);

        }

      }

    }

Generic component

  • a generic component that can be used for simple behavior implemented in a functional manner
  • message subscription and unsubscription is handled automagically
  •     new ECSA.GenericComponent('view')

          .setFrequency(0.1// 1 update per 10 seconds

          .doOnMessage('UNIT_EXPLODED', (cmpmsg=> playSound('SND_EXPLOSION'))

          .doOnMessage('UNIT_RESPAWNED', (cmpmsg=> displayWarning(Warnings.UNIT_RESPAWNED))

          .doOnUpdate((cmpdeltaabsolute=> displayCurrentState())

Chain component

  • a very powerful component that works as a chain of commands
  • similar to the Event Loop, it updates its internal queue during its own onUpdate method

Chain component example

// displays a sequence of fancy rotating texts whilst in the bonus mode

this.owner.addComponent(new ChainComponent()

  .beginWhile(() => this.gameModel.mode === BONUS_LEVEL)

      .beginRepeat(4)

          .addComponentAndWait(() => new RotationAnimation(0,360))

          .addComponentAndWait(() => new TranslateAnimation(0,0,2,2))

          .execute(() => textComponent.displayMessage('BONUS 100 POINTS!!!'))

          .execute(() => soundComponent.playSound('bonus'))

      .endRepeat()

  .endWhile()

  .execute(() => viewComponent.removeAllTexts()));

 


// changes background music every 20 seconds

this.owner.addComponent(new ChainComponent()

  .waitForMessage('GAME_STARTED')

  .beginWhile(() => this.scene.stage.hasFlag(GAME_RUNNING))

    .waitTime(20000)

    .execute(() => this.changeBackgroundMusic())

  .endWhile()

Exercise

  • go to lab03_squares.html
  • open src/labs/lab03/squares.ts in your editor
  • using components and the messaging system, implement the animation below