Creating a WebAssembly Game Using Quicksilver

In this chapter, you will see how Rust can be used to build a simple 2D game that can be compiled to run as a desktop app or as a web app. To run it as a web app, we will use the tools seen in the previous chapter to generate a WebAssembly (Wasm) application. As seen in that chapter, Wasm is a powerful new technology to run applications inside a browser. The appropriate tools translate Rust source code into a pseudo-machine language, named Wasm, that is loaded and run at top speed by browsers.

The Quicksilver open source framework will be described and used in this chapter. It has the powerful feature of being able to generate the following applications from a single source code:

  • A standalone graphical user interface (GUI) application, to be run in a desktop system such as Windows, macOS, or Linux
  • A Wasm app that runs in a JavaScript-enabled web browser

Quicksilver is oriented toward game programming, and so, as an example, we will develop an interactive graphical game using it: a slalom ski race, in which the player must drive a ski along a slope, entering the gates found along the ski run.

The following topics will be covered in this chapter:

  • Understanding the animation loop architecture
  • Building an animated application (ski) using the Quicksilver framework
  • Building a simple game using the Quicksilver framework (silent_slalom)
  • Adding text and sound to a game (assets_slalom)

Technical requirements

You need to read the section on Wasm of the previous chapter, but no other knowledge is required. To run the projects in this chapter, it is enough to install a Wasm code generator.

The complete source code for this chapter is in the Chapter06 folder of the repository, found at https://github.com/PacktPublishing/Creative-Projects-for-Rust-Programmers.

For macOS users, you may struggle to install coreaudio-sys. Upgrading the patch version of coreaudio-sys to 0.2.3 resolves this issue.

Project overview

In this chapter, we will see how to develop games to be run in modern web browsers, or in GUI windows.

For that purpose, we will first describe the typical architecture of any interactive game that is based on the animation loop concept.

Then, the Quicksilver crate will be introduced. This is a framework that allows us to create a graphical application based on an animation loop. It allows us to generate a Wasm executable to be run in a web browser, or a native executable to be run in a desktop environment.

The first project (ski) will be very simple: just a page containing one ski that can be rotated by pressing arrow keys. This project will show the general architecture of a game, how to draw on a page, and how to handle input.

The second project (silent_slalom) will add features to the first project, creating a complete—albeit very simple—game. However, it will not use loadable resources such as images, fonts, or sounds.

The third project (assets_slalom) will add features to the second project, loading a font and some recorded sounds, and showing how to display some text on the page, and how to play the loaded sound files.

Understanding the animation loop architecture

As described in the previous chapter, the typical architecture of interactive software is event-driven architecture. In such an architecture, the software just waits for input commands, and it responds to such commands when they arrive. Until any command arrives, the software does nothing.

This architecture is efficient and responsive for many kinds of applications, but it is not optimal for some other kinds of applications, such as the following:

  • Games with animations
  • Continuous-simulation software
  • Multimedia software
  • Some kind of educational software
  • Machine monitoring software (known as Human-Machine Interface (HMI) software)
  • Systems monitoring software (known as Supervisory Control and Data Acquisition (SCADA) software)

In such systems, the software has always something to do, as in the following examples:

  • In games with animations, such as sports games or combat games or racing games, both those against other human players and those against machine-simulated players, even if the user does nothing, the opponents move, and time flows; so, the screen must be constantly updated to show what the opponents have done, and what the current time is.
  • In continuous-simulation software, such as the graphical simulation of a car crash, the objects continue to move, even if you don't press any key; so, the screen must show the new positions of the objects at any time.
  • In multimedia software, such as software that reproduces an audio or video clip, the data continues to flow, until you pause or stop the reproduction.
  • There are many kinds of educational software, but some of them are just games with animations, continuous-simulation software, or multimedia software.
  • Most mechanical machines, to let a user monitor them, display on a screen a constantly updated representation of their internal status, even when the user does not request an update.
  • Many complex systems, such as industrial plants, office buildings, and—recently—also residential buildings, display on a screen a constantly updated representation of the status of the devices operating in the system.

Actually, such kinds of software can even be developed using an event-driven architecture. It is enough to use a specific widget known as a timer. A timer is a software component that triggers an event at a fixed time interval.

For example, in an electronic thermometer, there is a timer that executes a routine every minute. Such a routine reads the temperature from a sensor and displays the read value on the small screen.

For some kinds of applications, the use of an event-driven environment, possibly including one or more timers, is appropriate. For example, event-driven programming is optimal for business applications such as an accounting application. In such applications, the user screen is split into several input widgets, such as labels, buttons, and textboxes. In such software, no application code is run until the user clicks the mouse or presses a key. Such input events trigger the action.

However, event-driven programming is not quite appropriate for the kind of software that displays a scene that fills the window, with no widgets, and that always has some code running even if the user does not act on input devices.

For such software, the so-called animation loop architecture is more appropriate. Its simplest structure is the following one:

  1. First, a draw routine is defined as the one responsible for checking the status of the input devices and for redrawing the screen according to the status.
  2. Then, a screen area is defined as a scene, and an update rate is defined for it.
  3. When the program starts, it first opens a window (or a subwindow) for the scene, and then invokes the draw routine at regular intervals, using an internal timer.
  4. Such periodic invocations of the draw routine are usually named frames, and the invocation rate is measured in Frames Per Second (FPS).

