Skip to content
DasPeTe Tutorials

A First-Person Controller in Babylon.js · Part 7 of 11 in the series

Part 7: Ladders and a State Machine

Grow walk and air into a state machine, put ladders into the level as zones, and climb them: up, down, over the top and off again with a jump.

Quest objectives

  • Grow the two modes into a state machine with room for more
  • Describe ladders in the level as zones with a direction
  • Grab a ladder by walking at it, or by touching it in the air
  • Climb up and down, and over the top onto what the ladder leads to
  • Jump off a ladder without grabbing it again at once

So far the player is either walking or in the air. From this part on, they also hold on to things: ladders now, ledges and walls in the parts after this one. Each of those moves the player in its own way, without gravity, and with its own way to get on and off. They become modes like walk and air.

The ladder in the level

On the right of the course, this part adds a tower, 4 m high, with a ladder on the side that faces the start. The ladder is two parts: rails and rungs to look at, as ghost blocks without collision, and a zone, the box in front of the ladder where the player can grab it. The tower behind it is solid, so the player climbs against the wall.

The level gets a type for ladders and a list of them. In src/level.ts, import BoundingBox from @babylonjs/core/pure:

src/level.ts
/** A ladder: where it can be grabbed, and the way it faces, away from its wall. */
export interface Ladder {
  zone: BoundingBox;
  normal: Vector3;
}

export interface Level {
  /** Where the player's feet start. */
  spawn: Vector3;
  ladders: Ladder[];
}

Start buildLevel() with { spawn: …, ladders: [] }, add tower: '#c9c2f2' and ladder: '#ffc442' to the colors, and give the Builder a method for ladders:

src/level.ts
/** A ladder on a wall that faces -x, with its foot at (x, 0, z). */
  ladder({ x, z, height }: { x: number; z: number; height: number }): Ladder {
    for (const side of [-0.4, 0.4]) {
      this.block('ladder-rail', { size: [0.08, height, 0.08], at: [x - 0.1, height / 2, z + side], color: 'ladder', ghost: true });
    }
    for (let y = 0.3; y < height; y += 0.3) {
      this.block('ladder-rung', { size: [0.06, 0.06, 0.8], at: [x - 0.1, y, z], color: 'ladder', ghost: true });
    }
    // The zone reaches a meter above the top, so the feet clear the edge before it ends.
    return {
      zone: new BoundingBox(new Vector3(x - 0.7, 0, z - 0.5), new Vector3(x, height + 1, z + 0.5)),
      normal: new Vector3(-1, 0, 0),
    };
  }

The normal points away from the wall, towards the climber. The zone reaches 70 cm out from the wall and a meter above the top, so the feet clear the edge before the player leaves it. Then build the tower in buildLevel(), after the tunnel:

src/level.ts
// Right: a tower with a ladder on its side.
  build.block('tower', { size: [4, 4, 4], at: [11, 2, -2], color: 'tower' });
  level.ladders.push(build.ladder({ x: 9, z: -2, height: 4 }));

A state machine

The modes are already a small state machine: the player is in exactly one, each one runs its own code, and each decides when to switch. Add 'ladder' to the Mode type and import the Ladder type next to Level:

src/player.ts
export type Mode = 'walk' | 'air' | 'ladder';

The player keeps the ladder it holds and a cooldown, next to the timers. Add ladderSpeed: 3 to PLAYER in the settings too:

src/player.ts
private coyoteTimer = 0;
  private jumpTimer = 0;
  private grabCooldown = 0;
  private ladder: Ladder | null = null;

update() counts the cooldown down and runs the ladder mode. Crouching only happens on the ground and in the air now: hanging on a ladder, C has nothing to do.

src/player.ts
update(dt: number): void {
    if (this.controller.getPosition().y < -20) this.respawn();
    const support = this.controller.checkSupport(dt, DOWN);
    this.velocity.copyFrom(this.controller.getVelocity());

    this.coyoteTimer = Math.max(this.coyoteTimer - dt, 0);
    this.jumpTimer = Math.max(this.jumpTimer - dt, 0);
    this.grabCooldown = Math.max(this.grabCooldown - dt, 0);
    if (this.input.pressedNow('jump')) this.jumpTimer = PLAYER.jumpBuffer;
    this.running = !this.crouching && this.input.down('run') && this.input.axis('back', 'forward') > 0;

    if (this.mode === 'walk' || this.mode === 'air') this.updateStance();

    switch (this.mode) {
      case 'walk':
        this.walk(dt, support);
        break;
      case 'air':
        this.air(dt, support);
        break;
      case 'ladder':
        this.climbLadder(support);
        break;
    }

    this.controller.setVelocity(this.velocity);
    this.controller.integrate(dt, support, GRAVITY);

    const eye = this.crouching ? PLAYER.crouchEye : PLAYER.standEye;
    this.eyeHeight += (eye - this.eyeHeight) * (1 - Math.exp(-12 * dt));
  }

Getting on

Walking and falling look for a ladder. Replace walk() and air():

