Jump to content

[Oxygen] Tutorial #3 - Advanced yed easy to use input handling with InputHandler component


PsichiX
 Share

Recommended Posts

List of tutorials:

----------

Hi!
Today i'll start series of tutorials about making games with Oxygen Core game engine. Especially for that series i've created Oxygen Playground and Embeddable Oxygen Live Experiments services to easly create and store tutorials (feel free to play with it :D).

What is Oxygen Ecosystem? It's a bunch of tools and services that helps making games with Oxygen Core game engine.

What is Oxygen Core? It's an ECS (Entity-Component-System) game engine based on concept that your scene is a tree of actors (entities) that may have some behaviour (component) that will make him HIM (think of it as: entity is naked and stupid until you attach some components to it). It's goal is to focus on very rapid prototyping so making games with it is fast. You can check it out on Github: https://github.com/PsichiX/Oxygen (it's a NPM module used with webpack or any other bundler, but minified script bundle is also possible to use). Anyway, I want to stop talking about engine all tech stuff and reveal them one by one in every tutorial.

Okey, so first i'll show you what you'll have learn today:
Playground project: http://oxygen.experiments.psichix.io/p/BkbVmOdNz
Live experiment: http://oxygen.experiments.psichix.io/BkbVmOdNz
(btw. Is there a way to embed this in post? it's embeddable view of live experiments so works like movies on websites)

Launch Playground project and first what you'll see is code editor on the left and live preview with console on the right. Okey, so already opened code file is your index.js file, which is your game entry point.

// import engine related stuff to initialize it further.
import { lazyInitialization, System, vec4 } from 'oxygen-core';
// import component so we can register it in engine.
import SomeController from './SomeController';

// effortlessly initialize Oxygen engine systems.
lazyInitialization({
  // renderer will use canvas with id 'playground-screen'.
  render: { screen: 'playground-screen' },
  // game data will be stored under 'playground' id.
  store: { id: 'playground' },
  asset: {
    // in prior to be able to use playground project assets, we have to use it's fetch engine.
    fetchEngine: PLAYGROUND.fetchEngine,
    // we don't want to cache any assets fetched into our game.
    fetchOptions: { cache: 'no-store' }
  }
});

// get instances of already registered engine systems.
const {
  AssetSystem,
  RenderSystem,
  EntitySystem
} = System.systems;

// register component used by animated node (WebGL logo with 'hello' text).
// if we do not register this component, game will crash when some node will try to use it.
EntitySystem.registerComponent('SomeController', SomeController.factory);

// change renderer clear color to dark gray.
vec4.set(RenderSystem.clearColor, 0.25, 0.25, 0.25, 1);

// here we make kick-start of our game:
// - load first JSON asset with game config and list of used assets.
// - asynchronously load all assets from config list.
// - trigger event that will load and initialize scene from 'game.json' asset
//   (scenes and prefabs are just JSON files that describes entities tree).
AssetSystem.load('json://config.json')
  .then(configAsset => AssetSystem.loadAll(configAsset.data.assets))
  .then(() => System.events.triggerLater(
    'change-scene',
    'scene://game.json'
  ));

This file, created once is mostly never changed because it's just a bootstrap code to kick-start your game.

Next click on application menu (upper left icon before title) and you'll see list of project files. Here you can open editable files or create new and delete old, also download them (but if you want to download whole project just click upper right EXPORT button and zipped project will land on your HDD). If you're curious what the rest of buttons are: RUN reloads live preview to see your changes. SAVE makes sure that your changes aren't lost and SHARE will show you a bunch of links you can use to share your project/changes with other devs (SHARE works only if you previously save project).

Okey, so what actually makes our logo and text scaling up and down? It's entity that uses SomeController component and that component makes scaling animation like that:

import { Script } from 'oxygen-core';

// create component class that extends engine Script component (extending Script
// component is a base logic component so it will save us from writing a lot of code).
export default class SomeController extends Script {

  // propsTypes it's a place that you may want to describe serializable properties.
  static get propsTypes() {
    return {
      speed: 'number' // commonly used types: number, string, any.
    };
  }

  // this static method will be registered into EntitySystem
  // and it must return new instance of requested component.
  static factory() {
    return new SomeController();
  }

  // it's good practice to initialize all instance properties in constructor. 
  constructor() {
    super();

    this.speed = 1;
    this._phase = 0;
  }

  // onUpdate() is called on every update tick, here you put your component logic.
  onUpdate(deltaTime) {
    // get your instance entity.
    const { entity } = this;

    // move animation phase by delta time multiplied by animation speed.
    this._phase += deltaTime * 0.01 * this.speed;
    // calculate animated scale from animation phase.
    const v = 1 + 0.5 * Math.sin(this._phase);
    // apply new scale to entity.
    // NOTE: in most HTML5 game engines you'll encounter few scenarios:
    // - different entity types are just extension of base entity/node/actor;
    // - there are no entities and components - everything is a node;
    // Oxygen loves separation of entity from it's logic and prefer to make logic
    // as components/behaviours attached into entity so given logic may be used
    // by many entity types, not only one.
    entity.setScale(v, v);
  }

}

And the question you may ask right now: ok, but where is our scene tree, if it's not present in any of code above? Let's see! Open files list and click on game.json file to open it in editor.

{
  "name": "root",
  "components": {
    "Camera2D": {
      "zoomOut": 600,
      "zoomMode": "keep-aspect"
    },
    "Sprite": {
      "shader": "sprite-transparent.json",
      "width": 300,
      "height": 125,
      "xOffset": 150,
      "yOffset": 135,
      "overrideBaseTexture": "logo.png"
    },
    "TextRenderer": {
      "shader": "text-outline-transparent.json",
      "font": "verdana.fnt",
      "halign": "center",
      "valign": "top",
      "color": [1, 1, 1, 1],
      "colorOutline": [0, 0, 0, 1],
      "text": "Hello, Oxygen!"
    },
    "SomeController": {
      "speed": 0.1
    }
  }
}

This is single entity (root) scene tree, those are mostly called "prefabs" because their simplicity makes them good for instancing complex actors on scene, but here we pretend that our logo prefab is just a scene.
As you may see above, there is entity named "root" that have 4 components: Camera2D to view our scene (actually to view this entity and it's children, so it's a good practice to keep cameras in root entities), Sprite to render WebGL logo with sprite transparent shader and logo texture, TextRenderer to draw "hello" text from bitmap font with outlined text shader, and SomeController that animates our entity with given speed - as you can see, every component do it's specified job! Generally scene and prefab JSON files are the place to balance your game, and code should be only the place where you put some logic and configure it with properties in scene/prefab asset.

Okey, so this is all for the first tutorial - feel free to change some values of components in game.json file and click RUN (or press ctrl+enter) to see what changed ;)
And like always: i'll be very thankful for your opinion about what i did good or bad :D