The animation loop is sometimes named game-loop, as it is very often used for games. This is quite a misnomer, however, for the following two reasons:

  • There are several other kinds of apps that should use an animation loop, such as continuous-simulation software, industrial machine monitoring software, or multimedia software. So, an animation loop is not only for games.
  • There are some games that do not need an animation loop. For example, a chess game, a card game, or an adventure game, provided they are not based on animations, can be implemented perfectly well using an event-driven architecture. So, games are not necessarily based on animation loop.
Notice that, while in an event-driven architecture user input triggers the action, in an animation loop architecture some action happens anyway, but if there is some user input such actions change accordingly.

Consider a user who presses a keyboard key or a mouse button. In event-driven programming, that input operation sends exactly one command. Instead, in animation loop programming, the program, at any frame, checks whether any key is pressed. If the key is pressed for a very short time, it is possible that such an operation goes unnoticed as, when the keyboard is checked in one cycle, that key has not been pressed yet, and when the keyboard is checked in the next cycle, that key has been already released.

This is quite unusual, though. Typical frame rates are from 20 to 60 FPS, and so the corresponding intervals are from 50 to 16.7 milliseconds. It is very difficult to press a key for a shorter time than that. Instead, it is quite typical that a key-press is much longer than a frame, and so the key is seen pressed in several successive frames.

If you use such a key-press to insert text, you would want to allow the user to press a key to insert just one letter. If you use a mouse click to press a button on the screen, you want that screen button to be pressed just once. To avoid such multiple hits, you must disable input for a short time the first time you get it. This is quite a nuisance, and so, for typical widget-based GUI apps, event-driven programming is more appropriate.

Instead, animation loop programming is appropriate whenever a key-press must have an effect proportional to the duration of the press. For example, if the arrow keys are used to move a character on the screen, and if you keep the right arrow pressed for 1 second, that character moves by a short distance; while if you keep pressed that key for 2 seconds, that character moves double that distance. In general, a short press should change little, and a long press should change much.

Regarding the output, when using event-driven programming, the effect of the operation is usually shown by changing some property of a widget (such as changing the text contents in a textbox, or loading a bitmap in a picture box). After that change, the widget is capable of refreshing itself whenever it needs, using its internal state. The event that triggers refreshing is the invalidation of the screen portion containing the widget. For example, if another window overlaps our window, and then it moves away, the discovered portion of our window is invalidated, and so it must be refreshed.

This kind of graphic is named retained-mode, as there is an inner data structure that retains the information needed to refresh the screen when there is a need. Instead, when using animation loop programming, all the images must be regenerated at every frame, and so there is no need to wait for a specific event. This kind of graphic is named immediate-mode, as the drawing is performed immediately by application code when it must be seen.

In the previous chapter, we saw that for event-driven applications, the Model-View-Controller (MVC) architectural pattern allows you to give a better structure to your code. Also, for animation loop applications, there is a kind of MVC architectural pattern.

The Model is the data structure that contains all the variables that must persist between frames.
The Controller is a function that has input but no output. It checks the status of input devices (which keyboard keys are pressed; which mouse keys are pressed; where the mouse is; which are the values of possible other input channels), reads the fields of the model, and updates them.
The View is a function that has output but no input. It reads the fields of the model and draws on the screen according to the read values.

Here is how the Quicksilver framework implements this pattern.

The model is any data type, typically a struct, that must implement the State trait. Such a trait contains the following three functions:

  • fn new() -> Result<Screen>: This is the only way to create the model. It will return a valid model (if it can) or an error.
  • fn update(&mut self, window: &mut Window) -> Result<()>: This is the controller. It is invoked periodically by the framework. The window argument allows you to get some context information. In this framework, it is mutable, but in the proper implementation of the MVC pattern, it shouldn't be changed. Instead, self—that is, the model—is rightly mutable.
  • fn draw(&mut self, window: &mut Window) -> Result<()>: This is the view. It is invoked periodically by the framework. The self argument allows information to be obtained from the model. In this framework, it is mutable, but in the proper implementation of the MVC pattern, it shouldn't be changed. Instead, the window argument—that is, the output device—is rightly mutable.

Now, let's examine the first project in the repository using the Quicksilver framework.

Implementing the ski project

The first project we are going to see is quite simple. It just shows a geometric shape on the screen and it allows the user to rotate it using the arrow keys:

  1. To run it as a desktop app, go into the ski folder, and type the following command:
          cargo run --release
        

The --release argument is recommended to optimize the generated code. For this simple example, it is pointless, but in more complex examples, the code generated without specifying it is so inefficient that the resulting app is noticeably slowed down.

  1. After a few minutes of download and compilation, the following desktop window will appear:

It is just an 800 x 600-pixels white rectangle, with a small purple rectangle and a small indigo triangle on top of it. They represent a monoski with its pointed end, in a snowy ski slope.

  1. If you press the left or right arrow keys (/) on your keyboard, you will see the ski rotate around its tip.
  2. Now, close this window using the appropriate command in your windowing environment. Typically, you click on a cross icon in the caption bar or press the Alt + F4 key combination.
  3. Now, let's see another way to launch this application. Type the following command:
          cargo web start --release
        

We saw in the previous chapter that this command helps us to create a Wasm app and to launch a command-line program that serves it through the HTTP protocol.

