In the previous post I got the window open. The next thing I want to figure out is what the main game loop looks like, and how input is handled. The main loop is the beating heart of a game process, updating the game’s state and rendering the next frame.
The main loop also tends to be built on top however the system event loop works,
so I’m going to tackle input handling here too. So the SDL_PollEvent
main loop, or winit::EventLoop::run
, is close
to what I’m going to try to replicate here.
Update Loop
In a native game the main loop looks kinda like:
while (true) {
handleEvents();
updateState();
render();
}
This doesn’t map cleanly unfortunately. Implementing the traditional infinite main loop would lock up the tab and it would become unresponsive.
Instead I use
window.requestAnimationFrame
. This
takes a callback which will be invoked before the next repaint by the browser.
Demo (Contains Flashing Colors)
window.addEventListener('DOMContentLoaded', (event) => {
canvas = document.getElementById('main-canvas');
gl = canvas.getContext('webgl2');
window.requestAnimationFrame(doFrame);
});
function doFrame() {
gl.clearColor(Math.random(), 0.0, 0.0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
window.requestAnimationFrame(doFrame);
}
This is the bones of a render loop. The most important detail is that requestAnimationFrame
is invoked in the
callback in order to continue the loop. Each invokation only sets the callback for the next frame. It doesn’t behave
like an element’s event handler which will fire repeatedly until the handler is removed.
So that’s the render loop sorted. It’s almost enough for the update loop too - it’s just missing a way to tell how much
time has passed between frames. Fortunately requestAnimationFrame
also provides a high resolution timestamp as an
argument to the callback.
let canvas, gl;
let animVal = 0;
let previousT = 0;
window.addEventListener('DOMContentLoaded', (event) => {
canvas = document.getElementById('main-canvas');
gl = canvas.getContext('webgl2');
window.requestAnimationFrame(doFrame);
});
function doFrame(currentT) {
let deltaT = currentT - previousT;
previousT = currentT;
animVal += deltaT * 0.001;
let offsetX = Math.sin(animVal) * (canvas.width / 8);
let offsetY = Math.cos(animVal) * (canvas.height / 8);
gl.clearColor(1.0, 0.0, 1.0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.enable(gl.SCISSOR_TEST);
gl.scissor(offsetX + canvas.width / 4, offsetY + canvas.height / 4,
canvas.width / 2, canvas.height / 2);
gl.clearColor(1.0, 1.0, 1.0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.disable(gl.SCISSOR_TEST);
window.requestAnimationFrame(doFrame);
}
Here there’s a rectangle smoothly circling the canvas. To drive the animation I calculate the time between frames using the milliseconds timestamp provided to the callback.
Input Handling
I now have a place to update the game state and render each frame. What’s missing are the input events. On native platforms there’s often a place in the mainloop where you process the system’s event queue. Instead here I’m going to be largely subscribing the input events on the document or the canvas.
And as per the previous post on the topics of requestFullscreen
and requestPointerLock
, there are actions the
browser will only let the page do from inside a handler for a user initiated action. So I need to make sure however the
input handling logic is structured it happens in the callstack of those handlers - I can’t just queue up the events to
replicate the SDL_PollEvent
code flows I’m familiar with.
Many of the demos below just use console.log
to emit input event information - it’s worth keeping the dev console open
when checking them out.
Mouse Events
For mouse input there are a variety of events of interest:
mousedown
,
mouseup
,
mousemove
,
click
,
wheel
.
canvas.addEventListener('mousedown', (event) => {
console.log(`MouseDown: Button[${event.button}]`);
});
canvas.addEventListener('mouseup', (event) => {
console.log(`MouseUp: Button[${event.button}]`);
});
canvas.addEventListener('mousemove', (event) => {
console.log(`MouseMove: [${event.offsetX}, ${event.offsetY}]`);
})
canvas.addEventListener('click', (event) => {
console.log(`Click: [${event.offsetX}, ${event.offsetY}] [${event.button}]`);
})
canvas.addEventListener('wheel', (event) => {
console.log(`Wheel: [${event.deltaY}]`);
})
These listeners are attached to the canvas element. This means that events will only fire when the cursor is over the
canvas, and the offsetX
and offsetY
coordinates give the location of the event relative to the top-left of the
canvas.
The mousemove
event also has movementX
and movementY
attributes which are handy. I’ve written the boiler plate
code to calculate this based on delta of the mouse location many times, and it’s nice to have it handled for me.
The click
event only fires for left click. mouseup
and mousedown
fire for any button, but context menus will still
appear. There do seem to be ways to disable context menu on right-click, but it’s also a common anti-pattern such that
there are extensions and browser settings to prevent disabling of context menus so it’s not something I want to rely on.
The wheel
event fires on use of the scoll wheel over the canvas. The deltaY
property is positive for the direction
that would normally scroll the page down, and negative for the direction that would scroll the page up.
Mouse Events with Locked Cursor
The same events are available when the cursor is locked to the canvas, but behave slightly differently.
When the cursor becomes locked the position attributes on events (offsetX/Y
, clientX/Y
, etc) become locked. The
movementX/Y
attributes are still valid. So all the mouse events I’m using will fire as normal, just with the absolute
position reported as fixed.
The click
event will also fire for buttons other than left mouse button. When the cursor is locked context menu and
other default mouse interaction behaviors are suppressed so it should be safe to rely on the right mouse button.
With wheel
there’s a problem. The spec requires that wheel
fire while in pointer lock, and it does on Firefox. All
the versions of Chrome that I tried however wouldn’t emit the wheel
event when in pointer lock. Until this is resolved
I can’t use the scroll wheel as an input mechanism for games that require pointer lock - so no scroll wheel to change
weapon in an FPS. Hopefully that gets fixed soon.
Key Events
The main keyboard events are keydown
and
keyup
document.addEventListener('keydown', (event) => {
if (!event.repeat) {
console.log(`KeyDown Scancode[${event.code}] VirtualKey[${event.key}]`);
} else {
console.log(`KeyRepeat Scancode[${event.code}] VirtualKey[${event.key}]`);
}
});
document.addEventListener('keyup', (event) => {
console.log(`KeyUp Scancode[${event.code}] VirtualKey[${event.key}]`);
});
These handlers are attached to the document, not the canvas. The two attributes of note are code
and key
:
code
is a string representation of the scancode (broadly the physical key location) of the key pressed. The values seem to be roughly standardized across browser and platform, at least for the majority of keys.key
is the string value of the character pressed, with modifiers applied. If I press AltGr+E I get a keydown event with akey
ofAltGraph
, followed by a keydown event with akey
ofé
.
If the key is held down, eventually keydown repeat events are triggered, which can be detected via the repeat
attribute on the event.
Gamepad Events
The gamepad is the odd-one-out, and the only input device we directly query the state of, instead of relying on event
handlers. To get a handle to the gamepad initially and to track when the gamepad is disconnected there are the
gamepadconnected
and
gamepaddisconnected
events. The
connection event will fire when a button is pressed on the controller after the page has opened, so I’m able to rely
on getting a connection event even if the controller is physically connected when the page is loaded.
let gamepad;
window.addEventListener('gamepadconnected', (event) => {
gamepad = event.gamepad;
console.log("Gamepad Connected");
});
window.addEventListener('gamepaddisconnected', (event) => {
gamepad = null;
console.log("Gamepad Disconnected");
});
There is extensive information on the details of the gamepad API and how the controller is mapped on MDN here. I poll the gamepad object every frame, to see what the state of the buttons are.
function doFrame() {
let red = 0.0;
let green = 0.5;
if (gamepad) {
let padState = navigator.getGamepads()[gamepad.index];
if (padState.buttons[0].pressed) {
red = 1.0;
}
green = (padState.axes[0] / 2) + 0.5;
}
gl.clearColor(red, green, 0.0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
window.requestAnimationFrame(doFrame);
}
This checks for a button press to control the red channel, and the X axis on the primary stick to control the green channel. The standard controller mapping is specified in the spec. I have to request the current gamepad state each frame, and use the index from the gamepad object grabbed in the connection handler.
Gamepad interactions also set the browser state as having registered a user interaction. The consequence of this is that
I was able to put canvas.requestFullscreen
in the the button pressed block and use the controller to initiate
fullscreen mode.
Touch Events
Single touch events emit emulated mouse events, so basic interactions will work on touch devices without specific handling. There are events that allow for handling multi touch gestures, but I think I’ll handle that in a separate post at some point. It seems like hefty subject worth its own attention when the time comes.