Zum Inhalt springen
DasPeTe Tutorials

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

Teil 4: Schrägen, Treppen und Stufen

Geh an Schrägen entlang, statt von ihnen abzuheben, lege fest, was zu steil ist, geh Treppen auf einer unsichtbaren Rampe hoch, wie es die meisten Spiele machen, und bleib über einer Kuppe am Boden.

Fortgeschritten25 Min.

Quest-Ziele

  • Die Geschwindigkeit entlang des Bodens drehen, bergauf und bergab
  • Mit maxSlopeCosine festlegen, welche Schrägen zu steil sind
  • Wissen, wie hohe Kanten die Kapsel von selbst nimmt
  • Treppen auf einer unsichtbaren Rampe hochgehen
  • Am Boden bleiben, wenn er unter den Füßen wegfällt
  • Mit Kollisionsebenen verhindern, dass Strahlen die Spielfigur treffen

Am Ende von Teil 3 hat das HUD gezeigt, was nicht stimmt: Auf der Treppe hüpft die Spielfigur von Stufe zu Stufe, und auf dem Weg nach unten fliegt sie nach jeder ein Stück. Beides hat dieselbe Ursache. Die gewünschte Geschwindigkeit ist flach, und der Boden ist es nicht.

Am Boden entlang

Wer mit flacher Geschwindigkeit bergab geht, entfernt sich von der Schräge, und die Schwerkraft braucht einen Moment, um einen wieder auf sie zu ziehen: abheben, landen, wieder abheben. Bergauf muss der Controller die flache Geschwindigkeit selbst in die Schräge drehen.

Die Lösung ist, die Geschwindigkeit vor dem Benutzen in die Ebene des Bodens zu drehen. checkSupport() meldet diese Ebene als averageSurfaceNormal, die Richtung, in die der Boden zeigt. Zieht man den Teil der Geschwindigkeit ab, der entlang der Normale zeigt, bleibt der Teil entlang des Bodens, und auf die alte Länge skaliert behält er das Tempo. Die Hilfsfunktionen kommen ans Ende von src/player.ts:

src/player.ts
function capsule(height: number, scene: Scene): PhysicsShapeCapsule {
  const half = height / 2 - PLAYER.radius;
  const shape = new PhysicsShapeCapsule(new Vector3(0, half, 0), new Vector3(0, -half, 0), PLAYER.radius, scene);
  // A member of the player layer that only collides with the world, so queries with this
  // shape do not find the player's own capsule.
  shape.filterMembershipMask = LAYER.player;
  shape.filterCollideMask = SOLID;
  return shape;
}

/** Turns a flat velocity into one along the ground, at the same speed. */
function alongGround(velocity: Vector3, normal: Vector3): void {
  const speed = velocity.length();
  if (speed === 0) return;
  velocity.subtractInPlace(normal.scale(Vector3.Dot(velocity, normal)));
  velocity.normalize().scaleInPlace(speed);
}

capsule() kommt weiter unten ins Spiel. walk() wendet alongGround() auf die Zielgeschwindigkeit an, so geht die Spielfigur mit Gehtempo an Schrägen entlang, bergauf wie bergab:

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.air(dt, support);
      return;
    }

    const target = this.wish(PLAYER.walkSpeed);
    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;
  }

  /**
   * How far below the feet walkable ground is, if it is close: then the player is still
   * walking. Over the top of a slope the ground falls away faster than gravity pulls.
   */
  private groundBelow(): number | null {
    const center = this.controller.getPosition();
    const feet = center.y - this.controller.footOffset;
    const hit = raycast(center, new Vector3(center.x, feet - PLAYER.snapDistance, center.z));
    if (!hit || hit.hitNormalWorld.y < this.controller.maxSlopeCosine) return null;
    return feet - hit.hitPointWorld.y;
  }

Zu steil

Welcher Boden als begehbar gilt, entscheidet der Controller mit maxSlopeCosine: dem Kosinus des steilsten Winkels. Auf steilerem Boden meldet checkSupport() SLIDING, die Spielfigur ist im Luftmodus, und die Schwerkraft zieht sie die Schräge hinunter. Voreingestellt sind 60 Grad. Das Projekt setzt den Wert aus den Einstellungen, damit er bei den anderen Zahlen steht. Ergänze diese zwei in PLAYER in src/settings.ts:

src/settings.ts
/**
   * The steepest ground to walk on. It also decides how high an edge the rounded bottom of
   * the capsule rolls over: about radius * (1 - cos(angle)) above its bottom, which floats a
   * few centimeters over the ground. The 20 cm steps in the level work.
   */
  maxSlopeDegrees: 60,
  /** Ground this close under the feet still counts as walking. */
  snapDistance: 0.3,

Stufen und Kanten

