Examples

Characters

A character controller in a valley of steps, a slab, a bridge and a pond.

Starts examples/character in this page, on WebGPU where your browser has it.

Open on its own · Source

Characters is the chapter that walks through it.

From a checkout of the engine, npm run examples serves it at /character/.

Source

main.ts

examples/character/main.ts
/**
 * A character to walk about a valley: across a bridge or down under it, up a flight of steps, off
 * a slab too steep to stand on, into a pond, and into crates that move when pushed.
 *
 * The character is a `CharacterController`, a kinematic capsule swept through the physics world.
 * It stands on the bodies, the steps, the slab, the crates and the bridge's piers, and on a ground
 * surface beside them: the valley's heightfield, and the bridge deck as a second field that is only
 * there over its span, so the deck and the path beneath it are both a floor. The controls are a
 * DriftScript module reading the input actions.
 *
 * WASD or the arrows walk, Space jumps, Q and E turn the camera. Until a key is pressed, the
 * character walks a circuit of its own.
 */
import {
  ActionMap,
  InputSource,
  MeshBuilder,
  computeLightMatrix,
  createEnvironment,
  heightSurface,
} from '@driftengine/core';
import type { MeshHandle, Vec3, WaterBody } from '@driftengine/core';
import {
  BODY_DYNAMIC,
  BODY_STATIC,
  CharacterController,
  PhysicsWorld,
  applyBuoyancy,
  boxShape,
  createBuoyancy,
} from '@driftengine/physics';
import type { ControllerOptions } from '@driftengine/physics';
import { Terrain, heightfieldPatch } from '@driftengine/terrain';
import { patchModule } from 'driftscript';
import { createReadout } from '../common/readout';
import { exported, hostScript } from '../common/script';
import { controls, flag, openStage } from '../common/stage';
import * as controlsScript from './controls.drs';

const stage = await openStage({
  water: true,
  directionalShadows: true,
  outputTransform: 'aces',
  sceneSamples: 4,
});
const { renderer, camera, canvas } = stage;

// #region ground
/** A valley along z, a pond dug into its west bank, and a bridge deck across it at z = 0. */
const smooth = (t: number): number => t * t * (3 - 2 * t);
function valley(x: number, z: number): number {
  const bank = 2.6 * smooth(Math.min(1, Math.max(0, (Math.abs(x) - 1.5) / 4.5)));
  const pond = 1.6 * Math.max(0, 1 - Math.hypot(x + 12, z - 9) / 4);
  return bank - pond;
}
const SIDE = 81;
const terrain = new Terrain({
  width: SIDE,
  depth: SIDE,
  spacingM: 0.5,
  heights: Float32Array.from({ length: SIDE * SIDE }, (_, i) =>
    valley((i % SIDE) * 0.5 - 20, Math.floor(i / SIDE) * 0.5 - 20),
  ),
  origin: [-20, 0, -20],
});
/* Level with the banks where it meets them: a body walks from one field onto the other only where
   the two are at one height. */
const DECK_Y = 2.6;
const deck = (x: number, z: number): number =>
  Math.abs(x) < 6.5 && Math.abs(z) < 1.5 ? DECK_Y : Number.NaN;

/** Two fields: whichever is nearer the height a body is at is the floor it stands on. */
const ground = heightSurface([(x, z) => terrain.heightAt(x, z), deck]);
// #endregion

// #region bodies
/** What the character collides with as bodies: the piers, a flight of steps, a slab and crates. */
const world = new PhysicsWorld();
const solid: {
  x: number;
  y: number;
  z: number;
  hx: number;
  hy: number;
  hz: number;
  qz?: number;
}[] = [
  { x: -2, y: 1.2, z: 0, hx: 0.4, hy: 1.2, hz: 1.2 },
  { x: 2, y: 1.2, z: 0, hx: 0.4, hy: 1.2, hz: 1.2 },
];
for (let step = 0; step < 8; step += 1) {
  solid.push({
    x: 10 + step * 0.6,
    y: 2.6 + (step + 1) * 0.125,
    z: -8,
    hx: 0.3,
    hy: (step + 1) * 0.125,
    hz: 1.2,
  });
}
solid.push({ x: 16, y: 3.6, z: -8, hx: 1.5, hy: 1, hz: 2 });
for (const box of solid) {
  world.addBody({
    type: BODY_STATIC,
    shape: boxShape(box.hx, box.hy, box.hz),
    x: box.x,
    y: box.y,
    z: box.z,
  });
}
/* A slab falling fifty degrees from the platform's edge toward +z: too steep to stand on, so a
   character who walks off the platform onto it slides down to the bank. */
