Architecting a Game

Now let’s move beyond simple animations and start adding interactivity. In this section, you’ll put together an entire game using RxJS. I’ve already built out just enough for you to start plugging things together with observables. Don’t hesitate to read through these prebuilt sections, but understanding them is not required for the neat, new RxJS tricks you’ll learn through the rest of the chapter.

Before anything else, let’s talk about why you’d want to use RxJS in such a game. The skeleton of this project has been created for you in rxfighter, with the complete project available to read in rxfighter-complete. As always, I recommend that you build your own project before you peek into the code for the completed one. We can totally take a peek at what the finished project should look like—it’s important to know what you’re going for.

images/rxfighter.png

Drawing to a Canvas Element

The HTML5 standard introduced the <canvas> element as a way to more easily create interactive experiences in the browser. It was designed as a native replacement for Flash, avoiding the many security issues present while staying right in the core of the browser. Canvas exposes a set of APIs that allow you to programmatically draw to the page.

 let​ canvas = <HTMLCanvasElement>document.querySelector(​'canvas'​);
export​ ​let​ ctx = canvas.getContext(​'2d'​);
canvas.width = config.canvas.width;
 canvas.height = config.canvas.height;

The first thing to do is to grab the canvas element off of the page and get the context from it. The context object is the tool we’ll use to interact with the page.

There’s a quirk in canvas where the height and width of the element can be out of sync with the values stored on the JavaScript side, leading to odd stretching or compression of the values you write to the page. With that in mind, it’s important to sync those values right at the start to avoid errors.

Now everything’s set up and you can use the ctx to start drawing things to the canvas. Everything drawn to a canvas stays there until something else is drawn over it. You can’t have old frames lingering around, so the first thing to write up is a function that clears the canvas by drawing over the entire thing.

 function​ clearCanvas() {
  ctx.fillStyle = ​'#000'​;
  ctx.fillRect(0, 0, canvas.width, canvas.height);
 }

There are two steps here. First, we tell the context we want the fill to be black (you can set it using hex or RGB, just like CSS). Secondly, we tell it to draw a rectangle, starting at 0, 0 (the upper left coordinate) and extend the entire size of the canvas. Add a call to clearCanvas and you should see a black square appear on the page. First step down! Now, on to interactivity and animations.

The RxJS Backbone

For a game to work, every frame, we need to go through every item in the game, make any changes to the item’s state, and then render everything on the page. RxJS lets us declaratively state each step through custom operators and tap. Our custom operators take in the entire observable, so each item has the complete freedom to do whatever it needs to along the way through the entire RxJS API. Once the updates are done, tap lets us call the render functions safely, ensuring that errant code won’t accidentally update the state. Open index.ts and fill in the following, importing as needed:

 let​ gameState = ​new​ GameState();
 
 interval(17, animationFrame)
 .pipe(
  mapTo(gameState),
  tap(clearCanvas)
 )
 .subscribe((newGameState: GameState) => {
  Object.assign(gameState, newGameState);
 });

This backbone starts off by creating a new instance of the game state. The game loop is created using interval and the animationFrame scheduler you learned about earlier. The first operator, mapTo throws away the interval’s increasing integer and passes on the current state to the rest of the observable chain. The mapTo is followed by a tap call to the canvas-clearing function you wrote earlier. Finally, a subscription kicks off the next cycle, saving the new game state for the next frame.

Joe asks:
Joe asks:
Why Use Object.assign Instead of Just Assigning?

There’s a little quirk here with mapTo. It takes a variable, ignores any input, and passes on the passed-in variable. More importantly, it takes a reference to that variable. If the subscribe ran gameState = newGameState;, then gameState would contain the new state, but the old reference (which is what mapTo is looking at) would contain stale data. Instead, this uses Object.assign to update the old reference with new data.

The rest of this game follows a pattern for each item (or collection of items). Each file exports two functions: a custom operator that takes the game state and manipulates the objects contained therein. The second is passed in to tap and contains the logic for rendering those objects to the page.

This backbone also allows us to easily see and change the order things are rendered in. Canvas being a “last write wins” world, it’s key to ensure that the background is drawn before the player. Now that the backbone has been established, it’s time to talk about how to manage state manipulation through the lifetime of this game.

Managing Game State

Keeping a game’s state in sync with the variety of different events that can happen at any point in the game is quite the challenge. Add the requirement that it all needs to render smoothly at sixty frames per second, and life gets very difficult. Let’s take a few lessons from the ngrx section in Chapter 8, Advanced Angular and add in a canvas flavor to ensure a consistent gameplay experience.

First, open gameState.ts and take a look at the initial game state. We’re using a class so that resetting the game state is as easy as new GameState(). Like ngrx, this is a centralized state object that represents the state of everything in the game. Unlike ngrx, we’ll ditch the reducers and instead rely on pure RxJS as the backbone of our state management. We’ll do this by splitting the game into individual units of content (the player, background, enemy) and centralizing each unit of content into two parts: the update step and the render step. Let’s start with something simple—the background.

Shooting Stars

The background consists of 100 stars of various sizes moving around at different paces. The star object itself contains its current location, as well as its velocity. Open up stars.ts and you see two functions: updateStars and renderStars. Both of these functions are called once per frame. updateStars is passed into the first half of the backbone, with renderStars passed into the tap operator in the second half.

Each star needs to move down the canvas every frame. With our system, that means updating the x coordinate of the star. If the star has moved past the bottom of the screen, we reposition it to a random point back at the top of the screen:

 export​ ​function​ updateStars(gameState$: Observable<GameState>) {
 return​ gameState$
  .pipe(
  map(state => {
  state.stars.forEach(star => {
  star.y += star.dy;
 if​ (star.y > config.canvas.height) {
  star.x = randInt(0, config.canvas.width);
  star.y = 0;
  }
  });
 return​ state;
  })
  );
 }

On Pure Functions

images/aside-icons/note.png

Part of knowing best practices is knowing when to break them. Technically, these functions that update the game state should create a new object every time we manipulate state to ensure each function is pure. However, creating new objects and arrays in JavaScript is expensive. If we want to push 60 frames per section, that means that we only have 16.7 milliseconds to update each frame. As a compromise, we reuse the same object, but keep state changes isolated as much as possible. This also means we avoid using array methods, such as map and filter, as they create a new array behind the scenes.

The render function also makes the same loop over the stars array, this time writing pixels to the canvas. fillStyle, which you saw before, is set to white, and then each star is drawn to its location:

 export​ ​function​ renderStars(state: GameState) {
  ctx.fillStyle = ​'#fff'​;
  state.stars.forEach(star => {
  ctx.fillRect(star.x, star.y, star.size, star.size);
  });
 }
Joe asks:
Joe asks:
Why don’t we just combine these into a single function?

While I’m always in favor of not doing two things when one thing suffices, separating the update from the render has two key advantages. First, the render and update code serve very different purposes. I’m a stickler about keeping state changes isolated whenever possible. Any state change has a single place, which saves time when debugging. Secondly, this allows us to control the ordering of update and rendering events independently of each other, allowing for a more flexible program.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.225.254.192