Die Unterseite der Kapsel ist rund. Berührt sie eine Kante, zeigt der Kontakt nach oben und vorne wie eine Schräge, und ist diese Schräge begehbar, rollt die Kapsel über die Kante. Das klappt bis etwa Radius × (1 − cos(Winkel)) über der Unterkante der Kapsel: 0,35 × (1 − cos 60°) = 17,5 cm. Der Controller hält die Kapsel ein paar Zentimeter über dem Boden, deshalb ist der 15 cm hohe Bordstein in der Praxis kein Hindernis, und die 20-cm-Stufen gehen auch, mit den Hüpfern aus Teil 3.

Treppen sind Rampen

Zwanzig Kanten hintereinander zu überrollen hat die Treppe holprig gemacht. Die meisten Spiele lösen das mit einem Trick: Die Stufen sind nur zum Ansehen da, und die Spielfigur geht auf einer unsichtbaren Rampe über ihren Vorderkanten hinauf. Das fühlt sich an, wie sich eine Treppe anfühlen soll, und die Kamera gleitet, statt zu wackeln.

Das Level braucht dafür zwei neue Arten von Kisten: Geisterkisten (ghost), die gezeichnet werden, aber keinen Physikkörper haben, und versteckte (hidden), die einen Körper haben, aber nicht gezeichnet werden. Sie kommen in die Optionen in src/level.ts:

src/level.ts
interface BlockOptions {
  /** Width (x), height (y) and depth (z) in meters. */
  size: [number, number, number];
  /** Where the center of the block goes. */
  at: [number, number, number];
  color: ColorName;
  /** Rotation around the x axis, in degrees. */
  tilt?: number;
  /** Decoration only: no collision. */
  ghost?: boolean;
  /** Collision only: not drawn. */
  hidden?: boolean;
}

Ersetze block() im Builder. Die Zeile mit filterMembershipMask gehört zu den Ebenen weiter unten und braucht import { LAYER } from './physics.ts'; oben in der Datei:

src/level.ts
block(name: string, options: BlockOptions): Mesh {
    const [width, height, depth] = options.size;
    // One grid square per meter on every face, whatever the size of the block.
    const faceUV = [
      new Vector4(0, 0, width, height),
      new Vector4(0, 0, width, height),
      new Vector4(0, 0, depth, height),
      new Vector4(0, 0, depth, height),
      new Vector4(0, 0, width, depth),
      new Vector4(0, 0, width, depth),
    ];
    const mesh = CreateBox(name, { width, height, depth, faceUV }, this.scene);
    mesh.position.set(...options.at);
    mesh.rotation.x = -((options.tilt ?? 0) * Math.PI) / 180;
    mesh.material = this.material(options.color);
    mesh.isVisible = !options.hidden;
    if (!options.ghost) {
      // Mass 0 makes a static body: it never moves, and costs almost nothing.
      const body = new PhysicsAggregate(mesh, PhysicsShapeType.BOX, { mass: 0 }, this.scene);
      body.shape.filterMembershipMask = LAYER.world;
    }
    mesh.freezeWorldMatrix();
    return mesh;
  }

ramp() reicht hidden weiter:

src/level.ts
/**
   * A slab that rises along +z from the floor at `z`, by `rise` over `run` meters. Its top
   * surface starts at floor level, so there is no edge to trip over.
   */
  ramp(name: string, options: { x: number; width: number; z: number; rise: number; run: number; hidden?: boolean }): void {
    const { x, width, z, rise, run, hidden } = options;
    const angle = Math.atan2(rise, run);
    const thickness = 0.2;
    // The middle of the top surface, moved down to the middle of the slab.
    const y = rise / 2 - (Math.cos(angle) * thickness) / 2;
    const middle = z + run / 2 + (Math.sin(angle) * thickness) / 2;
    this.block(name, {
      size: [width, thickness, Math.hypot(rise, run)],
      at: [x, y, middle],
      tilt: (angle * 180) / Math.PI,
      color: 'steps',
      hidden,
    });
  }

Und die Treppe wird zu Geisterstufen mit einer versteckten Rampe vom Boden vor der ersten Stufe bis zur Kante der letzten, 2 m hoch auf 3 m. Ersetze sie in buildLevel():

src/level.ts
// Ahead: two staircases up to one platform. The low steps on the left are only for show:
  // the player walks up an invisible ramp over their front edges, as in most games, which
  // is smoother than climbing every step. The tall steps on the right have to be jumped.
  for (let i = 0; i < 10; i += 1) {
    const height = 0.2 * (i + 1);
    build.block(`step-${i}`, { size: [3, height, 0.3], at: [0, height / 2, 6.15 + 0.3 * i], color: 'steps', ghost: true });
  }
  build.ramp('stairs-ramp', { x: 0, width: 3, z: 5.7, rise: 2, run: 3, hidden: true });
  for (let i = 0; i < 4; i += 1) {
    const height = 0.5 * (i + 1);
    build.block(`tall-step-${i}`, { size: [2, height, 0.75], at: [2.5, height / 2, 6.375 + 0.75 * i], color: 'steps' });
  }
  build.block('stairs-top', { size: [5, 2, 3.3], at: [1, 1, 10.35], color: 'steps' });

Die Plattform oben beginnt jetzt dort, wo die Rampe endet, an der Kante der letzten Stufe, damit zwischen beiden keine Lücke bleibt.