At the end of the compilation, a server program starts and suggests the address where you can access the app. On your preferred browser, you can type this address: localhost:8000. Only modern 64-bit browsers support WebGL2. If this is not true in your case, then nothing happens; instead, if your browser supports this standard, you will see in the browser just the same graphics that before were shown in the desktop window.

This is possible as the Quicksilver framework, used by our app, has multi-target capability. When compiled for the Wasm target, it generates a web browser application; and when compiled for a central processing unit (CPU) target, it generates a desktop application.

This compile-time portability is very useful for debugging purposes. Actually, it is not easy to debug a Wasm application; but if you first debug the desktop application, a few bugs will remain in the Wasm version.

Understanding the code behind this

Now, let's see the code used to create such a project.

Before starting the project, a note about this is required. All the projects in this chapter show a monoski on a ski slope. There is a convention about the coordinates of the ski and other objects: the horizontal coordinate, usually named X, is actually named across; and the vertical coordinate, usually named Y, is actually named along.
So, the across speed is the speed of a movement from left to right (or vice versa, if negative), and the along speed is the speed of a movement from bottom to top (or vice versa, if negative).

First of all, the Cargo.toml file must contain the quicksilver = "0.3"dependency. Then, there is just a main.rs source file. It contains some constants, as shown in the following code snippet:

const SCREEN_WIDTH: f32 = 800.;
const SCREEN_HEIGHT: f32 = 600.;
const SKI_WIDTH: f32 = 10.;
const SKI_LENGTH: f32 = 50.;
const SKI_TIP_LEN: f32 = 20.;
const STEERING_SPEED: f32 = 3.5;
const MAX_ANGLE: f32 = 75.;

Let's look at what the terms suggest in this code, as follows:

  • SCREEN_WIDTH and SCREEN_HEIGHT are the size in pixels of the client area in the desktop window or the size of the canvas in the web page.
  • SKI_WIDTH, SKI_LENGTH, and SKI_TIP_LEN are the sizes of the ski.
  • STEERING_SPEED is the number of degrees by which the ski is rotated at every step. Steps have a frequency (that is, 25 per second), and so this constant represents an angular speed (3.5 degrees per step * 25 steps per second = 87.5 degrees per second).
  • MAX_ANGLE is a limit to rotational capability, both to the right and to the left, to ensure the ski is always downhill.

Then, there is the model of our MVC architecture, as shown in the following code snippet:

struct Screen {
ski_across_offset: f32,
direction: f32,
}

The meaning of these fields is as follows:

  • ski_across_offset represents the across displacement of the tip of the ski with respect to the center of the screen. Actually, in this project, it is always zero, as the tip of the ski never moves. It is a variable just because in future projects, it will change.
  • direction is the angle in degrees of the ski with respect to the downhill direction. It is initially zero but can vary from -75 to +75. It is the only portion of our model that can change.

The constructor of the model is quite simple, as illustrated in the following code snippet:

Ok(Screen {
ski_across_offset: 0.,
direction: 0.,
})

It simply initializes to zero both fields of the model. The body of the controller (the update function) is created with this code:

if window.keyboard()[Key::Right].is_down() {
self.steer(1.);
}
if window.keyboard()[Key::Left].is_down() {
self.steer(-1.);
}
Ok(())

The purpose of this routine is to steer the ski a bit to the right, if the right-arrow key is pressed, and a bit to the left if the left-arrow key is pressed.

The window.keyboard() expression gets a reference to the keyboard associated with the current window, and then the[Key::Right] expression gets a reference to the right-arrow key of such a keyboard. The is_down function returns true if the specified key is in a pressed state in this instant.

The steering is performed by the steer method, whose body consists of the following code:

self.direction += STEERING_SPEED * side;
if self.direction > MAX_ANGLE {
self.direction = MAX_ANGLE;
}
else if self.direction < -MAX_ANGLE {
self.direction = -MAX_ANGLE;
}

First, the value of the direction field of the model is incremented or decremented by the STEERING_SPEED constant. Then, it is ensured that the new value does not exceed the designed limits.

The view is more complex. It must redraw all the scene even if it has not changed at all. The first drawing operation is always to draw the white background, as follows:

window.clear(Color::WHITE)?;

Then, the rectangle is drawn, like this:

window.draw_ex(&Rectangle::new((
SCREEN_WIDTH / 2. + self.ski_across_offset - SKI_WIDTH / 2.,
SCREEN_HEIGHT * 15. / 16. - SKI_LENGTH / 2.),
(SKI_WIDTH, SKI_LENGTH)),
Background::Col(Color::PURPLE),
Transform::translate(Vector::new(0, - SKI_LENGTH / 2. - SKI_TIP_LEN)) *
Transform::rotate(self.direction) *
Transform::translate(Vector::new(0, SKI_LENGTH / 2.
+ SKI_TIP_LEN)),
0);

The draw_ex method is used to draw shapes. Its first argument is a reference to the shape to draw; in this case, it is Rectangle. Its second argument, in the fifth line, is the background color of the shape; in this case, it is PURPLE. Its third argument is a plane affine transformation matrix; in this case, it is a translation, followed by a rotation, followed by a translation. And its fourth argument, in the last line, is a Z elevation; its purpose is to give an overlapping order to shapes. Let's examine these arguments in more detail.