Next tutorial: Simple entity movement

Link to comment
Share on other sites

Hi! Today we will learn how to make simple entity movement for player and enemies, also we will use PrefabInstance engine component to instantiate snake prefab on scene.

Playground project: http://oxygen.experiments.psichix.io/p/rkP6JOK4M
Live experiment: http://oxygen.experiments.psichix.io/rkP6JOK4M

NOTE: Example uses Kenney's Animals asset pack.

Open game.json file from Files list (application menu) and you'll see full tree of nested entities that builds our game:

{
    "name": "root",
    "components": {
        "Camera2D": {
            "zoomOut": 2048,
            "zoomMode": "keep-aspect"
        }
    },
    "children": [
        {
            "name": "rabbit",
            "components": {
                "Sprite": {
                    "shader": "sprite-transparent.json",
                    "width": 284,
                    "height": 370,
                    "xOrigin": 0.5,
                    "yOrigin": 0.5,
                    "overrideBaseTexture": "rabbit.png"
                },
                "RabbitController": {
                    "listenTo": [ "key-down", "key-up" ],
                    "speed": 500
                }
            }
        },
        {
            "name": "snake",
            "components": {
                "PrefabInstance": {
                    "asset": "snake.json",
                    "count": 5,
                    "components": {
                        "Randomizer": {
                            "position": [2048, 2048]
                        },
                        "SnakeController": {
                            "bounds": [1024, 1024]
                        }
                    }
                }
            }
        },
        {
            "name": "ui-controls",
            "transform": {
                "scale": 3,
                "position": [0, -1024]
            },
            "components": {
                "TextRenderer": {
                    "shader": "text-outline-transparent.json",
                    "font": "verdana.fnt",
                    "halign": "center",
                    "valign": "top",
                    "color": [1, 1, 1, 1],
                    "colorOutline": [0, 0, 0, 1],
                    "text": "Use WSAD keys to move Rabbit around"
                }
            }
        }
    ]
}

In last tutorial we had very simple single level tree (root entity with all components attached to it) - now we have nested entities structure:

+ root
|- rabbit (player)
|- snake (snake prefab instantiator that will create 5 snake instances on scene)
|- ui-controls (text that tells how to move Rabbit)

