Player Input and Movement in Babylon.js

In the previous article, we dynamically loaded assets and even got our entities moving. Now, let's dive into the crucial aspect of capturing player input and translating it into meaningful actions within our game world. We'll build a flexible, data-driven input system using our component-based architecture.

Capturing Keyboard Input: The InputKeyboardSystem

Now, let's build a system to handle keyboard input:

import { Entity, EntityManager } from "../lib";
import { InputKeyboardComponent } from "../components/InputKeyboardComponent";
import { PlayerInputComponent } from "../components/PlayerInputComponent";
import { Vector3 } from "@babylonjs/core";

export class InputKeyboardSystem {
  inputComponent: InputKeyboardComponent | undefined;
  playerInputComponent: PlayerInputComponent | undefined;
  constructor(entityManager: EntityManager) {
    const playerEntity = entityManager.playerEntity as Entity;
    const playerInputComponent =
      playerEntity.getComponent(PlayerInputComponent);
    const keyboard = playerEntity.getComponent(InputKeyboardComponent);
    this.inputComponent = keyboard;
    this.playerInputComponent = playerInputComponent;
    document.addEventListener("keydown", this.handleKeyDown.bind(this));
    document.addEventListener("keyup", this.handleKeyUp.bind(this));
    window.addEventListener("blur", () => {
      for (const key in this.inputComponent.keys) {
        this.inputComponent.keys[key] = false;
      }
      this.updateMovementVector(this.inputComponent);
    });
    console.log("InputKeyboardSystem initialized");
  }

  private handleKeyDown(event: KeyboardEvent) {
    const inputComponent = this.inputComponent;
    const { keys, keyMappings } = inputComponent;
    const key = keyMappings[event.key.toLowerCase()];
    if (!key) return;
    inputComponent.keys[key] = true;
    this.updateMovementVector(inputComponent);
  }

  private handleKeyUp(event: KeyboardEvent) {
    const inputComponent = this.inputComponent;
    const { keyMappings } = inputComponent;
    const key = keyMappings[event.key.toLowerCase()];
    if (!key) return;
    inputComponent.keys[key] = false;
    this.updateMovementVector(inputComponent);
  }

  private updateMovementVector(inputComponent: InputKeyboardComponent) {
    this.playerInputComponent.movementVector = new Vector3(
      (inputComponent.keys.right ? 1 : 0) - (inputComponent.keys.left ? 1 : 0),
      0, // Assuming 2D movement for simplicity

      (inputComponent.keys.forward ? 1 : 0) -
        (inputComponent.keys.backward ? 1 : 0),
    );
    this.playerInputComponent.updateActions(inputComponent.keys);
  }
}

Explanation:

  • The InputKeyboardSystem listens for keydown and keyup events on the document.
  • It uses a keyMappings object (presumably defined in an InputKeyboardComponent) to map key presses to meaningful actions like "forward," "backward," "left," and "right."
  • Based on the pressed keys, it updates a movementVector in a PlayerInputComponent. This vector will be used by other systems, like our movement system, to move the player.

The PlayerInputComponent: A Hub for Input Data

// PlayerInputComponent.ts

import { Component } from "../lib";
import { Vector3 } from "@babylonjs/core";
import { InputKeyboardComponent } from "./InputKeyboardComponent";
import { InputGamePadComponent } from "./InputGamePadComponent";

export class PlayerInputComponent extends Component {
  public inputType = "keyboard";
  public actionToKeyMap: any;
  public currentActions: any;
  public movementVector = new Vector3(0, 0, 0);
  public inputSource: InputKeyboardComponent | InputGamePadComponent;

  constructor(data) {
    super(data);
    this.currentActions = data.currentActions;
    this.actionToKeyMap = data.actionToKeyMap;
  }

  updateActions(keys: any) {
    for (const action in this.currentActions) {
      const key = this.actionToKeyMap[action];
      const pressed = keys[key];
      this.currentActions[action] = pressed || false;
    }
  }
}

This component stores the player's current input state, including the movementVector and any actions triggered by key presses.

The PlayerInputSystem: Translating Input into Actions

import { Entity, EntityManager, System } from "../lib";
import { PlayerInputComponent } from "../components/PlayerInputComponent";
import { AnimationComponent } from "../components/AnimationComponent";

export class PlayerInputSystem extends System {
  constructor(entityManager: EntityManager) {
    super(entityManager, [PlayerInputComponent, AnimationComponent]);
    console.log("PlayerInputSystem initialized");
  }

  processEntity(entity: Entity, deltaTime: number): void {
    const playerInputComponent = entity.getComponent(PlayerInputComponent);
    const animationComponent = entity.getComponent(AnimationComponent);
    const movement = playerInputComponent.movementVector;
    const still = !movement.length();
    let t = "idle";
    const { attack, shield, jump, Roundhouse_Low, roll } =
      playerInputComponent.currentActions;
    // console.log(playerInputComponent.currentActions.Roundhouse_Low);
    if (still) {
      if (attack) {
        t == "attack3";
      } else if (shield) {
        t = "idle_shield";
      } else if (jump) {
        t = "jump";
      } else if (Roundhouse_Low) {
        t = "Roundhouse_Low";
      } else {
        t = "idle";
      }
    } else {
      // moving
      if (shield) {
        t = "run_shield";
      } else if (roll && movement.x && !movement.z) {
        t = "straf";
      } else if (roll) {
        t = "roll";
      } else {
        t = "run";
      }
    }
    if (animationComponent.targetAnimation != t)
      animationComponent.targetAnimation = t;
  }
}

This system processes entities with PlayerInputComponent and AnimationComponent components. It reads the player's input state and triggers corresponding animations.

Defining Input in our scene.json

Finally, we'd define our input mappings and actions in our scene.json:

{
  "entities": {
    // ... other entities

    "Player": {
      "components": {
        // ... other components

        "PlayerInput": {
          "type": "keyboard",
          "currentActions": {
            "attack": false,
            "shield": false,
            "jump": false,
            "Roundhouse_Low": false,
            "roll": false
          },
          "actionToKeyMap": {
            "Roundhouse_Low": "control"
          }
        },
        // ... other components
      }
    }
  }
}

In Sum

We've built a responsive and data-driven player input system! By separating input handling, action mapping, and game logic into distinct components and systems, we've created a flexible and maintainable architecture. Now, our game can react to player input, triggering actions and animations that breathe life into our game world. Keep experimenting, keep iterating, and keep those creative juices flowing!