Zum Inhalt springen
DasPeTe Tutorials

Ein First-Person-Controller in Babylon.js · Teil 3 von 11 der Serie

Teil 3: Gehen mit einem Character-Controller

Gib der Kamera einen Körper: eine Kapsel auf dem Havok-Character-Controller von Babylon.js, die dorthin geht, wohin du schaust, weich beschleunigt und bremst, fällt und landet.

Fortgeschritten25 Min.

Quest-Ziele

  • Wissen, was ein Character-Controller macht und warum die Spielfigur kein starrer Körper ist
  • Den PhysicsCharacterController von Babylon.js mit einer Kapsel anlegen
  • Aus Tasten eine Geschwindigkeit relativ zum Blick machen
  • Mit fester Rate beschleunigen und bremsen, bei jeder Bildrate
  • Mit Schwerkraft fallen und wieder landen
  • In einem kleinen HUD zeigen, was die Spielfigur macht

Die Kamera aus Teil 2 dreht sich, aber sie steht still. Dieser Teil gibt ihr einen Körper, der geht, gegen Wände stößt, von Kanten fällt und landet.

Ein Character-Controller

Die naheliegende Idee ist eine Kapsel, die ein Physikkörper ist wie jeder andere: mit Kräften anschieben und die Engine den Rest machen lassen. Das fühlt sich nicht gut an. Ein starrer Körper kippt um, rutscht auf Schrägen, prallt bei der Landung ab und rutscht weiter, wenn du die Tasten loslässt, und das alles zu bändigen ist mehr Arbeit, als es anders zu machen.

Ein Character-Controller dreht es um. Dein Code sagt, wie schnell sich die Spielfigur bewegen soll, und der Controller bewegt die Kapsel so weit in diese Richtung, wie es geht: Er gleitet an Wänden entlang, die er trifft, und bleibt auf dem Boden stehen, aber nichts schubst ihn herum. Außerdem sagt er dir, was unter den Füßen ist. Babylon.js hat einen für Havok, den PhysicsCharacterController. Jeder Frame hat drei Schritte:

  1. checkSupport() sucht unter der Kapsel nach Boden.

  2. Dein Code rechnet aus Tasten und Boden die gewünschte Geschwindigkeit aus und setzt sie.

  3. integrate() bewegt die Kapsel mit dieser Geschwindigkeit, mit Kollisionen, und hinterlässt die Geschwindigkeit, die tatsächlich möglich war.

Tasten

Die Eingabeklasse lernt Tasten kennen. Sie benennt Aktionen statt Tasten, damit der restliche Code nach forward fragt und nicht nach W. Ersetze src/input.ts:

src/input.ts
/**
 * Which keys do what. `event.code` names the physical key, so WASD stays where it is on
 * any keyboard layout.
 */
const BINDINGS = {
  forward: ['KeyW', 'ArrowUp'],
  back: ['KeyS', 'ArrowDown'],
  left: ['KeyA', 'ArrowLeft'],
  right: ['KeyD', 'ArrowRight'],
};

export type Action = keyof typeof BINDINGS;

const BOUND = new Set(Object.values(BINDINGS).flat());

export class Input {
  /** True while the mouse is captured. Keys and mouse only count then. */
  locked = false;
  onLockChange: (locked: boolean) => void = () => {};

  private readonly canvas: HTMLCanvasElement;
  private readonly held = new Set<string>();
  private mouseX = 0;
  private mouseY = 0;

  constructor(canvas: HTMLCanvasElement) {
    this.canvas = canvas;

    window.addEventListener('keydown', (event) => {
      if (!this.locked || !BOUND.has(event.code)) return;
      event.preventDefault();
      this.held.add(event.code);
    });
    window.addEventListener('keyup', (event) => this.held.delete(event.code));
    // A key let go while another window has the focus never sends its keyup.
    window.addEventListener('blur', () => this.held.clear());

    document.addEventListener('pointerlockchange', () => {
      this.locked = document.pointerLockElement === canvas;
      if (!this.locked) this.held.clear();
      this.onLockChange(this.locked);
    });
    document.addEventListener('mousemove', (event) => {
      if (!this.locked) return;
      this.mouseX += event.movementX;
      this.mouseY += event.movementY;
    });
  }

  /** Captures the mouse. Browsers only allow it in answer to a click or a key press. */
  async lock(): Promise<void> {
    try {
      // Raw mouse movement, without the acceleration of the operating system.
      await this.canvas.requestPointerLock({ unadjustedMovement: true });
    } catch {
      // Not every platform has raw movement (Linux does not), so ask again without it.
      await this.canvas.requestPointerLock().catch(() => {});
    }
  }