The Rectangle::new method receives two arguments. The first argument is a tuple made up of the x and y coordinates on the top-left vertex of the rectangle. The second argument is a tuple made up of the width and height of the rectangle. The origin of the coordinate system is the top left of the window, with the x coordinate that grows toward the right, and the y coordinate that grows downward.

In those formulas, the only variable is self.ski_across_offset, which represents the displacement of the ski to the right of the center of the window when positive, and to the left when negative. In this project, it is always zero, and so the ski's x coordinate is always at the center of the window. The vertical position is such that the center of the rectangle is near the bottom of the window, at 15/16 of the height of the window.

Rectangles are always created with their sides parallel to the sides of the window. To have a rotated angle, a geometric transformation must be applied. There are several elementary transformations that can be combined by multiplying them. To draw a shape in a translated position, a transformation is created using the Transform::translate method, which receives a Vector (not a Vec!) specifying the displacements along x and y. To draw a shape in a rotated position, a transformation is created using the Transform::rotate method, which receives an angle in degrees specifying the angle by which to rotate the shape.

The rotation is performed around the centroid of the shape, but we want to rotate around the tip of the ski. So, we need first to translate the rectangle so that its centroid is where the tip of the ski was, then rotate it around its centroid, and then translate it back to the original centroid. By multiplying the three transformations, a rotation around the tip of the ski is obtained. In the case of a rectangle, the centroid is just the center of the rectangle.

The last argument ofdraw_ex is a z coordinate. This is a 2D framework, and so no z coordinate would be required, but this coordinate allows us to specify the order of the appearance of the shapes. Actually, if two shapes overlap, and they have the same z coordinate, WebGL (used by Quicksilver) does not necessarily draw them in the order in which you have drawn them. The actual order is undefined. To specify that a shape must appear above another, it must have a larger z coordinate. It doesn't matter how much larger.

To draw the triangular-pointed end on top of the rectangle, a similar statement is executed. The Triangle::new method creates a Triangle shape, using three Vector variables as its vertices. To rotate it around its tip, we need to know the centroid of the triangle. With a bit of geometry, you can calculate that the centroid of that triangle is the point above the center of the base of the triangle by a distance equal to one-third of the height of the triangle.

By the end of the program, there is a main function that must initialize the app. The body of the function contains this:

run::<Screen>("Ski",
Vector::new(SCREEN_WIDTH, SCREEN_HEIGHT), Settings {
draw_rate: 40.,
update_rate: 40.,
..Settings::default()
}
);

This statement just runs the model, with some arguments. The first argument is the caption of the title bar, the second one is the size of the window, and the third one is a structure containing some optional settings.

The following two settings are specified here:

  • draw_rate: This is the interval in milliseconds between each successive invocation of the draw function
  • update_rate: This is the interval in milliseconds between each successive invocation of the update function

This project was quite trivial, but it showed many concepts that will be used in the other projects of this chapter.

Implementing the silent_slalom project

The previous project just showed a ski on a ski slope. In this section, we will show a possibly amusing game using a ski—a slalom. For simplicity, no text is displayed and no sound effects are played in this project. Its source code is contained in the silent_slalom folder.

After compiling and running its desktop version, a window similar to this will appear to you:

In addition to the ski, some blue dots are drawn. There are four dots in the middle of the window, and two half dots that come out at the top border. Each pair of blue dots is the poles of a slalom gate. The purpose of the game is to make the ski pass through each of the gates. Now, you can see just three gates, but the course contains seven intermediate gates, plus the finish gate. The remaining five gates will appear when the ski proceeds along the slope.

The actual position of the poles will be different in your case because their horizontal (across) position is generated at random. If you stop and relaunch the program, you will see other poles' positions. The size of the gates—that is, the distance between the two poles of any gate—is kept constant, though; and also, the distance, along the y coordinate, between any gate and the gate following it is constant.

To start the game, press the spacebar. The blue dots will begin to move slowly downward, giving the impression of the ski going forward. By rotating the ski, you change its direction, and you should try to ensure that its tip passes between the poles of every gate.

The finish gate is distinguished by having green poles instead of blue. If you pass through it, the game finishes, showing a window similar to this:

You can restart the game by pressing the R key. If you fail to pass a gate correctly, the game stops and ends. You can restart it by pressing the R key.

Of course, this project has something in common with the previous project. Let's see the differences within it.

The first difference is the insertion into the Cargo.toml file of the rand = "0.6" dependency. The gates are positioned at a random x position, and so the random number generator contained in this crate is required.

Then, the following constants are defined:

const N_GATES_IN_SCREEN: usize = 3;
const GATE_POLE_RADIUS: f32 = 4.;
const GATE_WIDTH: f32 = 150.;
const SKI_MARGIN: f32 = 12.;
const ALONG_ACCELERATION: f32 = 0.06;
const DRAG_FACTOR: f32 = 0.02;
const TOTAL_N_GATES: usize = 8;

