Input
A puck driven by keys, a gamepad or a thumb, a controls screen that rebinds, and rumble.
Starts examples/input in this page, on WebGPU where your browser has it.
Input is the chapter that walks through it.
From a checkout of the engine, npm run examples serves it at /input/.
Source
main.ts
examples/input/main.ts
/**
* A puck in an arena, driven by any device, and a controls screen that rebinds it.
*
* The keyboard, a gamepad and a touch screen all drive the same puck, because the script asks an
* action map what the player means and never which device said it. `puck.drs` steers, boosts,
* brakes and bounces, and rumbles the pad on a hit where the pad has motors. The panel on the right
* rebinds boost and brake to the next key or button pressed and saves only what was changed. The
* prompts name what the player last used: a key, the label printed on their pad, or a tap.
*/
import {
ActionMap,
InputSource,
MeshBuilder,
TouchControls,
computeLightMatrix,
createEnvironment,
defaultStore,
} from '@driftengine/core';
import type { Binding, GamepadButton, Vec3 } from '@driftengine/core';
import { patchModule } from 'driftscript';
import { createReadout } from '../common/readout';
import { exported, hostScript } from '../common/script';
import { controls, flag, openStage } from '../common/stage';
import * as puckScript from './puck.drs';
const stage = await openStage({
directionalShadows: true,
outputTransform: 'aces',
sceneSamples: 4,
});
const { renderer, camera, canvas } = stage;
// #region devices
/* Every device at once. The keys listed are kept from the page, so Space does not scroll it. */
const input = new InputSource(canvas, [
'Space',
'ArrowUp',
'ArrowDown',
'ArrowLeft',
'ArrowRight',
'ShiftLeft',
]);
const actions = new ActionMap(input, {
move: {
stick: 'left',
up: ['KeyW', 'ArrowUp'],
down: ['KeyS', 'ArrowDown'],
left: ['KeyA', 'ArrowLeft'],
right: ['KeyD', 'ArrowRight'],
},
boost: { keys: ['Space'], buttons: ['faceDown'] },
brake: { keys: ['ShiftLeft'], buttons: ['faceRight', 'l2'] },
});
/* A stick where the left thumb lands, and taps and holds on the right of the screen. */
const touch = new TouchControls(input, {
stickBase: document.querySelector<HTMLElement>('#stick-base') ?? undefined,
stickNub: document.querySelector<HTMLElement>('#stick-nub') ?? undefined,
});
// #endregion
// #region store
/* What the player changed survives a reload. Only the changes are written, so a default the game
alters later still reaches a player who never touched it. */
const store = defaultStore();
const SAVED = 'driftengine.examples.input';
actions.load(store, SAVED);
// #endregion
// #region script
const script = hostScript(puckScript);
interface Puck {
x: number;
z: number;
vx: number;
vz: number;
boost: number;
braking: boolean;
bumps: number;
hit: number;
raw: boolean;
shake: boolean;
}
const puck = exported<() => Puck>(script, 'createPuck')();
type Drive = (puck: Puck, actions: ActionMap, touch: TouchControls, dt: number) => void;
if (import.meta.hot) {
import.meta.hot.accept('./puck.drs', (next) => {
if (next !== undefined) patchModule(script, next as Record<string, unknown>, { Puck: [puck] });
});
}
// #endregion
/* The controls screen. A row per action the player may change; pressing its button waits for the
next key or pad button, binds it, and says which action lost it, if one did. */
const REBINDABLE = ['boost', 'brake'];
const BUTTONS: readonly GamepadButton[] = [
'faceDown',
'faceRight',
'faceLeft',
'faceUp',
'l1',
'r1',
'l2',
'r2',
'select',
'start',
'l3',
'r3',
'dpadUp',
'dpadDown',
'dpadLeft',
'dpadRight',
];
/* Where a button is, for naming it before any pad has said what it prints on the plastic. */
const POSITION: Record<GamepadButton, string> = {
faceDown: 'bottom',
faceRight: 'right',
faceLeft: 'left',
faceUp: 'top',
l1: 'left bumper',
r1: 'right bumper',
l2: 'left trigger',
r2: 'right trigger',
select: 'select',
start: 'start',
l3: 'left stick',
r3: 'right stick',
dpadUp: 'd-pad up',
dpadDown: 'd-pad down',
dpadLeft: 'd-pad left',
dpadRight: 'd-pad right',
guide: 'home',
};
let waiting: string | null = null;
const note = document.querySelector<HTMLElement>('#note');
const rows = document.querySelector<HTMLElement>('#rows');
function bindingNames(action: string): string {
const pad = input.pad(0);
return actions
.bindingsFor(action)
.map((binding) =>
binding.device === 'keyboard'
? keyName(binding.code)
: binding.device === 'mouse'
? `${binding.button} mouse button`
: (pad?.identity.label(binding.button) ?? `pad ${POSITION[binding.button]}`),
)
.join(', ');
}
// #region rebind
function bind(binding: Binding, said: string): void {
if (waiting === null) return;
const displaced = actions.rebind(waiting, binding);
actions.save(store, SAVED);
if (note !== null) {
note.textContent =
displaced.length > 0 ? `${said} was taken from ${displaced.join(' and ')}` : '';
}
waiting = null;
drawRows();
}
addEventListener('keydown', (event) => {
if (waiting === null) return;
event.preventDefault();
bind({ device: 'keyboard', code: event.code }, keyName(event.code));
});
/** Called each frame while a row is waiting: the first button any pad pressed. */
function listenToPads(): void {
if (waiting === null) return;
for (const pad of input.pads) {
for (const button of BUTTONS) {
if (pad.pressed(button)) {
bind({ device: 'gamepad', button }, pad.identity.label(button));
return;
}
}
}
}
// #endregion
function drawRows(): void {
if (rows === null) return;
rows.replaceChildren(
...REBINDABLE.map((action) => {
const row = document.createElement('div');
row.className = 'row';
const name = document.createElement('span');
name.textContent = action;
const keys = document.createElement('span');
keys.className = 'keys';
keys.textContent = bindingNames(action);
const change = document.createElement('button');
change.type = 'button';
change.textContent = waiting === action ? 'press…' : 'add';
if (waiting === action) change.className = 'waiting';
change.addEventListener('click', () => {
waiting = waiting === action ? null : action;
drawRows();
});
row.append(name, keys, change);
return row;
}),
);
}
document.querySelector('#reset')?.addEventListener('click', () => {
actions.resetToDefaults();
actions.save(store, SAVED);
waiting = null;
if (note !== null) note.textContent = '';
drawRows();
});
drawRows();
/* A pad plugged in changes how its buttons are named, so the rows are drawn again. */
input.onDeviceChange(() => drawRows());
/** A key's code as a player reads it: `KeyW` is W, `ShiftLeft` is left shift. */
function keyName(code: string): string {
if (code.startsWith('Key')) return code.slice(3);
if (code.startsWith('Digit')) return code.slice(5);
if (code.startsWith('Arrow')) return `${code.slice(5).toLowerCase()} arrow`;
if (code.endsWith('Left')) return `left ${code.slice(0, -4).toLowerCase()}`;
if (code.endsWith('Right')) return `right ${code.slice(0, -5).toLowerCase()}`;
return code.toLowerCase();
}
puck.raw = flag('stick', 'rim') === 'raw';
puck.shake = flag('rumble', 'on') === 'on';
const padsOn = flag('pad', 'on') === 'on';
input.setSourceEnabled('gamepad', padsOn);
controls([
{
key: 'stick',
label: 'stick',
value: puck.raw ? 'raw' : 'rim',
options: ['rim', 'raw'].map((s) => ({ text: s, value: s })),
change: (value) => {
puck.raw = value === 'raw';
},
},
{
key: 'rumble',
label: 'rumble',
value: puck.shake ? 'on' : 'off',
options: ['on', 'off'].map((r) => ({ text: r, value: r })),
change: (value) => {
puck.shake = value === 'on';
},
},
{
key: 'pad',
label: 'gamepad',
value: padsOn ? 'on' : 'off',
options: ['on', 'off'].map((p) => ({ text: p, value: p })),
change: (value) => input.setSourceEnabled('gamepad', value === 'on'),
},
]);
/* The arena: a floor with a low wall round it, four pillars, and the puck. */
const PILLARS: Vec3[] = [
[-5, 1.25, -4],
[5, 1.25, -4],
[-5, 1.25, 4],
[5, 1.25, 4],
];
const arena = new MeshBuilder()
.addBox([0, -0.1, 0], [11, 0.1, 11], [0.24, 0.25, 0.28])
.addBox([0, 0.25, -10.65], [11, 0.25, 0.15], [0.4, 0.38, 0.36])
.addBox([0, 0.25, 10.65], [11, 0.25, 0.15], [0.4, 0.38, 0.36])
.addBox([-10.65, 0.25, 0], [0.15, 0.25, 11], [0.4, 0.38, 0.36])
.addBox([10.65, 0.25, 0], [0.15, 0.25, 11], [0.4, 0.38, 0.36]);
for (let line = -10; line <= 10; line += 2) {
arena.addBox([line, 0.002, 0], [0.02, 0.002, 10.5], [0.3, 0.31, 0.35]);
arena.addBox([0, 0.002, line], [10.5, 0.002, 0.02], [0.3, 0.31, 0.35]);
}
for (const at of PILLARS) arena.addCylinder(at, 0.7, 1.25, 'y', [0.55, 0.5, 0.45], 0, 24);
const arenaMesh = renderer.createMesh(arena.build());
const puckMesh = renderer.createMesh(
new MeshBuilder()
.addCylinder([0, 0, 0], 0.6, 0.14, 'y', [0.2, 0.22, 0.26], 0, 32, 0.5)
.addCylinder([0, 0.16, 0], 0.32, 0.04, 'y', [0.85, 0.88, 0.92], 0, 24)
.build(),
);
const rim: number[] = [];
for (let k = 0; k <= 48; k += 1) {
const angle = (k / 48) * Math.PI * 2;
rim.push(Math.cos(angle) * 0.62, 0, Math.sin(angle) * 0.62);
}
const rimMesh = renderer.createMesh(
new MeshBuilder().addTube(rim, new Array<number>(49).fill(0.05), [1, 1, 1], 1).build(),
);
const env = createEnvironment({
directionalDir: [0.4, 0.85, 0.35],
directionalColor: [1.5, 1.45, 1.35],
ambient: [0.35, 0.37, 0.44],
ambientGround: [0.12, 0.12, 0.13],
/* Emission is gated on how much of night there is, so the rim needs some of it to glow. */
nightFactor: 0.6,
emissiveGain: 2,
});
const lightMatrix = new Float32Array(16);
env.lightViewProj = lightMatrix;
env.shadowStrength = 0.6;
env.shadowDepthSpan = computeLightMatrix(
env.directionalDir,
0,
1,
0,
15,
renderer.shadowMapSize,
lightMatrix,
);
const IDENTITY = new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
const RESTING: Vec3 = [0.15, 0.45, 1.2];
const BOOSTING: Vec3 = [1.6, 0.7, 0.15];
const BRAKING: Vec3 = [1.4, 0.12, 0.08];
const rimTint: Vec3 = [0, 0, 0];
const model = new Float32Array(IDENTITY);
/* Clear of the bindings panel, which shares the top of the frame with these lines. */
const bindings = document.querySelector<HTMLElement>('#bindings');
const readout = createReadout(renderer, 3, { clearRight: () => (bindings?.offsetWidth ?? 0) + 24 });
let time = 0;
/** What to press, said for the device the player last used. */
function prompt(): string {
if (input.lastDevice === 'touch') return 'LEFT THUMB STEERS, TAP TO BOOST, HOLD TO BRAKE';
const pad = input.lastDevice === 'gamepad' ? input.pad(0) : null;
const first = (action: string): string => {
for (const binding of actions.bindingsFor(action)) {
if (pad !== null && binding.device === 'gamepad') return pad.identity.label(binding.button);
if (pad === null && binding.device === 'keyboard') return keyName(binding.code);
}
return 'NOTHING';
};
const steer = pad === null ? 'WASD' : 'LEFT STICK';
return `${steer} TO STEER, ${first('boost')} TO BOOST, ${first('brake')} TO BRAKE`.toUpperCase();
}
stage.run({
simulate(dt) {
time += dt;
// #region tick
listenToPads();
touch.tick(performance.now());
exported<Drive>(script, 'drive')(puck, actions, touch, dt);
// #endregion
},
render() {
camera.fovYDeg = 50;
camera.position[0] = 0;
camera.position[1] = 18;
camera.position[2] = 15.5;
camera.lookAt(0, 0, 2.4);
model.set(IDENTITY);
model[12] = puck.x;
model[13] = 0.35;
model[14] = puck.z;
renderer.beginShadowPass(lightMatrix, 'static');
renderer.drawShadowCasters((sink) => {
sink.mesh(arenaMesh, IDENTITY);
sink.mesh(puckMesh, model);
});
renderer.endShadowPass();
renderer.beginFrame([0.13, 0.14, 0.18]);
renderer.bindMeshPass(camera, env);
renderer.drawMesh(arenaMesh, IDENTITY);
renderer.drawMesh(puckMesh, model);
/* The rim: orange while boosting, red while braking, and flashing white on a hit. Its glow is
a per-draw tint over a white emissive ring, so changing it allocates and rebuilds nothing. */
const glow = puck.hit * 2;
const base = puck.boost > 0 ? BOOSTING : puck.braking ? BRAKING : RESTING;
rimTint[0] = base[0] + glow;
rimTint[1] = base[1] + glow;
rimTint[2] = base[2] + glow;
renderer.drawMesh(rimMesh, model, undefined, rimTint);
const pad = input.pad(0);
const motors =
pad === null ? 'NO PAD' : pad.canRumble ? 'PAD HAS MOTORS' : 'PAD HAS NO MOTORS HERE';
readout.set(
0,
`LAST USED: ${input.lastDevice.toUpperCase()}${pad !== null && input.lastDevice === 'gamepad' ? ` (${pad.identity.family.toUpperCase()})` : ''}`,
);
readout.set(1, prompt());
readout.set(
2,
`SPEED ${Math.hypot(puck.vx, puck.vz).toFixed(1)} M/S BUMPS ${puck.bumps} ${motors}`,
);
readout.draw(time);
renderer.endFrame();
},
});puck.drs
examples/input/puck.drs
// The puck: how it answers a stick, the keys or a thumb, a boost and a brake, and what it does when
// it hits something.
//
// Under `npm run examples`, change a rule and save: the puck drives by it from the next tick. Try a
// longer boost, a puck that slows by itself, or a rumble that only the hardest hits get.
import { axisX, axisY, canRumble, down, pressed, rawAxisX, rawAxisY, rumble, touchHeld, touchTap, touchX, touchY } from "drift/input"
import { abs, max, min, sqrt } from "std/math"
// Half the arena's width to its walls, the puck's radius and a pillar's, in metres.
let WALL: f32 = 10.5
let RADIUS: f32 = 0.6
let PILLAR: f32 = 0.7
// How hard the puck accelerates, and how much of its speed the floor takes each second.
let PUSH: f32 = 14
let DRAG: f32 = 1.4
data Puck {
x: f32 = 0
z: f32 = 5
vx: f32 = 0
vz: f32 = 0
// Seconds of boost left, and before another may start.
boost: f32 = 0
rest: f32 = 0
braking: bool = false
bumps: u32 = 0
// How hard the last hit was, 0 to 1 and fading, which the page flashes the rim with.
hit: f32 = 0
// The page's switches: read the stick as it is or shortened to the rim, and rumble or not.
raw: bool = false
shake: bool = true
}
fn drive(puck: mut Puck, actions: Actions, touch: Touch, dt: f32) {
steer(puck, actions, touch, dt)
walls(puck, actions)
pillar(puck, actions, -5, -4)
pillar(puck, actions, 5, -4)
pillar(puck, actions, -5, 4)
pillar(puck, actions, 5, 4)
puck.hit = math.max(0, puck.hit - dt * 2)
}
// #region steer
// The stick, the keys or a thumb on the screen, whichever is pushed furthest: a keyboard and a
// touch screen at once never add up to more than one stick.
fn steer(puck: mut Puck, actions: Actions, touch: Touch, dt: f32) {
var sx = input.axisX(actions, "move")
var sz = input.axisY(actions, "move")
if puck.raw {
sx = input.rawAxisX(actions, "move")
sz = input.rawAxisY(actions, "move")
}
if math.abs(input.touchX(touch)) > math.abs(sx) {
sx = input.touchX(touch)
}
if math.abs(input.touchY(touch)) > math.abs(sz) {
sz = input.touchY(touch)
}
if puck.rest > 0 {
puck.rest -= dt
}
let tapped = input.touchTap(touch)
if (input.pressed(actions, "boost") || tapped) && puck.rest <= 0 {
puck.boost = 0.5
puck.rest = 1.2
}
var push = PUSH
if puck.boost > 0 {
push = PUSH * 2.6
puck.boost -= dt
}
puck.braking = input.down(actions, "brake") || input.touchHeld(touch)
var drag = DRAG
if puck.braking {
drag = DRAG * 5
}
puck.vx += (sx * push - puck.vx * drag) * dt
puck.vz += (sz * push - puck.vz * drag) * dt
puck.x += puck.vx * dt
puck.z += puck.vz * dt
}
// #endregion
// #region bump
// Off the walls and the pillars with some of its speed lost, and the pad, where it has motors,
// rumbles with how hard the puck hit.
fn bounce(puck: mut Puck, actions: Actions, speed: f32) {
let hard = math.min(1, speed / 12)
puck.bumps += 1
puck.hit = math.max(puck.hit, hard)
if puck.shake && input.canRumble(actions) {
input.rumble(actions, 60 + 140 * hard, hard, 0.3 * hard)
}
}
// #endregion
fn walls(puck: mut Puck, actions: Actions) {
let edge = WALL - RADIUS
if puck.x > edge && puck.vx > 0 {
bounce(puck, actions, puck.vx)
puck.vx = -puck.vx * 0.55
puck.x = edge
}
if puck.x < -edge && puck.vx < 0 {
bounce(puck, actions, -puck.vx)
puck.vx = -puck.vx * 0.55
puck.x = -edge
}
if puck.z > edge && puck.vz > 0 {
bounce(puck, actions, puck.vz)
puck.vz = -puck.vz * 0.55
puck.z = edge
}
if puck.z < -edge && puck.vz < 0 {
bounce(puck, actions, -puck.vz)
puck.vz = -puck.vz * 0.55
puck.z = -edge
}
}
fn pillar(puck: mut Puck, actions: Actions, px: f32, pz: f32) {
let dx = puck.x - px
let dz = puck.z - pz
let reach = RADIUS + PILLAR
let apart = dx * dx + dz * dz
if apart >= reach * reach || apart <= 0 {
return
}
let distance = math.sqrt(apart)
let nx = dx / distance
let nz = dz / distance
let into = puck.vx * nx + puck.vz * nz
if into >= 0 {
return
}
bounce(puck, actions, -into)
puck.vx -= nx * into * 1.55
puck.vz -= nz * into * 1.55
puck.x = px + nx * reach
puck.z = pz + nz * reach
}