  /** Whether one of the action's keys is held down. */
  down(action: Action): boolean {
    return BINDINGS[action].some((code) => this.held.has(code));
  }

  /** -1, 0 or 1, from a pair of opposite actions. */
  axis(negative: Action, positive: Action): number {
    return Number(this.down(positive)) - Number(this.down(negative));
  }

  /** The mouse movement since the last call, in pixels. */
  takeMouse(): { x: number; y: number } {
    const movement = { x: this.mouseX, y: this.mouseY };
    this.mouseX = 0;
    this.mouseY = 0;
    return movement;
  }
}
  • event.code benennt die physische Taste, egal welches Tastaturlayout. Auf einer französischen Tastatur liegen an der Stelle von WASD die Tasten ZQSD, und KeyW ist trotzdem die oben links.

  • Nur die eigenen Tasten des Spiels hält preventDefault() vom Browser fern, und nur, solange die Maus eingefangen ist. Die Pfeiltasten würden sonst die Seite scrollen.

  • Eine Taste, die losgelassen wird, während ein anderes Fenster den Fokus hat, schickt nie ihr keyup. Ohne den blur-Handler liefe die Spielfigur nach einem Alt+Tab weiter.

  • axis() macht aus zwei gegensätzlichen Aktionen -1, 0 oder 1.

Der Blick lernt, in welche Richtung die Spielfigur schaut. Ergänze diese zwei Methoden in der Klasse Look in src/look.ts und importiere oben Vector3 aus @babylonjs/core/pure:

src/look.ts
/** The way the player faces, flat on the ground. */
  forward(result = new Vector3()): Vector3 {
    return result.set(Math.sin(this.yaw), 0, Math.cos(this.yaw));
  }

  /** Ninety degrees to the right of forward. */
  right(result = new Vector3()): Vector3 {
    return result.set(Math.cos(this.yaw), 0, -Math.sin(this.yaw));
  }

Beide liegen flach auf dem Boden: Wer beim Vorwärtsgehen nach oben schaut, fliegt nicht los.

Die Zahlen

Die Zahlen der Spielfigur kommen in src/settings.ts, über die der Kamera:

src/settings.ts
/** Every number that decides how the player feels. Meters, seconds and radians. */
export const PLAYER = {
  radius: 0.35,
  standHeight: 1.8,
  /** Eye height above the feet. */
  standEye: 1.65,

  walkSpeed: 4,
  /** How fast the velocity may change on the ground, in m/s². */
  groundAcceleration: 40,

  gravity: 20,
};

Die Kapsel ist 1,8 m hoch und 70 cm breit, und die Augen sitzen 15 cm unter ihrer Oberkante. Sie geht mit 4 m/s, für ein Spiel ein zügiger Schritt. Die Schwerkraft ist 20 m/s², etwa das Doppelte des echten Werts: Mit echter Schwerkraft fühlt sich ein Spiel schwebend an, deshalb nehmen die meisten Spiele mehr. Das Beispiel von Babylon.js für seinen Character-Controller nimmt 18.

Die Spielfigur

Lege src/player.ts an:

src/player.ts
import {
  CharacterSupportedState,
  PhysicsCharacterController,
  Vector3,
  type CharacterSurfaceInfo,
  type Scene,
} from '@babylonjs/core/pure';
import type { Input } from './input.ts';
import type { Level } from './level.ts';
import type { Look } from './look.ts';
import { PLAYER } from './settings.ts';

export type Mode = 'walk' | 'air';

const DOWN = Vector3.Down();
const GRAVITY = new Vector3(0, -PLAYER.gravity, 0);

export class Player {
  mode: Mode = 'air';

  private readonly controller: PhysicsCharacterController;
  private readonly level: Level;
  private readonly input: Input;
  private readonly look: Look;
  private readonly velocity = new Vector3();

  constructor(scene: Scene, level: Level, input: Input, look: Look) {
    this.level = level;
    this.input = input;
    this.look = look;
    this.controller = new PhysicsCharacterController(
      level.spawn.add(new Vector3(0, PLAYER.standHeight / 2, 0)),
      { capsuleHeight: PLAYER.standHeight, capsuleRadius: PLAYER.radius },
      scene,
    );
  }

  /** Back to the start, for when the player falls off the world. */
  respawn(): void {
    this.controller.setPosition(this.level.spawn.add(new Vector3(0, this.controller.footOffset, 0)));
    this.controller.setVelocity(Vector3.Zero());
    this.mode = 'air';
  }

  /** The point between the eyes, in world space. */
  eyePosition(result = new Vector3()): Vector3 {
    return result.copyFrom(this.controller.getPosition()).addInPlaceFromFloats(0, PLAYER.standEye - this.controller.footOffset, 0);
  }

