Ein First-Person-Controller in Babylon.js · Teil 7 von 11 der Serie
Teil 7: Leitern und ein Zustandsautomat
Mach aus Gehen und Luft einen Zustandsautomaten, setze Leitern als Zonen ins Level und klettere daran: hinauf, hinunter, oben darüber und mit einem Sprung wieder ab.

Quest-Ziele
- Die zwei Modi zu einem Zustandsautomaten mit Platz für mehr ausbauen
- Leitern im Level als Zonen mit einer Richtung beschreiben
- Eine Leiter greifen, indem man auf sie zugeht oder sie in der Luft berührt
- Hinauf- und hinunterklettern und oben auf das, wozu die Leiter führt
- Von einer Leiter abspringen, ohne sie sofort wieder zu greifen
Bisher geht die Spielfigur oder ist in der Luft. Ab diesem Teil hält sie sich auch fest: jetzt an Leitern, in den nächsten Teilen an Kanten und Wänden. Jedes davon bewegt die Spielfigur auf eigene Art, ohne Schwerkraft und mit eigenem Weg hinauf und herunter. Sie werden Modi wie walk und air.
Die Leiter im Level
Rechts im Parcours kommt in diesem Teil ein 4 m hoher Turm dazu, mit einer Leiter an der Seite, die zum Start zeigt. Die Leiter besteht aus zwei Teilen: Holmen und Sprossen zum Ansehen, als Geisterkisten ohne Kollision, und einer Zone, dem Quader vor der Leiter, in dem die Spielfigur sie greifen kann. Der Turm dahinter ist fest, also klettert die Spielfigur an der Wand.
Das Level bekommt einen Typ für Leitern und eine Liste davon. Importiere in src/level.ts BoundingBox aus @babylonjs/core/pure:
/** 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[];
}Beginne buildLevel() mit { spawn: …, ladders: [] }, ergänze bei den Farben tower: '#c9c2f2' und ladder: '#ffc442' und gib dem Builder eine Methode für Leitern:
/** 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),
};
}Die Normale zeigt von der Wand weg, zur kletternden Person hin. Die Zone reicht 70 cm von der Wand weg und einen Meter über die Oberkante hinaus, damit die Füße die Kante überragen, bevor die Spielfigur sie verlässt. Dann baue den Turm in buildLevel(), hinter dem Tunnel:
// 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 }));Ein Zustandsautomat
Die Modi sind schon ein kleiner Zustandsautomat: Die Spielfigur ist in genau einem, jeder führt seinen eigenen Code aus, und jeder entscheidet selbst, wann er wechselt. Ergänze 'ladder' im Typ Mode und importiere den Typ Ladder neben Level:
export type Mode = 'walk' | 'air' | 'ladder';Die Spielfigur merkt sich die Leiter, an der sie hängt, und eine Abklingzeit, neben den Timern. Ergänze auch ladderSpeed: 3 in PLAYER in den Einstellungen:
private coyoteTimer = 0;
private jumpTimer = 0;
private grabCooldown = 0;
private ladder: Ladder | null = null;update() zählt die Abklingzeit herunter und führt den Leitermodus aus. Geduckt wird jetzt nur noch am Boden und in der Luft: An einer Leiter hat C nichts zu tun.
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));
}Hinauf
Gehen und Fallen halten nach einer Leiter Ausschau. Ersetze walk() und air():
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;
}Und ergänze den Leiterabschnitt am Ende der Klasse:
// 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() sucht eine Leiter, deren Zone die Mitte der Kapsel enthält. Am Boden greift die Spielfigur nur zu, wenn sie auf die Leiter zugeht: Die Richtung der Tasten muss höchstens 60 Grad von der geraden Richtung in die Leiter abweichen, ein Skalarprodukt unter -0,5 mit ihrer Normale. So bleibt man nicht hängen, wenn man an einer Leiter vorbeigeht. In der Luft genügt es, die Zone zu berühren, so fängt, wer oben rückwärts vom Turm geht, die Leiter auf dem Weg nach unten.
Klettern
An der Leiter gibt es keine Schwerkraft. W klettert mit 3 m/s hinauf, S hinunter, und unten steigt S am Boden ab. A und D bewegen seitwärts, und aus der Zone hinaus, womit man loslässt.
Oben braucht es keinen eigenen Code. Wer W hält, wird auch mit 1,5 m/s zur Leiter hin geschoben. Solange der Turm im Weg ist, bewirkt das nichts. Sobald die Füße über seiner Oberkante sind, trägt der Schub die Spielfigur auf ihn hinauf, die Mitte verlässt die Zone, und sie landet auf dem Turm.
Herunter
Ein Sprung stößt die Spielfigur von der Leiter weg und nach oben, mit letGo(), das auch die Kanten und Wände der nächsten Teile nutzen. Zwei Details zählen:
Die Abklingzeit verhindert, dass die Spielfigur die Leiter im nächsten Frame wieder greift, solange sie noch in ihrer Zone ist.
risingverhindert, dass der erste Frame in der Luft als Landung zählt, wie bei einem Sprung vom Boden. Nahe am Fuß der Leiter endete der Stoß sonst auf dem Boden.
Wer die Leiter greift, löscht jumpTimer: Ein Sprung, der kurz vorher in der Luft gedrückt wurde, würde sonst sofort wieder loslassen.
Ausprobieren
Geh zur Leiter an der Seite des Turms und hinein: Das HUD springt auf ladder. Klettere hinauf und oben darüber. Geh rückwärts von der Kante, und du fängst die Leiter auf dem Weg nach unten wieder. Auf halber Höhe drückst du die Leertaste und springst ab.
Im nächsten Teil lernt die Spielfigur, auf alles zu klettern, dessen Kante in Reichweite ist.