const tilt = (50 * Math.PI) / 360;
world.addBody({
  type: BODY_STATIC,
  shape: boxShape(1.4, 0.15, 1.3),
  x: 16,
  y: 3.6,
  z: -5.15,
  qx: Math.sin(tilt),
  qw: Math.cos(tilt),
});
const crates: number[] = [];
for (let i = 0; i < 4; i += 1) {
  crates.push(
    world.addBody({
      type: BODY_DYNAMIC,
      shape: boxShape(0.4, 0.4, 0.4),
      x: -9 + i * 1.1,
      y: 3.1,
      z: -6,
      density: 150,
    }),
  );
}
// #endregion

// #region controller
/** Two feels for one character: quick to turn and short in the air, or slow to stop and floaty. */
const FEELS: Record<string, ControllerOptions> = {
  snappy: { acceleration: 60, deceleration: 50, airControl: 0.4, jumpSpeed: 7, gravity: -24 },
  floaty: { acceleration: 12, deceleration: 6, airControl: 0.8, jumpSpeed: 6, gravity: -11 },
};
let feel = flag('feel', 'snappy');
let player = new CharacterController({
  ...FEELS[feel],
  coyoteTicks: 6,
  jumpBufferTicks: 6,
  ground,
});
player.teleport(-8, 4, 4);
// #endregion

// #region water
/** The pond: falling in costs speed on every axis, and a drop settles instead of sinking fast. */
const POND = { x: -12, z: 9, half: 3.4, level: 1.8 };
const sea = createBuoyancy({ level: POND.level, drag: 3, sinkSpeed: -1.2 });
// #endregion

const input = new InputSource(canvas, ['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']);
const actions = new ActionMap(input, {
  move: {
    stick: 'left',
    up: ['KeyW', 'ArrowUp'],
    down: ['KeyS', 'ArrowDown'],
    left: ['KeyA', 'ArrowLeft'],
    right: ['KeyD', 'ArrowRight'],
  },
  turn: { stick: 'right', up: [], down: [], left: ['KeyQ'], right: ['KeyE'] },
  jump: { keys: ['Space'], buttons: ['faceDown'] },
});

// #region script
/** The controls, hosted, and the wish they fill each tick. */
const controlsModule = hostScript(controlsScript);
interface Wish {
  x: number;
  z: number;
  jump: boolean;
}
const wish = exported<() => Wish>(controlsModule, 'createWish')();
type Steer = (wish: Wish, actions: ActionMap, yaw: number, speed: number) => void;
type Seek = (
  wish: Wish,
  fx: number,
  fz: number,
  tx: number,
  tz: number,
  speed: number,
  jump: boolean,
) => void;
if (import.meta.hot) {
  import.meta.hot.accept('./controls.drs', (next) => {
    if (next !== undefined)
      patchModule(controlsModule, next as Record<string, unknown>, { Wish: [wish] });
  });
}
// #endregion

controls([
  {
    key: 'feel',
    label: 'feel',
    value: feel,
    options: ['snappy', 'floaty'].map((f) => ({ text: f, value: f })),
    change: (value) => {
      feel = value;
      /* The feel is the controller's options, so a new controller takes over where the old one stood. */
      const { x, y, z } = player;
      player = new CharacterController({
        ...FEELS[feel],
        coyoteTicks: 6,
        jumpBufferTicks: 6,
        ground,
      });
      player.teleport(x, y, z);
    },
  },
]);

/** The circuit the character walks until somebody takes the keys: across, down, under and back. */
const CIRCUIT: [number, number, boolean][] = [
  [-8, 0, false],
  [8, 0, false],
  [8.6, -8, false],
  [15.5, -8, false],
  [16, -2, false],
  [8, -3, false],
  [0, -5, false],
  [0, 6, false],
  [-10, 8, false],
  [-8, 3, true],
];
let leg = 0;
let playing = false;
const turn = { x: 0, y: 0 };
let yaw = 0.6;

