Move your Player with a Data-Driven Movement System in Babylon.js

In our previous adventures, we dynamically loaded game assets using components and systems. Now, it's time to inject some action into our game world by building a robust movement system. Get ready to watch your player characters gracefully glide, dash, and leap across the screen!

The MovementComponent: Defining Motion

First, we need a way to store movement-related properties for our entities. Enter the MovementComponent:

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

export interface MovementInput {
  velocity: number[];
  position: number[];
  walkingSpeed: number;
  rotationSpeed: number;
  jumpingSpeed: number;
}

// MovementComponent.ts
export class MovementComponent extends Component {
  velocity: Vector3;
  position: Vector3;
  acceleration: Vector3;
  walkingSpeed: number;
  rotationSpeed: number;
  jumpingSpeed: number;
  movementOverride: Vector3;
  overrideTimer = 0;

  constructor(data: MovementInput) {
    const { position, walkingSpeed, rotationSpeed, jumpingSpeed } = data;
    super(data);
    this.velocity = Vector3.Zero();
    this.position = Vector3.FromArray(position);
    this.acceleration = Vector3.Zero();
    this.walkingSpeed = walkingSpeed;
    this.rotationSpeed = rotationSpeed;
    this.jumpingSpeed = jumpingSpeed;
  }

  // Optional: Methods to manipulate position and velocity directly
  setPosition(x: number, y: number, z: number) {
    this.position = new Vector3(x, y, z);
  }

  setVelocity(vx: number, vy: number, vz: number) {
    this.velocity = new Vector3(vx, vy, vz);
  }

  // Optional: Method to apply a force to the acceleration
  applyForce(force: Vector3) {
    this.velocity.addInPlace(force);
  }
}

Key Properties:

  • velocity: The entity's current velocity (speed and direction).
  • position: The entity's current position in 3D space.
  • acceleration: The rate of change of velocity.
  • walkingSpeed, rotationSpeed, jumpingSpeed: Parameters controlling how fast the entity moves.

The MovementSystem: Bringing Motion to Life

Now, let's create the MovementSystem. This system will process only entities that have all three, AssetComponent, MovementComponent and PlayerInputComponent. These can be thought of as the system's data dependencies. It never sees entities that don't have all three components.

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

export class MovementSystem extends System {
  constructor(
    entityManager: EntityManager,
    componentClasses = [
      PlayerInputComponent,
      MovementComponent,
      AssetComponent,
    ],
  ) {
    super(entityManager, componentClasses);
    this.componentClasses = componentClasses;
    console.log("MovementSystem initialized");
  }

  updateMovement(
    assetComponent,
    movementComponent: MovementComponent,
    deltaTime: number,
  ) {
    // 1. Calculate the desired velocity based on input and walkingSpeed
    const { walkingSpeed, velocity, position } = movementComponent;
    const desiredVelocity = velocity.scale(walkingSpeed);
    if (
      assetComponent.mainMesh &&
      movementComponent.position != assetComponent.mainMesh.position &&
      velocity.length() == 0
    )
      assetComponent.mainMesh.position = movementComponent.position;

    // 2. Calculate the acceleration needed to reach desired velocity
    const acceleration = desiredVelocity
      .subtract(velocity)
      .scale(1 / deltaTime);

    // 3. Limit acceleration to a maximum value if needed
    const maxAcceleration = walkingSpeed; // Define your maximum acceleration
    if (acceleration.length() > maxAcceleration) {
      acceleration.normalize().scaleInPlace(maxAcceleration);
    }

    // 4. Update velocity with acceleration and deltaTime
    movementComponent.velocity = velocity.addInPlace(
      acceleration.scale(deltaTime),
    );
    if (isNaN(movementComponent.velocity.x)) {
      // debugger;
    }

    // 5. Update position based on velocity and deltaTime
    // movementComponent.position = position.addInPlace(
    //   movementComponent.velocity.scale(deltaTime),
    // );
    // console.log(movementComponent.velocity);
    const a = assetComponent.mainMesh;
    a.position.y = 0;
    a.moveWithCollisions(movementComponent.velocity);
    movementComponent.position = a.position;
  }

  protected processEntity(entity: Entity, deltaTime: number): void {
    const movementComponent = entity.getComponent(MovementComponent);
    const inputComponent = entity.getComponent(PlayerInputComponent);
    const assetComponent = entity.getComponent(AssetComponent);
    if (assetComponent && assetComponent.mainMesh) {
      movementComponent.velocity = inputComponent.movementVector;
      this.updateMovement(assetComponent, movementComponent, deltaTime);
    }
  }
}

Explanation:

  1. processEntity(entity, deltaTime): This method, called by the EntityManager, processes each entity that has both a MovementComponent and a PlayerInputComponent.
  2. updateMovement(...): This function encapsulates the movement logic:
    • Calculates the desired velocity based on player input and the entity's speed.
    • Calculates the acceleration needed to reach that velocity.
    • Limits acceleration if necessary to prevent unrealistic movement.
    • Updates the entity's velocity and position based on acceleration and time elapsed (deltaTime).

Defining Movement in Our scene.json

Let's assume our scene.json now includes movement data for the player:

{
  "entities": {
    // ... (World entity)

    "Player": {
      "components": {
        // ... (Asset component)

        "Movement": {
          "position": [0, 0, 0],
          "walkingSpeed": 1,
          "runningSpeed": 800,
          "jumpingSpeed": 5,
          "rotationSpeed": 50
        },
        // ... (InputKeyboard component)
      }
    }
  }
}

Bringing It All Together: Movement in Harmony

When our game runs:

  1. The SceneManager loads the scene.json and instantiates the MovementSystem.
  2. The EntityManager calls the MovementSystem's processEntity method for each entity with the required components.
  3. The MovementSystem reads input data, calculates movement, and updates the positions of our entities, bringing them to life with smooth, data-driven motion.

In Sum

We've successfully built a flexible movement system driven by data! By defining movement properties in our scene.json and using components and systems, we've decoupled our game logic, making it easier to modify and extend. Experiment, iterate, and watch your game world come alive with dynamic, data-driven motion!