  /** How fast the player moves across the ground, in m/s. */
  horizontalSpeed(): number {
    const velocity = this.controller.getVelocity();
    return Math.hypot(velocity.x, velocity.z);
  }

  /** Where the feet are. */
  feet(): Vector3 {
    return this.controller.getPosition().subtract(new Vector3(0, this.controller.footOffset, 0));
  }

  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());

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

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

  private walk(dt: number, support: CharacterSurfaceInfo): void {
    if (support.supportedState !== CharacterSupportedState.SUPPORTED) {
      this.mode = 'air';
      this.air(dt, support);
      return;
    }
    const target = this.wish(PLAYER.walkSpeed);
    approach(this.velocity, target, PLAYER.groundAcceleration * dt);
  }

  private air(dt: number, support: CharacterSurfaceInfo): void {
    if (support.supportedState === CharacterSupportedState.SUPPORTED) {
      this.mode = 'walk';
      this.walk(dt, support);
      return;
    }
    this.velocity.y -= PLAYER.gravity * dt;
  }

  /** The velocity the keys ask for, flat on the ground, at the given speed. */
  private wish(speed: number): Vector3 {
    const direction = this.look.forward().scaleInPlace(this.input.axis('back', 'forward'));
    direction.addInPlace(this.look.right().scaleInPlace(this.input.axis('left', 'right')));
    // Forward and sideways together would be faster than either alone.
    const length = direction.length();
    if (length > 1) direction.scaleInPlace(1 / length);
    return direction.scaleInPlace(speed);
  }
}

/** Moves `current` towards `target` by at most `maxDelta`. */
function approach(current: Vector3, target: Vector3, maxDelta: number): void {
  const distance = Vector3.Distance(current, target);
  if (distance <= maxDelta) current.copyFrom(target);
  else current.addInPlace(target.subtract(current).scaleInPlace(maxDelta / distance));
}

Die Position des Controllers ist die Mitte der Kapsel, deshalb ist die Startposition der Startpunkt plus die halbe Höhe, und footOffset, die halbe Höhe, führt von der Mitte zu den Füßen.

Die Spielfigur ist immer in genau einem Modus, fürs Erste walk oder air, und das switch in update() führt den Code des aktuellen aus. Merkt ein Modus, dass er nicht mehr passt, wechselt er und gibt den Frame weiter, so beginnt der Fall von einer Kante im selben Frame. Spätere Teile ergänzen Modi für Leitern, Kanten und Wände.

update() beginnt mit getVelocity(), der Geschwindigkeit, die dem Controller nach dem letzten Frame geblieben ist. Ist die Spielfigur gegen eine Wand gelaufen, ist das die Geschwindigkeit an ihr entlang, nicht in sie hinein, und so staut sich an Wänden kein Tempo auf.

Gehen

wish() macht aus den Tasten die gewünschte Geschwindigkeit: vorwärts und rechts, so wie die Spielfigur schaut, mal die Achsen, mal das Tempo. Vorwärts und rechts zugleich ergeben einen Vektor der Länge 1,41, deshalb wird er auf 1 gekürzt. Sonst wäre man schräg 41 Prozent schneller, der Strafe-Running-Trick früher Shooter.

Die Geschwindigkeit springt nicht auf diesen Wert. approach() bewegt sie pro Frame um höchstens groundAcceleration × dt auf das Ziel zu: 40 m/s² bringen sie in einer Zehntelsekunde vom Stand auf 4 m/s und wieder zurück. Weil der Schritt an dt hängt, dauert das bei 30, 60 oder 144 Bildern pro Sekunde gleich lang.

Fallen

checkSupport() meldet einen von drei Zuständen: SUPPORTED auf begehbarem Boden, SLIDING auf Boden, der zu steil zum Stehen ist, und UNSUPPORTED in der Luft. Ohne Halt fällt die Spielfigur: Die Schwerkraft erhöht in jedem Frame die Fallgeschwindigkeit. Kommt der Halt zurück, landet sie und geht weiter.

Ein HUD

Bewegungscode lässt sich mit bloßem Auge schwer beurteilen. Ein paar Zeilen in der Ecke mit Modus, Tempo und Position der Füße helfen sehr. Lege src/hud.ts an:

src/hud.ts
import type { Player } from './player.ts';

/** A few lines of text in the corner that say what the player is doing. */
export class Hud {
  private readonly element: HTMLElement;
  private text = '';

  constructor(element: HTMLElement) {
    this.element = element;
  }

  update(player: Player): void {
    const feet = player.feet();
    const text = [
      player.mode,
      `${player.horizontalSpeed().toFixed(1)} m/s`,
      `x ${feet.x.toFixed(1)}  y ${feet.y.toFixed(2)}  z ${feet.z.toFixed(1)}`,
    ].join('\n');
    // Only touch the page when something changed.
    if (text !== this.text) this.element.textContent = this.text = text;
  }
}

