Command pattern for canvas games
Despite popular opinion, I still belive in the use of design patterns where applicable. I've been reading through Bob Nystroms Game Programming Patterns from 2014. The great thing about these sort of programming books is that they hold up really well. The patterns present are just as relevant today as they were in 2014.
I've been writing lots of small html canvas games recently, and each time have to write some key handling code. This is normally to move the character around the screen and to allow it to perform certain actions. Normally I handle the input directly inside the update code. Which is fine for small demos, but the Command pattern offers abstraction and the benefit of undo/redo functionality.
Working demo
Using the Command pattern
In the following code, I set up an abstract class called Command. This will be extended
by our custom commands and force each to implement an execute and undo method. For
purposes of the demo I have added a currentMove variable to demonstrate how undo/redo
may work in game. The currentMove value will track the index of the player's last executed
MoveCommand. Each time the arrow keys are pressed the generated MoveCommand will be added
to the commands array. In a real game the array will hold many different commands, for example
item pick up, actions etc...Each command will allow the encapsulation of logic. For this demo
the MoveCommand handles the player's movement logic.
Inside the game loop you can see that the handleInput method returns an instance of a Command.
Becuase the logic is encapsulated we don't need to worry about what type of Command is returned.
We just have to know to call execute and push the command to the list of previous moves. The
execute method stores the last position so that it can be restored when undo is called.
The advantage of this pattern, if it is not already clear, is that we have implemented a trivial way of applying undo and redo.
import { isKeyPressed, clearPressedKeys, addListeners } from "./keys.js";
interface Vec2 {
x: number;
y: number;
}
abstract class Command {
abstract execute(): void;
abstract undo(): void;
}
class MoveCommand extends Command {
private lastPos: Vec2;
constructor(
private pos: Vec2,
private dx: number,
private dy: number,
) {
super();
}
execute() {
this.lastPos = { ...this.pos };
this.pos.x += this.dx;
this.pos.y += this.dy;
}
undo() {
this.pos.x = this.lastPos.x;
this.pos.y = this.lastPos.y;
}
}
const player = {
x: 200,
y: 200,
w: 40,
h: 40,
spd: 20,
};
const history: Command[] = [];
let currentMove = 0;
function handleInput(): Command | null {
if (isKeyPressed("ArrowUp")) return new MoveCommand(player, 0, -player.spd);
if (isKeyPressed("ArrowDown")) return new MoveCommand(player, 0, player.spd);
if (isKeyPressed("ArrowLeft")) return new MoveCommand(player, -player.spd, 0);
if (isKeyPressed("ArrowRight")) return new MoveCommand(player, player.spd, 0);
return null;
}
(function () {
addListeners();
const canvas = document.createElement("canvas")!;
document.body.appendChild(canvas);
const ctx = canvas.getContext("2d")!;
const w = (canvas.width = 500);
const h = (canvas.height = 500);
function renderLoop() {
requestAnimationFrame(renderLoop);
const command = handleInput();
if (command) {
history.length = currentMove;
command.execute();
history.push(command);
currentMove++;
}
if (isKeyPressed("z") && currentMove > 0) {
currentMove--;
history[currentMove]!.undo();
}
if (isKeyPressed("r") && currentMove < history.length) {
history[currentMove]!.execute();
currentMove++;
}
draw(ctx);
clearPressedKeys();
}
renderLoop();
})();
function draw(ctx: CanvasRenderingContext2D) {
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
ctx.fillStyle = "#222";
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
ctx.fillStyle = "dodgerblue";
ctx.fillRect(player.x - 20, player.y - 20, 40, 40);
ctx.fillStyle = "white";
ctx.font = "16px sans-serif";
ctx.fillText("Arrow keys = move", 10, 25);
ctx.fillText("Z = undo, R = redo", 10, 50);
ctx.fillText(`Current Move: ${currentMove}`, 10, 75);
ctx.fillText(`Total Moves: ${history.length}`, 10, 100);
}
The key handling boilerplate
const keysHeld = new Set();
const keysPressed = new Set();
export function isKeyDown(key: string) {
return keysHeld.has(key);
}
export function isKeyPressed(key: string) {
return keysPressed.has(key);
}
export function clearPressedKeys() {
keysPressed.clear();
}
export function addListeners(anchor = window) {
anchor.addEventListener("keydown", ({ key }: KeyboardEvent) => {
if (!keysHeld.has(key)) keysHeld.add(key);
});
anchor.addEventListener("keyup", ({ key }: KeyboardEvent) => {
if (keysHeld.has(key)) {
keysHeld.delete(key);
keysPressed.add(key);
}
});
}