let time = 0;
stage.run({
  simulate(dt) {
    time += dt;
    actions.vector('turn', turn);
    yaw -= turn.x * dt * 1.8;
    if (actions.down('jump') || actions.axis('move', 'x') !== 0 || actions.axis('move', 'y') !== 0)
      playing = true;
    if (playing) {
      exported<Steer>(controlsModule, 'steer')(wish, actions, yaw, player.maxSpeed);
    } else {
      const [tx, tz, jump] = CIRCUIT[leg] ?? [0, 0, false];
      exported<Seek>(controlsModule, 'seek')(
        wish,
        player.x,
        player.z,
        tx,
        tz,
        player.maxSpeed * 0.8,
        jump,
      );
      if (Math.hypot(tx - player.x, tz - player.z) < 0.8) leg = (leg + 1) % CIRCUIT.length;
    }
    player.move(world, dt, { moveX: wish.x, moveZ: wish.z, jump: wish.jump });
    if (Math.abs(player.x - POND.x) < POND.half && Math.abs(player.z - POND.z) < POND.half)
      applyBuoyancy(sea, player.y, player, dt);
    world.step(dt);
  },
  render: drawFrame,
});

const terrainMesh = renderer.createMesh(
  heightfieldPatch(terrain, { x: 0, z: 0, cells: SIDE - 1, color: [0.34, 0.46, 0.27] }),
);
const deckMesh = renderer.createMesh(
  new MeshBuilder().addBox([0, DECK_Y - 0.1, 0], [6.5, 0.1, 1.5], [0.6, 0.56, 0.5]).build(),
);
const solidMesh = renderer.createMesh(
  solid
    .reduce(
      (builder, box) =>
        builder.addBox([box.x, box.y, box.z], [box.hx, box.hy, box.hz], [0.62, 0.6, 0.57]),
      new MeshBuilder(),
    )
    .build(),
);
const slabMesh = renderer.createMesh(
  new MeshBuilder().addBox([0, 0, 0], [1.4, 0.15, 1.3], [0.5, 0.45, 0.42]).build(),
);
/* The same fifty degrees about x, as the matrix's three columns and its position. */
const angle = (50 * Math.PI) / 180;
const slabModel = Float32Array.of(
  ...[1, 0, 0, 0],
  ...[0, Math.cos(angle), Math.sin(angle), 0],
  ...[0, -Math.sin(angle), Math.cos(angle), 0],
  ...[16, 3.6, -5.15, 1],
);
const crateMesh = renderer.createMesh(
  new MeshBuilder().addBox([0, 0, 0], [0.4, 0.4, 0.4], [0.72, 0.55, 0.36]).build(),
);
const body = renderer.createMesh(
  new MeshBuilder()
    .addCapsule([0, 0, 0], player.radius, player.halfHeight, [0.95, 0.5, 0.2])
    .build(),
);
const pond = renderer.createWater();
const pondBody: WaterBody = {
  level: POND.level,
  deepColor: [0.05, 0.12, 0.14],
  shallowColor: [0.15, 0.3, 0.3],
  density: 0.6,
  waveScale: 0.1,
  bounds: { centreX: POND.x, centreZ: POND.z, halfM: POND.half },
};

const SKY: Vec3 = [0.62, 0.7, 0.8];
const env = createEnvironment({
  directionalDir: [-0.4, 0.7, 0.45],
  directionalColor: [1.8, 1.7, 1.5],
  ambient: [0.32, 0.36, 0.44],
  ambientGround: [0.14, 0.13, 0.12],
  fogColor: SKY,
  fogDensity: 0.01,
});
const lightMatrix = new Float32Array(16);
env.lightViewProj = lightMatrix;
env.shadowStrength = 0.8;
const IDENTITY = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
const model = new Float32Array(IDENTITY);
const readout = createReadout(renderer, 1);
const STATES = ['STANDING', 'IN THE AIR', 'SLIDING'];
const focus = { x: player.x, y: player.y, z: player.z };