Let's have a look at these constants in detail, as follows:

  • N_GATES_IN_SCREEN is the number of gates that will appear in the window at once. The along separation between successive gates is the window height divided by this number. Therefore, this number must be positive.
  • GATE_POLE_RADIUS is the radius in pixels of each circle drawn to represent a pole.
  • GATE_WIDTH is the distance in pixels between the centers of the poles in each gate. This number must be positive.
  • SKI_MARGIN is the distance in pixels between the leftmost position that can be reached by the tip of the ski to the left border of the window, and between the rightmost position that can be reached by the tip of the ski to the right border of the window.
  • ALONG_ACCELERATION is the acceleration, in pixels per frame for each frame, for the movement of the ski, due to the slope, when the ski is in downhill position—that is, vertical. For example, for an acceleration value of 0.06 and an update rate of 40 milliseconds, or 25 frames per second, in a second the speed would go from zero to 0.06 * 25 = 1.5 pixels per frame—that is, a speed of 1.5 * 25 = 37.5 pixels per second. The actual acceleration will be lower if the ski has an inclination with respect to the slope.
  • DRAG_FACTOR represents the deceleration caused by air friction. The actual deceleration is this factor multiplied by the module of the speed.
  • TOTAL_N_GATES is the number of gates, including the finish gate.

While in the previous project you could do just one thing all the time—that is, rotate the ski—in this project, you can do different things according to the current situation. So, there is a need to distinguish among four possible states, as follows:

enum Mode {
Ready,
Running,
Finished,
Failed,
}

The initial mode is Ready, when you are eager to start the run, at the top of the slope. After the start command, you are in Runningmode, until you complete the run correctly, ending in Finishedmode, or get out of a gate, ending in Failed mode.

Some fields have been added to the model of the application, to track some other state information, as illustrated in the following code block:

gates: Vec<(f32, f32)>,
forward_speed: f32,
gates_along_offset: f32,
mode: Mode,
entered_gate: bool,
disappeared_gates: usize,

The meaning of these fields is described as follows:

  • gates is a list of the along positions of the poles. For them, the origin is the center of the window.
  • forward_speed is the module of the velocity in pixels per frame.
  • gates_along_offset is the Y translation of all the shown gates toward the bottom, which represents the advancement of the ski. It is a number between zero and the along spacing between successive gates.
  • mode is the state described previously.
  • entered_gate indicates whether the tip of the ski has already entered the lowest gate shown in the window. This flag is initialized as false; it becomes true when the ski passes a gate correctly and becomes false again when that gate exits the window from the bottom because now it refers to the next gate.
  • disappeared_gates counts the gates exited from the window. Of course, it is initialized at zero and is incremented every time a gate exits the window.

A function added to the Screen type generates a random gate, as illustrated in the following code block:

fn get_random_gate(gate_is_at_right: bool) -> (f32, f32) {
let mut rng = thread_rng();
let pole_pos = rng.gen_range(-GATE_WIDTH / 2., SCREEN_WIDTH / 2. -
GATE_WIDTH * 1.5);
if gate_is_at_right {
(pole_pos, pole_pos + GATE_WIDTH)
} else {
(-pole_pos - GATE_WIDTH, -pole_pos)
}
}

This function receives the gate_is_at_right flag, which indicates in which part of the slope the generated gate will be. If such an argument is true, the new gate will be at the right of the center of the window; otherwise, it will be at the left of the center of the window. This function creates a random number generator and uses it to generate a reasonable position for a pole. The other pole position is computed using the argument of the function and the fixed gate size (GATE_WIDTH).

Another utility function is deg_to_rad, which converts angles from degrees to radians. It is needed because Quicksilver uses degrees, but trigonometric functions use radians. The new method creates all the gates, alternating them at right and at left, and initializes the model. The update function does a lot more than the function with that name seen in the previous project. Let's look at the following code snippet:

