Ein First-Person-Controller in Babylon.js · Teil 5 von 11 der Serie
Teil 5: Rennen und Springen
Renne mit Shift, springe genau so hoch, wie du willst, behalte deinen Schwung in der Luft und mach Sprünge mit Coyote Time und einem Sprungpuffer fair.

Quest-Ziele
- Eine gehaltene Taste von einer gerade gedrückten unterscheiden
- Rennen, aber nur vorwärts
- Auf eine Höhe springen, die du festlegst, statt auf ein Tempo
- In der Luft Schwung behalten und ein wenig lenken
- Späte und frühe Sprünge mit Coyote Time und Sprungpuffer verzeihen
Nach Teil 4 geht die Spielfigur überall hin, wo es eine Schräge erlaubt. Dieser Teil bringt Tempo und Höhe: Rennen und Springen über die Lücken rechts im Parcours.
Gedrückt, nicht gehalten
Gehen fragt, ob eine Taste unten ist. Ein Sprung fragt, ob sie gerade heruntergegangen ist: Wer Leertaste hält, soll einmal springen und nicht nach jeder Landung wieder. Die Eingabeklasse sammelt die Tasten, die seit dem letzten Frame gedrückt wurden, übergeht die Wiederholungen, die das Betriebssystem für gehaltene Tasten schickt, und vergisst sie am Ende des Frames. Ersetze 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'],
run: ['ShiftLeft', 'ShiftRight'],
jump: ['Space'],
};
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 readonly pressed = 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();
if (!event.repeat) this.pressed.add(event.code);
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));
}
/** Whether one of the action's keys went down since the last frame. */
pressedNow(action: Action): boolean {
return BINDINGS[action].some((code) => this.pressed.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;
}
/** Call at the end of every frame. */
endFrame(): void {
this.pressed.clear();
}
}endFrame() muss als Letztes im Frame kommen, nach allem, was pressedNow() fragt. In src/main.ts:
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);
input.endFrame();
});Die Zahlen
Die Spielfigur bekommt ein Renntempo, eine Beschleunigung für die Luft, eine Sprunghöhe und zwei Timer. PLAYER in src/settings.ts sieht jetzt so aus:
/** 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,
runSpeed: 7,
/** How fast the velocity may change on the ground and in the air, in m/s². */
groundAcceleration: 40,
airAcceleration: 10,
gravity: 20,
jumpHeight: 1.2,
/** A jump still works this long after walking off an edge. */
coyoteTime: 0.12,
/** A jump pressed this long before landing happens on landing. */
jumpBuffer: 0.12,
/**
* 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,
};Rennen
Die neuen Felder kommen in die Klasse Player in src/player.ts, running neben mode und die anderen hinter velocity:
running = false;
private readonly controller: PhysicsCharacterController;
private readonly level: Level;
private readonly input: Input;
private readonly look: Look;
private readonly velocity = new Vector3();
/** True from a jump until the player starts to come down. */
private rising = false;
private coyoteTimer = 0;
private jumpTimer = 0;Die Spielfigur rennt, solange Shift gehalten wird und sie vorwärts geht. Rückwärts oder seitwärts mit vollem Tempo zu rennen sieht seltsam aus und ist in Spielen selten. update() rechnet das aus und zählt die Timer herunter, bevor die Modi laufen:
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);
if (this.input.pressedNow('jump')) this.jumpTimer = PLAYER.jumpBuffer;
this.running = this.input.down('run') && this.input.axis('back', 'forward') > 0;
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);
}groundSpeed() wählt das Tempo, für den Boden und für die Luft:
private groundSpeed(): number {
return this.running ? PLAYER.runSpeed : PLAYER.walkSpeed;
}Springen
Ein Sprung setzt die vertikale Geschwindigkeit. Welche? Die, mit der die gewünschte Höhe erreicht wird, und die Physik hat die Antwort: Was mit dem Tempo v gegen die Schwerkraft g nach oben geworfen wird, steigt v² / 2g hoch, also ist das Tempo für eine Höhe h gleich √(2gh). Für 1,2 m bei 20 m/s² sind das 6,9 m/s. Wer die Höhe statt des Tempos festlegt, kann die Schwerkraft ändern, und der Sprung bleibt gleich hoch. 1,2 m reichen über die 1-m-Kisten und über die 50-cm-Stufen, eine nach der anderen.
Ersetze walk(), das jetzt springt:
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;
}
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;
}Und ersetze air() und ergänze dahinter jump():
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;
}
// 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;
}
private jump(): void {
this.velocity.y = Math.sqrt(2 * PLAYER.gravity * PLAYER.jumpHeight);
this.rising = true;
this.mode = 'air';
this.jumpTimer = 0;
this.coyoteTimer = 0;
}rising bleibt von einem Sprung an wahr, bis die Spielfigur wieder fällt. Direkt nach dem Absprung ist die Kapsel noch wenige Zentimeter über dem Boden, und checkSupport() kann sie noch als gestützt melden. Ohne das Flag hielte air() das für eine Landung, und walk() finge an, die Aufwärtsgeschwindigkeit zu bremsen wie jede andere: In einem Test ohne das Flag fiel der Sprung etwa 10 cm niedriger aus.
In der Luft
In der Luft behält die Spielfigur ihren Schwung: Ohne Eingabe ändert sich nichts außer der Schwerkraft. Mit Eingabe lenkt sie in Richtung der Geschwindigkeit, die die Tasten verlangen, aber mit einem Viertel der Beschleunigung, die sie am Boden hat. Das reicht, um einen Sprung zu korrigieren, aber nicht, um in der Luft umzukehren.
Coyote Time und ein Sprungpuffer
Spielende drücken Springen etwas zu spät, einen Frame nachdem sie von einer Kante gegangen sind, und etwas zu früh, kurz vor der Landung. Strenger Code übergeht beides, und das Spiel fühlt sich unfair an, obwohl die Spielenden danebenlagen. Zwei kleine Timer helfen:
Coyote Time, benannt nach dem Kojoten aus dem Zeichentrick, der über Klippen hinausrennt und erst fällt, als er es merkt: Bis 0,12 s nachdem die Spielfigur von einer Kante gegangen ist, klappt ein Sprung noch.
walk()startet den Timer, wenn der Boden verschwindet, undair()springt, solange er läuft.Der Sprungpuffer: Ein Sprung, der bis zu 0,12 s vor der Landung gedrückt wird, wird gemerkt und bei der Landung ausgeführt.
update()startet den Timer beim Drücken, undwalk()springt, solange er läuft.
Das HUD
Die erste Zeile des HUD bekommt die Haltung, damit du siehst, wann die Spielfigur rennt. Ersetze update() in src/hud.ts:
update(player: Player): void {
const feet = player.feet();
const stance = player.running ? 'running' : 'standing';
const text = [
`${player.mode} · ${stance}`,
`${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;
}Und das Overlay in index.html nennt die neuen Tasten:
<div id="overlay">
<strong>Click to play</strong>
<span>WASD move · mouse look · Shift run · Space jump · Esc pause</span>
</div>Ausprobieren
Renne mit Shift: Das HUD zeigt 7,0 m/s und running. Springe auf der Stelle, und die Füße steigen 1,2 m. Dann geh zu den Kisten rechts im Parcours und springe auf die erste. Die 2-m-Lücke zur nächsten schaffst du mit einem Sprung im Gehen. Die 5-m-Lücke danach braucht Anlauf: Ein Sprung dauert etwa 0,7 s, bis du wieder auf derselben Höhe bist, das sind 2,8 m im Gehtempo und 4,8 m im Rennen. Geh vom Ende einer Kiste und drücke einen Moment später Springen, und du springst trotzdem.
Im nächsten Teil lernt die Spielfigur, sich klein zu machen und durch einen Tunnel zu kriechen.