src/player.ts
private walk(dt: number, support: CharacterSurfaceInfo): void {
    const grounded = support.supportedState === CharacterSupportedState.SUPPORTED;
    const drop = grounded ? 0 : this.groundBelow();
    if (drop === null) {
      this.mode = 'air';
      this.coyoteTimer = PLAYER.coyoteTime;
      this.air(dt, support);
      return;
    }
    if (this.jumpTimer > 0) {
      this.jump();
      return;
    }
    if (this.tryLadder()) return;

    const target = this.wish(this.groundSpeed());
    if (grounded) alongGround(target, support.averageSurfaceNormal);
    approach(this.velocity, target, PLAYER.groundAcceleration * dt);
    // Just off the ground, such as over the top of a slope: move down onto it this frame.
    if (!grounded) this.velocity.y = -drop / dt;
  }

  private air(dt: number, support: CharacterSurfaceInfo): void {
    if (this.jumpTimer > 0 && this.coyoteTimer > 0) {
      this.jump();
      return;
    }
    if (this.velocity.y <= 0) this.rising = false;
    if (support.supportedState === CharacterSupportedState.SUPPORTED && !this.rising) {
      this.mode = 'walk';
      this.walk(dt, support);
      return;
    }
    if (this.tryLadder()) return;

    // Without input the player keeps their momentum. With it, they steer, but slowly.
    if (this.input.axis('back', 'forward') || this.input.axis('left', 'right')) {
      const target = this.wish(this.groundSpeed());
      target.y = this.velocity.y;
      approach(this.velocity, target, PLAYER.airAcceleration * dt);
    }
    this.velocity.y -= PLAYER.gravity * dt;
  }

And add the ladder section to the end of the class:

src/player.ts
// Ladders ---------------------------------------------------------------------------

  private tryLadder(): boolean {
    if (this.grabCooldown > 0) return false;
    const center = this.controller.getPosition();
    const ladder = this.level.ladders.find((candidate) => candidate.zone.intersectsPoint(center));
    if (!ladder) return false;
    // From the ground, only walking at the ladder grabs it. In the air, touching it does.
    if (this.mode === 'walk' && Vector3.Dot(this.wish(1), ladder.normal) > -0.5) return false;
    if (this.crouching) this.setCrouching(false);
    this.ladder = ladder;
    this.mode = 'ladder';
    this.jumpTimer = 0;
    return true;
  }

  private climbLadder(support: CharacterSurfaceInfo): void {
    const ladder = this.ladder!;
    if (!ladder.zone.intersectsPoint(this.controller.getPosition())) {
      this.mode = 'air';
      return;
    }
    if (this.jumpTimer > 0) {
      this.letGo(ladder.normal, 4, 3);
      return;
    }
    const climb = this.input.axis('back', 'forward');
    if (climb < 0 && support.supportedState === CharacterSupportedState.SUPPORTED) {
      this.mode = 'walk';
      return;
    }
    this.velocity.set(0, climb * PLAYER.ladderSpeed, 0);
    // Pressing towards the ladder carries the player over the top, where it ends.
    this.velocity.addInPlace(ladder.normal.scale(-1.5 * Math.max(climb, 0)));
    this.velocity.addInPlace(this.look.right().scaleInPlace(this.input.axis('left', 'right') * 1.5));
  }

  /**
   * Lets go of whatever the player holds on to, pushed `away` and lifted by the given
   * speeds, and keeps them from grabbing it again at once.
   */
  private letGo(away: Vector3, push: number, lift: number): void {
    this.velocity.copyFrom(away).scaleInPlace(push);
    this.velocity.y = lift;
    this.rising = lift > 0;
    this.mode = 'air';
    this.jumpTimer = 0;
    this.grabCooldown = 0.4;
  }

tryLadder() looks for a ladder whose zone holds the center of the capsule. On the ground, the player only grabs it when walking at it: the direction of the keys has to point within 60 degrees of straight into the ladder, a dot product below -0.5 with its normal. That way, walking past a ladder does not catch you. In the air, touching the zone is enough, so a player who walks off the top of the tower backwards catches the ladder on the way down.

Climbing

On the ladder there is no gravity. W climbs at 3 m/s, S climbs down, and at the bottom, S on the ground steps off. A and D move sideways, and out of the zone, which lets go.

The top takes no code of its own. Holding W also pushes the player towards the ladder at 1.5 m/s. As long as the tower is in the way, that push does nothing. Once the feet are above its top, it carries the player onto it, the center leaves the zone, and the player lands on the tower.

Getting off

A jump pushes the player away from the ladder and up, through letGo(), which the ledges and walls of the next parts use too. Two details matter:

  • The cooldown keeps the player from grabbing the ladder again in the next frame, while they are still in its zone.

  • rising keeps the first frame in the air from counting as a landing, as with a jump from the ground. Near the bottom of the ladder, the push would end on the floor otherwise.

Grabbing the ladder clears jumpTimer: a jump pressed just before, in the air, would let go again at once.

Try it

Walk to the ladder on the side of the tower and walk into it: the HUD switches to ladder. Climb to the top and over it. Walk backwards off the edge, and you catch the ladder again on the way down. Halfway down, press Space to jump off.

The project at the end of part 7: walk into the ladder on the tower and climb with W and S.
Open in a new tab

In the next part the player learns to climb onto anything with an edge that is in reach.

Comments

The comments are GitHub Discussions: sign in with GitHub to write one.

The comments come from GitHub, through giscus. Loading them sends your IP address to giscus.app and GitHub.