3. Camera and input
Rat Hunt, step 3: walk the valley in first person with keyboard, mouse, gamepad or touch, using makeActions, makeLookController and makeTouchControls.
In this step the circling camera becomes the player's eyes. You will walk with WASD, the arrow keys, a gamepad's left stick or an on-screen joystick, and look around with the mouse, a drag, the right stick or Q and E.
All of it goes through @voxolith/engine/input. A game never adds its own
keydown or pointermove listeners: one input object owns them, and everything else reads from
it. That is what lets the same code work on a laptop, a phone and a console controller.
One input per surface
import {
axis,
button,
createInput,
makeActions,
makeLookController,
makeTouchControls,
prepareSurface,
} from "@voxolith/engine/input";
prepareSurface(canvas);
const input = createInput(canvas);prepareSurface stops the browser from
scrolling, zooming or selecting text when the canvas is touched.
createInput listens to pointers, keys, the
wheel, pointer lock and gamepads for that canvas.
Actions, not keys
The game asks "how hard is the player pushing forward?", not "is W down?".
makeActions maps named actions to every source
that can drive them:
const actions = makeActions(input, {
strafe: axis({ negative: ["key:KeyA", "key:ArrowLeft"], positive: ["key:KeyD", "key:ArrowRight"], analog: ["pad:LeftX", "touch:move.x"] }),
walk: axis({
negative: ["key:KeyS", "key:ArrowDown", "pad:LeftY+", "touch:move.y+"],
positive: ["key:KeyW", "key:ArrowUp", "pad:LeftY-", "touch:move.y-"],
}),
run: button("key:ShiftLeft", "key:ShiftRight", "pad:LT"),
});key:KeyWis a physical key (KeyboardEvent.code), so WASD stays in place on an AZERTY keyboard.pad:LeftXis a signed stick axis, -1 to 1. Screen and gamepad Y point down, sopad:LeftY-is the half of the stick pushed up (0 to 1) andpad:LeftY+the half pulled back.touch:move.xandtouch:move.ycome from the joystick calledmove, created below.
An axis takes whichever source is pushed hardest, so a half-tilted stick is not overridden by a
key. actions.vector("strafe", "walk") reads two axes as one vector clamped to length 1, so
walking diagonally is not faster.
Looking around
const look = makeLookController(input, { yaw: 180, pitch: -8, keys: { left: ["KeyQ"], right: ["KeyE"] } });
makeTouchControls(input, { joysticks: [{ id: "move", side: "left" }] });makeLookController keeps a yaw and a
pitch in degrees:
- with a mouse, the first click captures the pointer (Esc releases it) and movement turns the view; before that, dragging turns it;
- on a touch screen, dragging anywhere that is not a control turns it;
- the right stick and the Q and E keys turn it at a steady rate.
makeTouchControls adds a floating
joystick on the left of the screen. It appears as soon as the page is touched and hides again
when a mouse or keyboard is used, so desktop players never see it.
The player
The player is a position on the ground and an eye 17 voxels (1.7 m) above it:
const EYE = 17; // 1.7 m
const player = { x: SIZE.x / 2, z: SIZE.z / 2, eyeY: 0 };
player.eyeY = terrain.heightAt(player.x, player.z) + EYE;
const view = () => firstPersonFrame([player.x, player.eyeY, player.z], look.yaw(), look.pitch(), 70);firstPersonFrame(eye, yaw, pitch, fov)
gives the camera fields for an eye position. Yaw 0 looks along +Z and turning right increases
the yaw. terrain.heightAt clamps and rounds its arguments, so it is safe to call with any
position.
Moving means turning the action vector into world directions. With firstPersonFrame's
convention, forward is (sin yaw, cos yaw) on the ground and screen-right is
cross(forward, up), which is (-cos yaw, sin yaw):
function movePlayer(dt: number): boolean {
const [sx, wz] = actions.vector("strafe", "walk");
if (!sx && !wz) return false;
const speed = (actions.down("run") ? 80 : 40) * dt;
const yaw = (look.yaw() * Math.PI) / 180;
// firstPersonFrame: forward is (sin, cos), screen-right is cross(forward, up).
const nx = player.x + (Math.sin(yaw) * wz - Math.cos(yaw) * sx) * speed;
const nz = player.z + (Math.cos(yaw) * wz + Math.sin(yaw) * sx) * speed;
if (nx < 4 || nz < 4 || nx > SIZE.x - 5 || nz > SIZE.z - 5) return false;
player.x = nx;
player.z = nz;
return true;
}Walking is 40 voxels a second (4 m/s) and Shift or the left trigger doubles it. The function returns whether the player moved, which step 7 uses.
The frame
Every frame reads the input in a fixed order: input.update() first, so this frame's key
presses and gamepad state are current, then look.update(dt), then the actions.
const loop = makeFrameLoop({
continuous: true,
render(now, dt) {
input.update();
look.update(dt);
movePlayer(dt);
const ground = terrain.heightAt(player.x, player.z) + EYE;
player.eyeY += (ground - player.eyeY) * Math.min(1, dt * 10);
resizeToDisplay(gpu);
renderer.render({ ...view(), ...sky, time: now / 1000 });
},
});
observeResize(canvas, loop);The ground rises in one-voxel steps, so the eye follows it with a little smoothing instead of
jumping. The old makeCamera line goes; sky stays.
Run it
Click the page to capture the mouse, then walk about. On a phone, touch the lower left for the joystick and drag elsewhere to look. Walking into a pond is allowed: you wade.
An orbit camera instead
For a view from above, makeOrbitController
gives drag to turn, wheel or pinch to zoom and right-drag or two fingers to pan, and feeds
makeCamera directly. The orbit example is the smallest program that
uses it; Cameras compares the two.
Next: trees and a house.
2. A generated valley
Rat Hunt, step 2: generate rolling ground with ponds using gen-terrain, give its colour roles palette slots, and write it into the renderer's bricks.
4. Trees and a house
Rat Hunt, step 4: generate a cottage and trees with gen-building and gen-tree, level the ground under the house, and stamp the models into the world.