match self.mode {
Mode::Ready => {
if window.keyboard()[Key::Space].is_down() {
self.mode = Mode::Running;
}
}

According to the current mode, different operations are performed. If the mode is Ready, it checks whether the spacebar key is pressed, and, in such a case, it sets the current mode to Running. This means that it starts the race. If the mode is Running, the following code is executed:

Mode::Running => {
let angle = deg_to_rad(self.direction);
self.forward_speed +=
ALONG_ACCELERATION * angle.cos() - DRAG_FACTOR
* self.forward_speed;
let along_speed = self.forward_speed * angle.cos();
self.ski_across_offset += self.forward_speed * angle.sin();

In this mode, a lot of things are computed. First, the ski direction is converted from degrees to radians.

Then, the forward speed is incremented because of the slope, and it is decremented because of the friction of the air, which is proportional to the speed itself. The net effect is that the speed will tend to a maximum value. In addition, the more the ski direction is rotated with respect to the slope, the slower it is. This effect is implemented using the cos cosine trigonometric function.

Then, the forward speed is split into its components: the along speed, which causes the downward movement of the poles, and the across speed, which increments the across ski offset. They are computed by applying, respectively, the cos and sin trigonometric functions to the forward speed, as shown in the following code snippet:

if self.ski_across_offset < -SCREEN_WIDTH / 2. + SKI_MARGIN {
self.ski_across_offset = -SCREEN_WIDTH / 2. + SKI_MARGIN;
}
if self.ski_across_offset > SCREEN_WIDTH / 2. - SKI_MARGIN {
self.ski_across_offset = SCREEN_WIDTH / 2. - SKI_MARGIN;
}

Then, it checks that the ski position is not too far to the left or to the right, and, if it is so, it is kept within the defined margins, as illustrated in the following code snippet:

self.gates_along_offset += along_speed;
let max_gates_along_offset = SCREEN_HEIGHT / N_GATES_IN_SCREEN as f32;
if self.gates_along_offset > max_gates_along_offset {
self.gates_along_offset -= max_gates_along_offset;
self.disappeared_gates += 1;
}

The new along speed is used to move down the gates, by incrementing the gates_along_offset field. If its new value is larger than the distance between successive gates, one gate is dropped out of the bottom of the window, and all the gates are moved backward by one step and the number of disappeared gates is incremented, as illustrated in the following code snippet:

let ski_tip_along = SCREEN_HEIGHT * 15. / 16. - SKI_LENGTH / 2. - SKI_TIP_LEN;
let ski_tip_across = SCREEN_WIDTH / 2. + self.ski_across_offset;
let n_next_gate = self.disappeared_gates;
let next_gate = &self.gates[n_next_gate];
let left_pole_offset = SCREEN_WIDTH / 2. + next_gate.0 + GATE_POLE_RADIUS;
let right_pole_offset = SCREEN_WIDTH / 2. + next_gate.1 - GATE_POLE_RADIUS;
let next_gate_along = self.gates_along_offset + SCREEN_HEIGHT
- SCREEN_HEIGHT / N_GATES_IN_SCREEN as f32;

Then, the two coordinates of the tip of the ski are computed: ski_tip_along is the constant y coordinate, from the top of the window, and ski_tip_across is the variable x coordinate, from the center of the window.

Then, the positions inside the next gate are computed: left_pole_offset is the x position of the right side of the left pole, and right_pole_offset is the x position of the left side of the right pole. These coordinates are computed from the left border of the window. And then, next_gate_along is the y position of such points, as illustrated in the following code snippet:

if ski_tip_along <= next_gate_along {
if !self.entered_gate {
if ski_tip_across < left_pole_offset ||
ski_tip_across > right_pole_offset {
self.mode = Mode::Failed;
} else if self.disappeared_gates == TOTAL_N_GATES - 1 {
self.mode = Mode::Finished;
}
self.entered_gate = true;
}
} else {
self.entered_gate = false;
}

If the y coordinate of the tip of the ski (ski_tip_along) is less than that of the gate (next_gate_along), then we can say that the tip of the ski has passed to the next gate. Though, if the entered_gate field, which records such passing, is still false, we can say that in the previous frame the ski hadn't yet passed the gate. Therefore, in such a case, we are in the situation in which the ski has just passed a gate. So, we must check whether the gate has been passed correctly or wrongly.

If the x coordinate of the tip is not between the two coordinates of the poles, we are outside the gate, and so we go into the Failed mode. Otherwise, we must check whether this gate is the last gate of the course—that is, the finish gate. If it is the case, we go into the Finish mode; otherwise, we make a note that we have entered the gate, to avoid checking it again at the next frame, and the race goes on.

If the y coordinate is such that we haven't reached the next gate yet, we take note that entered_gate is still false. With this, we have completed the computations for the Running case.

Two modes remain to be considered, as illustrated in the following code snippet:

Mode::Failed | Mode::Finished => {
if window.keyboard()[Key::R].is_down() {
*self = Screen::new().unwrap();
}
}

Both in the Failed mode and in the Finished mode, the R key is checked. If it is pressed, the model is reinitialized, going to the same state as when the game was just launched.

Lastly, the steering key is checked for any mode, just as in the previous project. Regarding the draw function, what has been added in this project, with respect to the previous project, is the drawing of the poles. The code can be seen in the following snippet:

for i_gate in self.disappeared_gates..self.disappeared_gates + N_GATES_IN_SCREEN {
if i_gate >= TOTAL_N_GATES {
break;
}

A loop scans the gates that appear in the window. The indices of the gates go from zero to TOTAL_N_GATES, but we must ski the ones that have already exited from the bottom, whose number is self.disappeared_gates. We must show at least the N_GATES_IN_SCREEN gates and must stop at the last gate.

To show the player which is the finish gate, it has a different color, as can be seen in the following code snippet:

let pole_color = Background::Col(if i_gate == TOTAL_N_GATES - 1 {
Color::GREEN
} else {
Color::BLUE
});

The last gate is green. To compute the y coordinate of the poles of a gate, the following formula is used:

let gates_along_pos = self.gates_along_offset
+ SCREEN_HEIGHT / N_GATES_IN_SCREEN as f32
* (self.disappeared_gates + N_GATES_IN_SCREEN - 1 - i_gate) as f32;

It adds the position of the ski between two successive gates (gates_along_offset) to the initial position of the first three gates.

And then, two small circles are drawn for each gate. The left circle is drawn by executing the following statement:

window.draw(
&Circle::new(
(SCREEN_WIDTH / 2. + gate.0, gates_along_pos),
GATE_POLE_RADIUS,
),
pole_color,
);

The argument of the Circle constructor is a tuple composed of the x and y coordinates of the center and the radius. Here, the draw method of the window object is used, instead of the draw_ex method. It is simpler, as it does not require a transformation nor a z coordinate.

And so, we have examined all the code of this project. In the next project, we'll show how we can add text and sound to our game.

Implementing the assets_slalom project

The previous project built was a valid slalom race, but that game had no sound or text to explain what was happening. This project, contained in the assets_slalom folder, just adds sound and text to the game of the previous project.

Here is a screenshot that was taken during a race:

In the top left of the window, there is the following information:

  • Elapsed time: This tells us how many seconds or hundreds of seconds have elapsed since the start of the current race.
  • Speed: This tells us how much is the current forward speed in pixels per second.
  • Remaining gates: This tells us how many gates remain to pass.

Then, a help message explains which commands are available.

In addition, four sounds have been added, as follows:

  • A tick at any start of a race
  • A whoosh at any turn
  • A bump at any fail
  • A chime at any finish

You have to run the game to hear them. Notice that not all web browsers are equally capable of reproducing sounds.

Now, let's see how Quicksilver can show text and play sounds. Sounds and text are not so simple to use because of the fact that they need files; for text, one or more font files are needed; and for sounds, a sound file for any sound effect is needed. Such files must be stored in a folder named static in the root of the project. If you look in the said folder, you'll find the following files:

  • font.ttf: This is a font in TrueType format.
  • click.ogg: This is a short click sound, to be played at the start of a race.
  • whoosh.ogg: This is a short friction sound, to be played when the ski is turning during a race.
  • bump.ogg: This is a bump sound to express disapproval, to be played when the ski misses a gate.
  • two_notes.ogg: This is a pair of notes to express satisfaction, to be played when the ski passes the finish gate.

Such a static folder and its contained files must be deployed together with the executable program, as they are loaded at runtime by the program. They are usually also named assets as they are just data, not executable code.

Quicksilver has chosen to load such assets in an asynchronous way, using thefutureconcept.To load a sound from a file, the Sound::load(«filename») expression is used. It receives a value implementing a reference to a path, such as a string, and it returns an object implementing the Future trait.

An asset—that is, an object that encapsulates a future that is loading a file—is created by the Asset::new(«future value») expression. It receives a value implementing a future, and it returns an Asset instance of the specific type. For instance, the Asset::new(Sound::load("bump.ogg")) expression returns a value of the Asset<Sound> type. Such a value is an asset that encapsulates a future—that is, reading a sound from the bump.ogg file. The sounds in this project are in the .ogg format, but Quicksilver is capable of reading several audio formats.

Once you have an asset encapsulating a future loading a file, you can access such a file in an expression such as sound_future.execute(|sound_resource| sound_resource.play()). Here, the sound_future variable is our asset. As it is a future, you have to wait for it to be ready. This is done using the execute method of the Asset type. It invokes the closure received as an argument, passing to it the encapsulated resource, which in this case is of the Sound type.

The Sound type has the play method, which starts to reproduce the sound. As usual in multimedia systems, such reproduction is asynchronous: you don't have to wait for the end of the sound to proceed with the game. If you call play on a sound when the previous sound is still reproducing, the two sounds overlap, and if you play many of them, the resulting volume typically becomes very high. Therefore, you should keep your sounds very short, or play them seldom.

Similarly, the Asset::new(Font::load("font.ttf")) expression returns a value of the Asset<Font>. type. Such a value is an asset that encapsulates a future—that is, reading a font from the font.ttf file. You can use that font with the font_future.execute(|font_resource| image = font_resource.render(&"Hello", &style)) expression. Here, the font_future variable is our asset. As it is a future, you have to wait for it using the execute method of the Asset type, which invokes the closure received as an argument, passing to it the encapsulated resource, which in this case is of the Font type.

The Font type has the render method, which receives a string and a reference to a FontStyle value and creates an image containing that text, printed using that font and that font style.

Analyzing the code

And now, let's see all the code of the project that differs from the previous project. There is a new constant, as can be seen in the following code snippet:

const MIN_TIME_DURATION: f64 = 0.1;

This is to solve the following problem. If the game has a frame rate of 50 FPS, the window is redrawn 50 times per second, and each time using the latest values of the variables. Regarding time, it is a number that would change so rapidly that it would be impossible to read. Therefore, this constant sets the maximum rate of change of the displayed time.

The model has several new fields, as can be seen in the following code snippet:

elapsed_sec: f64,
elapsed_shown_sec: f64,
font_style: FontStyle,
font: Asset<Font>,
whoosh_sound: Asset<Sound>,
bump_sound: Asset<Sound>,
click_sound: Asset<Sound>,
two_notes_sound: Asset<Sound>,

The meaning of these fields is described as follows:

  • elapsed_sec is the fractional number of seconds elapsed since the start of the current race, using the maximum resolution available.
  • elapsed_shown_secis the fractional number to show to the user as the number of elapsed seconds since the start of the current race.
  • font_style contains the size and color of the text to print.
  • font is the future value of the font to use to print the text of the screen.
  • whoosh_soundis the future value of the sound to play during the turns of the running ski.
  • bump_soundis the future value of the sound to play when a gate is missed.
  • click_soundis the future value of the sound to play when a race is started.
  • two_notes_soundis the future value of the sound to play when the finish gate is crossed.

A routine to play sounds has been defined, as follows:

fn play_sound(sound: &mut Asset<Sound>, volume: f32) {
let _ = sound.execute(|sound| {
sound.set_volume(volume);
let _ = sound.play();
Ok(())
});
}

It receives a future value of a sound and a volume. It calls execute to ensure the sound is loaded, and then sets the specified volume and plays that sound. Notice that the execute method returns a Result, to allow for possible errors. As in games sounds are not essential, we want to ignore possible errors regarding sounds, and so, we always return Ok(()).

In the steer function, when a turn operation is performed and the ski is not already at an extreme angle, the following statement is performed:

play_sound(&mut self.whoosh_sound, self.forward_speed * 0.1);

It plays the whoosh sound and a volume that is proportional to the speed of the ski. In this way, if you rotate the ski when you are not running, you are silent.

The new fields of the model are initialized like this:

elapsed_sec: 0.,
elapsed_shown_sec: 0.,
font_style: FontStyle::new(16.0, Color::BLACK),
font: Asset::new(Font::load("font.ttf")),
whoosh_sound: Asset::new(Sound::load("whoosh.ogg")),
bump_sound: Asset::new(Sound::load("bump.ogg")),
click_sound: Asset::new(Sound::load("click.ogg")),
two_notes_sound: Asset::new(Sound::load("two_notes.ogg")),

Notice that, as font_style, a size of 16 points and a black color are set. We already described the other kind of expressions.

In the update function, when the race is started by pressing the spacebar, the following statement is executed:

play_sound(&mut self.click_sound, 1.)

It plays a click sound with a normal volume. When running, the elapsed time is computed like this:

self.elapsed_sec += window.update_rate() / 1000.;
if self.elapsed_sec - self.elapsed_shown_sec >= MIN_TIME_DURATION {
self.elapsed_shown_sec = self.elapsed_sec;
}

The update_rate function actually returns the time between frames, in milliseconds. So, if you divide it by 1,000, you get the seconds between each frame.

If there is a high frame rate, such as 25 frames per second or more, showing the user different text at any frame can be confusing, as people cannot read a text that changes so rapidly. So, the second statement in the previous code snippet shows a technique to update the text at a lower rate. The elapsed_shown_sec field keeps the time of the last update, and the elapsed_sec field keeps the current time.

The MIN_TIME_DURATION constant keeps the minimum duration by which a text must remain unchanged on screen before it can be updated. So, if the time elapsed from the time of the previous update to the current time is larger than such minimum duration, the text can be updated. In this particular case, the text to update is just the elapsed time in seconds, and so, if enough time has passed, the elapsed_shown_sec field is set to the current time. The draw routine will use that value to print the elapsed time on the screen.

Two other sounds are emitted. When the mode becomes Failed, the play_sound is called to play a bump sound. And when the mode becomes Finished, the play_sound is called to play a chime.

Then, it's up to the draw routine to print all the text. First, the text is formatted in a new multi-line string, as follows:

let elapsed_shown_text = format!(
"Elapsed time: {:.2} s,
Speed: {:.2} pixel/s,
Remaining gates: {}
Use Left and Right arrow keys to change direction.
{}",
self.elapsed_shown_sec,
self.forward_speed * 1000f32 / window.update_rate() as f32,
TOTAL_N_GATES - self.disappeared_gates - if self.entered_gate { 1 } else { 0 },
match self.mode {
Mode::Ready => "Press Space to start.",
Mode::Running => "",
Mode::Finished => "Finished: Press R to reset.",
Mode::Failed => "Failed: Press R to reset.",
}
);

The elapsed time and the speed are printed using two decimals; the remaining gates are computed by subtracting the disappeared gates to the total number of gates. In addition, if the current gate has been entered, the count of remaining gates is decremented by one. Then, some different words are printed according to the current mode.

After having prepared the multiline string, the string is printed on a new image and stored in the image local variable, and the image is drawn on the window using the draw method, as a textured background. The method receives as a first argument the rectangular area to print, large as the whole bitmap, and, as a second argument, the Img variant of the Background type, constructed using the image, as illustrated in the following code snippet:

let style = self.font_style;
self.font.execute(|font| {
let image = font.render(&elapsed_shown_text, &style).unwrap();
window.draw(&image.area(), Img(&image));
Ok(())
})?;

So, we have completed our examination of this simple but interesting framework.

Summary

We have seen how a complete game, running both on desktop and on the web, can be built using Rust and the Quicksilver framework, with the web version using the cargo-web command and the Wasm code generator. This game was structured according to the animation loop architecture and the MVC architectural pattern. We created three apps—ski, silent_slalom, and assets_slalom—and understood the implementation behind them.

In the next chapter, we will be seeing another 2D game framework, the ggez framework, oriented toward desktop applications.

Questions

  1. What is the animation loop, and what are its advantages with respect to an event-driven architecture?
  2. When is an event-driven architecture better than an animation loop architecture?
  3. Which kinds of software can use the animation loop?
  4. How can you draw triangles, rectangles, and circles using Quicksilver?
  5. How can you receive input from the keyboard using Quicksilver?
  6. How are the controller and the view of MVC implemented using Quicksilver?
  7. How can you vary the frame rate of animation using Quicksilver?
  8. How can you load assets from files using Quicksilver, and where should you keep such assets?
  9. How can you play sounds using Quicksilver?
  10. How can you draw text on the screen using Quicksilver?

Further reading

The Quicksilver project can be downloaded from here: https://github.com/ryanisaacg/quicksilver. This repository contains a link to a very short tutorial and some examples.

You can find more information about generating Wasm code from a Rust project athttps://github.com/koute/cargo-web.

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

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