Am Boden bleiben

Über einer Kuppe, oben an der Treppe auf dem Weg nach unten, fällt der Boden unter den Füßen schneller weg, als die Schwerkraft zieht. Für ein paar Frames ist die Spielfigur in der Luft, und das HUD flackert.

Darum kümmert sich groundBelow() in walk() oben. Findet der Controller keinen Boden, schaut ein Strahl von der Mitte der Kapsel senkrecht nach unten. Liegt begehbarer Boden weniger als snapDistance unter den Füßen, geht die Spielfigur weiter, und die vertikale Geschwindigkeit wird so gesetzt, dass dieser Frame sie genau um diese Strecke nach unten bewegt: -drop / dt. Der Controller hält sie auf dem Boden an. Erst wenn innerhalb von 30 cm kein Boden kommt, fällt sie.

Kollisionsebenen

Dieser Strahl beginnt mitten in der eigenen Kapsel der Spielfigur, die auch ein Physikkörper ist, und das Erste, was er träfe, wäre die Spielfigur. Havok löst das mit Kollisionsfiltern: Jede Form ist Mitglied in einigen Ebenen, ein Bit pro Ebene, und kollidiert mit anderen. Zwei Formen kollidieren nur, wenn die Mitgliedschaft jeder zur Maske der anderen passt, und Strahlen und Shape-Casts haben solche Masken auch.

Ersetze src/physics.ts. Es behält das Havok-Plugin jetzt für Abfragen und bekommt die Ebenen und eine Hilfsfunktion für Strahlen:

src/physics.ts
import {
  HavokPlugin,
  PhysicsRaycastResult,
  RegisterJoinedPhysicsEngineComponent,
  Vector3,
  type Scene,
} from '@babylonjs/core/pure';
import '@babylonjs/core/Physics/joinedPhysicsEngineComponent.types';
import HavokPhysics from '@babylonjs/havok';

RegisterJoinedPhysicsEngineComponent();

/**
 * Collision layers, one bit each. A shape is a member of some layers and collides with
 * others, and so is every query: rays and shape casts that look for the world skip the
 * player's own capsule.
 */
export const LAYER = {
  world: 1,
  player: 2,
};

/** What counts as solid: the ground and walls. */
export const SOLID = LAYER.world;

let plugin: HavokPlugin;

/** Loads Havok's WebAssembly module and turns physics on for the scene. */
export async function enablePhysics(scene: Scene): Promise<void> {
  const havok = await HavokPhysics();
  plugin = new HavokPlugin(true, havok);
  scene.enablePhysics(new Vector3(0, -9.81, 0), plugin);
}

const rayResult = new PhysicsRaycastResult();

/** The first solid surface between two points, or null. Shared result: read it right away. */
export function raycast(from: Vector3, to: Vector3, layers = SOLID): PhysicsRaycastResult | null {
  plugin.raycast(from, to, rayResult, { collideWith: layers });
  return rayResult.hasHit ? rayResult : null;
}

Die Kisten des Levels sind Mitglieder der Ebene world, dafür sorgt die Zeile mit filterMembershipMask in block(). Die Spielfigur bekommt von capsule() eine eigene Kapsel: Mitglied in player, die nur mit Festem kollidiert. Ein Strahl, der nach SOLID sucht, geht durch die Spielfigur hindurch, als wäre sie nicht da.

Die Strahl-Hilfsfunktion benutzt ein einziges Ergebnisobjekt, statt für jeden Strahl eines anzulegen. Das spart Müll für den Garbage Collector, aber der nächste Strahl überschreibt das Ergebnis, also lies es gleich aus.

Gib dem Controller im Konstruktor die neue Kapsel und die Steigungsgrenze:

src/player.ts
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)),
      { shape: capsule(PLAYER.standHeight, scene), capsuleHeight: PLAYER.standHeight, capsuleRadius: PLAYER.radius },
      scene,
    );
    this.controller.maxSlopeCosine = Math.cos((PLAYER.maxSlopeDegrees * Math.PI) / 180);
  }

Die Importe oben in src/player.ts sehen jetzt so aus:

src/player.ts
import {
  CharacterSupportedState,
  PhysicsCharacterController,
  PhysicsShapeCapsule,
  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 { LAYER, SOLID, raycast } from './physics.ts';
import { PLAYER } from './settings.ts';

Ausprobieren

Geh die Treppe hoch: Das HUD bleibt den ganzen Weg hinauf und wieder hinunter bei walk, und auf der Treppe zeigt es 3,3 m/s, weil das HUD das Tempo über den Boden zeigt und der Rest nach oben geht. Geh rechts über den Bordstein, links die 20- und die 40-Grad-Rampe hoch und probiere die mit 70 Grad: Du rutschst wieder hinunter.

Das Projekt am Ende von Teil 4: Treppe, Rampen und Bordstein, ohne Hüpfen.
In neuem Tab öffnen

Im nächsten Teil lernt die Spielfigur zu rennen und zu springen, mit etwas Hilfe, die Sprünge fair wirken lässt.

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.