The typical solution to triggering actions in a game is to add a layer of indirection: don't let the user's action update entity state until the game loop runs on the next frame. (Yes, this applies to mouse events and just about anything else that affects game state as well in most cases)
It might make intuitive sense to trigger a game event as soon as a key is pressed; after all, that's how you'd normally respond to an event: right away in the listener callback.
However, in games and animations, the update/rendering loop is the only place where entity updates such as movement should occur. Messing with positions outside of the rendering loop bypasses the normal flow illustrated below:
[initialize state]
|
v
.-----------------.
| synchronously |
| update/render |
| a single frame |
`-----------------`
^ |
| v
(time passes asynchronously,
events fire between frames)
When events fire, they should modify intermediate state that the update code can then take into consideration when it comes time to update entity positions and states.
Specifically, you could use flags that represent which keys were pressed, flip them on whenever a keydown
event fires, and flip them off whenever a keyup
event fires. Then, the key will be processed regardless of any operating system delay buffering in the update loop.
Rather than a boolean for every key, simply add the keys to a set when pressed and remove them when they're released.
Here's a minimal example:
const keysPressed = new Set();
document.addEventListener("keydown", e => {
keysPressed.add(e.code);
});
document.addEventListener("keyup", e => {
keysPressed.delete(e.code);
});
(function update() {
requestAnimationFrame(update);
document.body.innerHTML = `
<p>These keys are pressed:</p>
<ul>
${[...keysPressed]
.map(e => `<li>${e}</li>`)
.join("")
}
</ul>
`;
})();
The above code works as a drop-in to implement rudimentary game movement, with some default prevention as needed:
const keysPressed = new Set();
const preventedKeys = new Set([
"ArrowUp",
"ArrowDown",
"ArrowLeft",
"ArrowRight",
]);
document.addEventListener("keydown", e => {
if (preventedKeys.has(e.code)) {
e.preventDefault();
}
keysPressed.add(e.code);
});
document.addEventListener("keyup", e => {
keysPressed.delete(e.code);
});
const player = {
x: 0,
y: 0,
speed: 2,
el: document.querySelector("#player"),
render() {
this.el.style.left = player.x + "px";
this.el.style.top = player.y + "px";
},
actions: {
ArrowLeft() { this.x -= this.speed; },
ArrowDown() { this.y += this.speed; },
ArrowUp() { this.y -= this.speed; },
ArrowRight() { this.x += this.speed; },
},
update(keysPressed) {
Object.entries(this.actions)
.forEach(([key, action]) =>
keysPressed.has(key) && action.call(this)
)
;
},
};
(function update() {
requestAnimationFrame(update);
player.update(keysPressed);
player.render();
})();
.wrapper {
position: relative;
}
#player {
width: 40px;
height: 40px;
background: purple;
position: absolute;
}
<p>Use arrow keys to move</p>
<div class="wrapper">
<div id="player"></div>
</div>
If you want some sort of cooldown/retrigger period (for example, the player is holding down a "fire" key but a gun should not fire a new bullet on every frame), I suggest handling that in each entity rather than in the logic for the above key-handling code. The key logic is responsible for identifying which keys are being pressed and nothing else.
Note that keyboards have limits to how many keys they register as pressed at once.
setTimeout
and setInterval
are imprecise. Rely on requestAnimationFrame
as much as possible for games and animations. You can use a tick counter to determine elapsed time such that all entities in the game are synchronized to the same clock. Of course, much is application-dependent.
See also:
HTML5
WILL work for everybody.... – Orman