Root now uses only Camera2D to render game scene (root entity and it's children);
Rabbit is our player and it uses Sprite to render rabbit image and RabbitController that will move rabbit with WSAD keys;
Snake uses PrefabInstantiator to instantiate snake prefab as 5 snake instances;
Ui Controls uses TextRenderer to display handful information on screen.

Let's take a look at RabbitController component:

import { Script } from 'oxygen-core';

export default class RabbitController extends Script {
    
    static get propsTypes() {
        return {
            speed: 'number' // units per second
        };
    }
    
    static factory() {
        return new RabbitController();
    }
    
    constructor() {
        super();
        
        this.speed = 100;
        // status of WSAD keys.
        this._left = false;
        this._right = false;
        this._up = false;
        this._down = false;
    }
    
    onKeyDown(code) {
        if (code === 87) { this._up = true; }
        if (code === 83) { this._down = true; }
        if (code === 65) { this._left = true; }
        if (code === 68) { this._right = true; }
    }
    
    onKeyUp(code) {
        if (code === 87) { this._up = false; }
        if (code === 83) { this._down = false; }
        if (code === 65) { this._left = false; }
        if (code === 68) { this._right = false; }
    }
    
    onUpdate(deltaTime) {
        // convert delta time to seconds.
        deltaTime *= 0.001;
        
        // get entity, speed and WSAD keys status.
        const { entity, speed, _up, _down, _left, _right } = this;
        // get entity position.
        const { position } = entity;
        // calculate delta movement based on WSAD keys status.
        let dx = 0;
        let dy = 0;
        if (_up) { dy -= speed; }
        if (_down) { dy += speed; }
        if (_left) { dx -= speed; }
        if (_right) { dx += speed; }
        
        // apply new position.
        entity.setPosition(
            position[0] + dx * deltaTime,
            position[1] + dy * deltaTime
        );
    }
    
}

In constructor we initialize non-pressed WSAD keys status, then onKeyDown changes WSAD key status if W, S, A or D key is pressed, and onKeyUp resets them. But be careful: those events are triggered ONLY if RabbitController component has set listenTo property to array of accepted events (this is property derived from Script component that our RabbitController extends and it have to contain a list of input events that our script want to listen to - making explicit listenTo setup has optimization reason, because Script can listen to many events, and most of the time we will want to receive only few (or none) events that our script needs). Then onUpdate process rabbit logic, so it checks which keys are hold and then move in corresponding directions.
NOTE: handling input with direct events methods is not recommended way to do it - you will be glad using InputHandler engine component, which will be covered in next tutorial, so keep in mind that direct event listeners are the low-level way to handle input, but in most if not all cases you'll use InputHandler component.

Next is SnakeController component used as snake AI that goes in random directions:

import { Script } from 'oxygen-core';

export default class SnakeController extends Script {
    
    static get propsTypes() {
        return {
            speed: 'number', // units per second
            bounds: 'array(number)', // [x, y]
            changeDirectionDelay: 'number' // delay in seconds.
        };
    }
    
    static factory() {
        return new SnakeController();
    }
    
    constructor() {
        super();
        
        this.speed = 100;
        this.bounds = [0, 0];
        this.changeDirectionDelay = 2;
        this._direction = 0;
        this._changeTimer = 0;
    }
    
    selectDirection() {
        // randomly pick one of 4 movement directions.
        this._direction = (Math.random() * 4) | 0;
    }
    
    onUpdate(deltaTime) {
        deltaTime *= 0.001;
        
        // change direction when timer goes to 0.
        if (this._changeTimer <= 0) {
            this.selectDirection();
            this._changeTimer = this.changeDirectionDelay;
        } else {
            this._changeTimer -= deltaTime;
        }

        const { entity, speed, bounds, _direction } = this;
        const { position } = entity;
        let dx = 0;
        let dy = 0;
        switch (_direction) {
            case 0: dx = -speed; break;
            case 1: dx = speed; break;
            case 2: dy = -speed; break;
            case 3: dy = speed; break;
        }
        let tx = position[0] + dx * deltaTime;
        let ty = position[1] + dy * deltaTime;
        // trim target position to boundaries.
        tx = Math.max(-bounds[0], Math.min(bounds[0], tx));
        ty = Math.max(-bounds[1], Math.min(bounds[1], ty));
        
        entity.setPosition(tx, ty);
    }
    
}

Snake AI is very stupid because it just picks some random direction at some time point so it's movement algorithm is very easy: if there is time to change direction, pick new randomly; depending on which direction is currently used, change delta position, then clamp target position to movement area boundaries and apply that target position to entity.

Now it's time for Randomizer - because we instantiate 5 snakes on scene, all of them will be placed at the same position on scene, so to place them at different positions, we use this component to randomize entity position when created on scene:

import { Script } from 'oxygen-core';

// This component makes entity initial position random.
export default class Randomizer extends Script {

  static get propsTypes() {
    return {
      position: 'array(number)' // [x, y]
    };
  }

  static factory() {
    return new Randomizer();
  }

  constructor() {
    super();

    this.position = [0, 0];
  }
  
  dispose() {
    super.dispose();
      
    this.position = null;
  }

  onAttach() {
    super.onAttach();

    // get entity and randomized position range.
    const { entity, position } = this;
    // check if position range is 2 item array.
    if (!!position && position.length >= 2) {
      // randomize initial position in range.
      entity.setPosition(
        (Math.random() - 0.5) * position[0],
        (Math.random() - 0.5) * position[1]
      );
    }
  }

}

So now when we covered all components, it's time to look at snake.json prefab:

{
    "name": "snake",
    "components": {
        "Sprite": {
            "shader": "sprite-transparent.json",
            "width": 284,
            "height": 321,
            "xOrigin": 0.5,
            "yOrigin": 0.5,
            "overrideBaseTexture": "snake.png"
        },
        "Randomizer": {},
        "SnakeController": {
            "speed": 500,
            "changeDirectionDelay": 1
        }
    }
}

As you may see, it looks exactly like any scene - that's correct! Prefabs and scenes are the same, and reason for that is that at some point of development you may want to make one scene with sub-scenes within (maybe loading time optimizations, or just want to put part of scene tree in different files to keep main scene small, readable and make editing easy).

We also had to add new assets to game config, so game will load rabbit and snake images, also snake prefab.

----------

This is everything for this tutorial, next one will be: Advanced yet easy to use input handling with InputHandler component

Link to comment
Share on other sites

Tutorial #3 - Advanced yet easy to use input handling with InputHandler component

Playground project: http://oxygen.experiments.psichix.io/p/r15hPq9EG
Live experiment: http://oxygen.experiments.psichix.io/r15hPq9EG

Based on "simple entity movement" tutorial, first we modify game.json to add InputHandler engine component to root entity:

{
    "name": "root",
    "components": {
        "Camera2D": {
            "zoomOut": 2048,
            "zoomMode": "keep-aspect"
        },
        "InputHandler": {}
    }
}

So now we can use it to respond to player input, which layout is defined in config.json asset:

{
  "assets": [
    "shader://sprite-transparent.json",
    "shader://text-outline-transparent.json",
    "image://rabbit.png",
    "image://snake.png",
    "font://verdana.fnt",
    "scene://game.json",
    "scene://snake.json"
  ],
  "input": {
      "axes": {
          "x": {
              "keys": [ "A", -1, "D", 1 ],
              "gamepadAxis": "primary-x"
          },
          "y": {
              "keys": [ "W", -1, "S", 1 ],
              "gamepadAxis": "primary-y"
          }
      }
  }
}

There is input property in config asset, which defines input mapping to input axes used by game. Those axes are "x" and "y" for left-right and up-down movement and each axis has mapping to keys for negative and positive values, and to gamepad axes of primary (left) stick.

And the last part to change is RabbitController.js, and here we remove all WSAD related key status properties and replace them with reference holding InputHandler instance from root entity. Then in onUpdate instead of checking all key statuses, we just ask input handler for x/y axes values which comes from mapped keys to axes by names. So everytime you press and release WSAD key, it converts to x/y axes values, so we can easly get their values as numbers. Yeah, that's a lot of less code! InputHandler, yay!

import { Script, System } from 'oxygen-core';

export default class RabbitController extends Script {
    
    static get propsTypes() {
        return {
            speed: 'number' // units per second
        };
    }
    
    static factory() {
        return new RabbitController();
    }
    
    constructor() {
        super();
        
        this.speed = 100;
        this._input = null;
    }
    
    dispose() {
        super.dispose();
        
        this._input = null;
    }
    
    onAttach() {
        // get config data from asset.
        const { AssetSystem } = System.systems;
        const config = AssetSystem.get('json://config.json').data;
        // find root entity and it's input handler, then apply input layout to it.
        const root = this.entity.findEntity('/');
        this._input = root.getComponent('InputHandler');
        this._input.setup(config.input);
    }
    
    onUpdate(deltaTime) {
        deltaTime *= 0.001;
        
        const { entity, speed, _input } = this;
        const { position } = entity;
        // calculate delta movement based on input handler axes defined in config.
        const dx = _input.getAxis('x') * speed;
        const dy = _input.getAxis('y') * speed;

        // apply new position.
        entity.setPosition(
            position[0] + dx * deltaTime,
            position[1] + dy * deltaTime
        );
    }
    
}

----------

Next tutorial: Simple UI and multiple scenes

Link to comment
Share on other sites

Join the conversation

You can post now and register later. If you have an account, sign in now to post with your account.
Note: Your post will require moderator approval before it will be visible.

Guest
Reply to this topic...

×   Pasted as rich text.   Paste as plain text instead

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   Your previous content has been restored.   Clear editor

×   You cannot paste images directly. Upload or insert images from URL.

Loading...
 Share

  • Recently Browsing   0 members

    • No registered users viewing this page.
×
×
  • Create New...