function drawAll(draw: (mesh: MeshHandle, matrix: Float32Array) => void): void {
  draw(terrainMesh, IDENTITY);
  draw(deckMesh, IDENTITY);
  draw(solidMesh, IDENTITY);
  draw(slabMesh, slabModel);
  for (const crate of crates) {
    const b = world.bodies;
    const x = b.rotX[crate] ?? 0;
    const y = b.rotY[crate] ?? 0;
    const z = b.rotZ[crate] ?? 0;
    const w = b.rotW[crate] ?? 1;
    model.set([
      1 - 2 * (y * y + z * z),
      2 * (x * y + z * w),
      2 * (x * z - y * w),
      0,
      2 * (x * y - z * w),
      1 - 2 * (x * x + z * z),
      2 * (y * z + x * w),
      0,
      2 * (x * z + y * w),
      2 * (y * z - x * w),
      1 - 2 * (x * x + y * y),
      0,
      b.posX[crate] ?? 0,
      b.posY[crate] ?? 0,
      b.posZ[crate] ?? 0,
      1,
    ]);
    draw(crateMesh, model);
  }
  model.set(IDENTITY);
  model[12] = player.x;
  model[13] = player.y;
  model[14] = player.z;
  draw(body, model);
}

function drawFrame(): void {
  /* The camera follows behind and above, easing so a jump does not jerk it. */
  focus.x += (player.x - focus.x) * 0.1;
  focus.y += (player.y - focus.y) * 0.1;
  focus.z += (player.z - focus.z) * 0.1;
  camera.fovYDeg = 55;
  camera.position[0] = focus.x + Math.sin(yaw) * 9;
  camera.position[1] = focus.y + 5;
  camera.position[2] = focus.z + Math.cos(yaw) * 9;
  camera.lookAt(focus.x, focus.y + 0.8, focus.z);

  env.shadowDepthSpan = computeLightMatrix(
    env.directionalDir,
    focus.x,
    focus.y,
    focus.z,
    18,
    renderer.shadowMapSize,
    lightMatrix,
  );
  renderer.beginShadowPass(lightMatrix, 'static');
  renderer.drawShadowCasters((sink) => drawAll((mesh, matrix) => sink.mesh(mesh, matrix)));
  renderer.endShadowPass();
  renderer.beginFrame(SKY);
  renderer.bindMeshPass(camera, env);
  drawAll((mesh, matrix) => renderer.drawMesh(mesh, matrix));
  renderer.drawSky(
    camera,
    {
      top: [0.25, 0.42, 0.72],
      horizon: SKY,
      deep: [0.3, 0.34, 0.4],
      sunDir: env.directionalDir,
      sunColor: [1.8, 1.6, 1.3],
      sunAngularRadius: 0.02,
      moonDir: [0, -1, 0],
      moonColor: [0, 0, 0],
      moonAngularRadius: 0.03,
      moonPhase: 0,
      nightFactor: 0,
      cloudOffsetX: 0,
      cloudOffsetZ: 0,
    },
    env,
  );
  renderer.drawWater(pond, camera, time, pondBody, env, 0.5, 0.3);
  readout.set(0, `${STATES[player.state] ?? ''}  ${playing ? 'YOURS' : 'WALKING ITSELF'}`);
  readout.draw(time);
  renderer.endFrame();
}

controls.drs

examples/character/controls.drs
// The controls: what the player's keys or stick ask the character to do, turned to face the way
// the camera looks, and the walk the page takes when nobody is playing.
//
// Under `npm run examples`, change a rule and save: the next tick moves by it. Try a slower walk,
// or jumping whenever the character is moving.

import { axisX, axisY, down } from "drift/input"
import { clamp, cos, sin, sqrt } from "std/math"

data Wish {
    // Metres a second along x and z, and whether jump is held.
    x: f32 = 0
    z: f32 = 0
    jump: bool = false
}

// #region steer
// The stick or the keys, turned by the camera's yaw so that up is away from the camera.
fn steer(wish: mut Wish, actions: Actions, yaw: f32, speed: f32) {
    let across = input.axisX(actions, "move")
    let along = input.axisY(actions, "move")
    wish.x = (math.cos(yaw) * across + math.sin(yaw) * along) * speed
    wish.z = (math.cos(yaw) * along - math.sin(yaw) * across) * speed
    wish.jump = input.down(actions, "jump")
}
// #endregion

// Walk toward a point at a steady pace, slowing into it, and jump when told to.
fn seek(wish: mut Wish, fromX: f32, fromZ: f32, toX: f32, toZ: f32, speed: f32, jump: bool) {
    let dx = toX - fromX
    let dz = toZ - fromZ
    let distance = math.sqrt(dx * dx + dz * dz)
    let pace = math.clamp(distance, 0, 1) * speed
    if distance > 0.01 {
        wish.x = dx / distance * pace
        wish.z = dz / distance * pace
    } else {
        wish.x = 0
        wish.z = 0
    }
    wish.jump = jump
}