Es ändert die Seite nur, wenn sich der Text geändert hat: Die Seite 60-mal pro Sekunde umsonst anzufassen kostet in jedem Frame Zeit.

Das HUD kommt in index.html hinter das Fadenkreuz, und das Overlay bekommt seinen neuen Hinweis:

index.html
<canvas id="game"></canvas>
    <div id="crosshair"></div>
    <div id="hud"></div>
    <div id="overlay">
      <strong>Click to play</strong>
      <span>WASD walk · mouse look · Esc pause</span>
    </div>
    <script type="module" src="/src/main.ts"></script>

Gestaltet wird es in src/style.css, vor #overlay:

src/style.css
#hud {
  position: fixed;
  left: 12px;
  top: 12px;
  font: 13px/1.5 ui-monospace, monospace;
  white-space: pre;
  text-shadow: 0 1px 2px #000;
  pointer-events: none;
}

Die Kamera folgt

Die Kamera nimmt ihre Position jetzt von den Augen der Spielfigur. Ersetze src/camera.ts:

src/camera.ts
import { FreeCamera, Vector3, type Scene } from '@babylonjs/core/pure';
import type { Look } from './look.ts';
import type { Player } from './player.ts';
import { CAMERA } from './settings.ts';

/** The player's eyes: the camera, put where the player is and turned where they look. */
export class PlayerCamera {
  readonly camera: FreeCamera;

  constructor(scene: Scene) {
    this.camera = new FreeCamera('eyes', Vector3.Zero(), scene);
    // A near plane close to the eye lets the player hug a wall without seeing through it.
    this.camera.minZ = 0.05;
    this.camera.fov = CAMERA.fov;
  }

  update(player: Player, look: Look): void {
    player.eyePosition(this.camera.position);
    this.camera.rotation.set(look.pitch, look.yaw, 0);
  }
}

Importiere in src/main.ts Player und Hud und ersetze alles von await enablePhysics(scene) bis zur Render-Schleife. Die feste Kameraposition fällt weg:

src/main.ts
await enablePhysics(scene);
const level = buildLevel(scene);

const input = new Input(canvas);
const look = new Look();
const player = new Player(scene, level, input, look);
const view = new PlayerCamera(scene);
const hud = new Hud(document.querySelector<HTMLElement>('#hud')!);

overlay.addEventListener('click', () => input.lock());
input.onLockChange = (locked) => overlay.classList.toggle('hidden', locked);

scene.onBeforeRenderObservable.add(() => {
  // After a stall, such as a tab in the background, one huge step would tunnel through walls.
  const dt = Math.min(engine.getDeltaTime() / 1000, 0.05);
  if (dt <= 0) return;
  look.update(input);
  player.update(dt);
  view.update(player, look);
  hud.update(player);
});

engine.runRenderLoop(() => scene.render());

Die Framezeit dt ist auf 50 Millisekunden begrenzt. Browser pausieren requestAnimationFrame in Hintergrund-Tabs, und der erste Frame danach würde die Spielfigur sonst um Sekunden auf einmal bewegen, glatt durch eine Wand. Den gleichen Trick gab es schon bei Pong.

Die Reihenfolge im Frame zählt: zuerst der Blick, damit die Spielfigur dorthin geht, wohin die Maus jetzt zeigt, dann die Spielfigur, dann die Kamera an ihrer neuen Position.

Ausprobieren

Geh mit W, A, S und D oder den Pfeiltasten umher. Das HUD zeigt 4,0 m/s, auch schräg, und wenn du loslässt, bleibt die Spielfigur nach 20 cm stehen. Wände halten dich auf, und du gleitest an ihnen entlang. Geh oben auf der Treppe von der Plattform: Das HUD springt auf air, und du landest 2 m tiefer.

Dann probiere die Treppe. Du kommst hoch, aber in kleinen Hüpfern, und das HUD flackert zwischen walk und air, hinauf wie hinunter. Die 70-Grad-Rampe kommst du gar nicht hoch. Darum geht es im nächsten Teil.

Das Projekt am Ende von Teil 3: Geh mit WASD, das HUD in der Ecke zeigt, was die Spielfigur macht.
In neuem Tab öffnen

Im nächsten Teil lernt die Spielfigur, auf Schrägen und Treppen zu gehen, ohne zu hüpfen.

Kommentare

Die Kommentare sind GitHub Discussions: Zum Schreiben mit GitHub anmelden.

Die Kommentare kommen über giscus von GitHub. Beim Laden gehen deine IP-Adresse an giscus.app und GitHub.