Skip to content

Latest commit

 

History

History
1373 lines (996 loc) · 39.1 KB

README.md

File metadata and controls

1373 lines (996 loc) · 39.1 KB

Writing a Rust Roguelike for Desktop and the Web

This is a template for a Roguelike written in Rust. You can build it for Windows, macOS, Linux and the Web (via WebAssembly).

If you want to dive right in, just clone this repo and run it. Or read on for a detailed guide.

Motivation

Rust is a pretty good language for writing roguelikes: it is fast, modern (modules, closures, fast collections, powerful macros, great enums) and it can build standalone executables for all the major platforms as well as the web!

The last bit is especially cool for game jams -- you don't have to worry about software packaging and distribution. Just give people a URL and they can play your game.

We're going to build a skeleton for a traditional ASCII roguelike. You can take it and turn it into a real game. The same codebase will work for all three the major platforms as well as the web. And it will support multiple fonts and text sizes so you can have your square maps and readable text at the same time!

It will look like this:

Final game in the web browser

You can try the web build here:

https://tomassedovic.github.io/quicksilver-roguelike/

Setup

Rust

You will need the 2018 edition of Rust to get started. Get it from the Rust website:

https://www.rust-lang.org/learn/get-started

You'll want rustup (it should be the default) -- we'll need it for the WebAssembly backend.

WebAssembly

WebAssembly is a new bytecode format supported in all modern desktop browsers. It's faster than JavaScript and various languages (including Rust) can be compiled into it.

The WebAssembly compilation target is not shipped by default, but you can add it like so:

$ rustup target add wasm32-unknown-unknown

You don't need this if you only care about the desktop.

cargo-web

cargo-web handles everything you need to ship your game on the web. Install it like so:

$ cargo install cargo-web

You don't need this if you only care about the desktop.

Repository

Create a new project like so:

$ cargo new --vcs git roguelike

This will initialise a new git repository as well! Remove --vcs git if you don't want that.

If you've got everything set up properly, you should be able to run the default program:

$ cd roguelike
$ cargo run --release

It will print:

$ cargo run --release
   Compiling roguelike v0.1.0 (/home/thomas/tmp/roguelike)
    Finished release [optimized] target(s) in 0.92s
     Running `target/release/roguelike`
Hello, world!

Quicksilver

Rust has several gamedev engines and frameworks. We are going to use Quicksilver because it lets you target both the desktop and web really easily!

Open your Cargo.toml in the root of your repository and add this at the end:

[dependencies]
# More features: "collisions", "complex_shapes", "immi_ui", "sounds", gamepads
quicksilver = { version = "0.3.22", default-features = false, features = ["fonts", "saving"]}

We're disabling most of the features. You don't have to do this, but some of these require libraries you might not have installed. The above should compile pretty much anywhere.

You can always add the sound or gamepad support later if you need it.

Run the program again to build the quicksilver dependency:

$ cargo run --release

This might take a couple of minutes. It should print out the same hello world message as before.

We'll be always building the optimised version in this guide. You can drop the --release flag but if you do, you're not allowed to make any speed measurements. Rust's debug builds are slower than you think. They're slower than unoptimised C++. They're slower than Ruby.

Hello, Game!

The first thing we'll do is create a window and print some text on it. We'll do all our coding in the src/main.rs file in the game repository. It's less than 300 lines total.

Empty Window

A Quicksilver app is all encapsulated in an item that implements the quicksilver::lifecycle::State trait:

use quicksilver::prelude::*;

struct Game;

impl State for Game {
    /// Load the assets and initialise the game
    fn new() -> Result<Self> {
        Ok(Self)
    }

    /// Process keyboard and mouse, update the game state
    fn update(&mut self, window: &mut Window) -> Result<()> {
        Ok(())
    }

    /// Draw stuff on the screen
    fn draw(&mut self, window: &mut Window) -> Result<()> {
        Ok(())
    }
}

Our game code will go into the three methods above.

To run the game add the above to main.rs and replace the main function with:

fn main() {
    let settings = Settings {
        ..Default::default()
    };
    run::<Game>("Quicksilver Roguelike", Vector::new(800, 600), settings);
}

The Settings struct lets us control various engine settings that we'll get to later. The 800 and 600 numbers represent the logical size of your window. Depending on your DPI settings, it might be bigger than that.

Imports

The first line where we use * from quicksilver::prelude called a wildcard import. It brings in every type in the quicksilver::prelude module. Without it, we would have to explicitly add everything we use such as Settings, Vector, State, etc.

Importing everything from a module is generally frowned upon in Rust. A crate can add more items later on and these might break your code when you switch to the newer version. So it's safer if you always declare exactly which types you use.

It also helps other people reading your code.

However, there are situations where you will always want to import certain types. In such cases, the crate authors might provide a so-called prelude module. It contains these common types and it is thought of as a more stable interface that you're expect to import everything from.

Even Rust's standard library does this. See the std::io::prelude or indeed the std::prelude which is imported automatically on top of every Rust file.

We will be relying on the prelude import in this guide. If you want to be explicit about it, these are the functions and types you need to bring in right now:

use quicksilver::{
    geom::Vector,
    lifecycle::{run, Settings, State, Window},
    Result,
};

The quicksilver::prelude module was added in version 0.3.10. If you're using an older version, either upgrade or use the explicit imports.

This guide will import more types later on. So if you're using them explicitly, you'll need to find them yourself. The Rust compiler can often suggest them. And when it can't, search for them in the Quicksilver documentation.

Running the game should now produce an empty window filled with black:

$ cargo run --release

Empty Window

Assets

You may have noticed the following message when running the code:

Warning: no asset directory found. Please place all your assets inside
a directory called 'static' so they can be loaded
Execution continuing, but any asset-not-found errors are likely due to
the lack of a 'static' directory.

Quicksilver expects all the game assets to be in the static directory under the project's root. We don't have one, hence the warning.

Let's create it:

$ mkdir static

Our repo should now look like this:

├── Cargo.lock
├── Cargo.toml
├── src
│   └── main.rs
├── static
└── target
    └── release

What goes into static? Sounds, images, models, maps and anything else your game will need to load. Including fonts.

Let's use mononoki, a beautiful little monospace font. Go to the mononoki website and download it.

It is an open source font created by Matthias Tellen distributed under the Open Font License 1.1.

Unpack it and copy the mononoki-Regular.ttf file into our new static directory.

Loading the font

All initial asset loading should happen in the State::new function. We will load the font file, use it to render text into an image and store it in our Game struct:

impl State for Game {
    /// Load the assets and initialise the game
    fn new() -> Result<Self> {
        let font_mononoki = "mononoki-Regular.ttf";

        let title = Asset::new(Font::load(font_mononoki).and_then(|font| {
            font.render("Quicksilver Roguelike", &FontStyle::new(72.0, Color::BLACK))
        }));

        let mononoki_font_info = Asset::new(Font::load(font_mononoki).and_then(|font| {
            font.render(
                "Mononoki font by Matthias Tellen, terms: SIL Open Font License 1.1",
                &FontStyle::new(20.0, Color::BLACK),
            )
        }));

        Ok(Self)
    }

    /// ... the rest of the `State` functions
}

We're rendering two bits of text: a heading 72 points big and the font's credits.

Thanks, Matthias!

Font::load returns a Future. It exits immediately and loads the actual file in the background. Calling and_then will let us manipulate the value (font) when it's ready.

Quicksilver does dynamic asset loading for us!

Note that, "render" here doesn't mean "draw onto the screen". font.render takes a text and a style (font size & colour) and creates an image we can draw later.

Font rendering takes a lot computational of work. It has to rasterise the glyphs, handle kerning, etc. Drawing an image is much faster. So we do all the hard work once, store the results and then just draw a static image later.

To make both of our images (title and the credits) available to the draw method, we'll store them in the Game struct:

struct Game {
    title: Asset<Image>,
    mononoki_font_info: Asset<Image>,
}

And return them from new:

Ok(Self {
    title,
    mononoki_font_info,
})

Drawing text

We wrote a bunch of code, but if we run the game, nothing changed. We need to draw the text on screen which happens in the draw function.

Since we rendered our text black, we're going to set the window's background to white to make it visible. Let's do that and draw the text:

fn draw(&mut self, window: &mut Window) -> Result<()> {
    window.clear(Color::WHITE)?;

    self.title.execute(|image| {
        window.draw(
            &image
                .area()
                .with_center((window.screen_size().x as i32 / 2, 40)),
            Img(&image),
        );
        Ok(())
    })?;

    self.mononoki_font_info.execute(|image| {
        window.draw(
            &image
                .area()
                .translate((2, window.screen_size().y as i32 - 60)),
            Img(&image),
        );
        Ok(())
    })?;

    Ok(())
}

window.clear(color) is quite straightforward, but what's the deal with this execute stuff?

We're not storing the images directly -- we're storing them in an Asset. An Asset wraps a Future that is, a value that might not actually exist yet (because the font did not finish loading).

To get to the Asset's inner value, we need to call execute and pass in a closure that operates on that asset (an Image in our case). If it's loaded, the closure will be called, if not, nothing will happen (but the program will keep going).

There's also a [Asset::execute_or] which can call a function if the loading did not complete yet.

Inside the closure we call window.draw which takes two parameters: a Drawable (an object that can be drawn: a Rectangle in our case) and a background.

There's also window.draw_ex with more options such as transformation to apply or z layer (what's on top of what).

The background can be an image (Background::Img), colour fill (Background::Col) or a combination of the two (Background::Blended).

We're drawing images so we use Img(&image).

This might seem backward: we've got an image, so why do we draw a rectangle and set the image as the background? That's how OpenGL and similar APIs work: you draw shapes and you either fill them with colour or a texture (our image).

We're calling Image::area() to get the Rectangle (position and size). It's positioned in the top-left corner however (x: 0, y: 0).

So we use .with_center to draw the title centered near the top of the screen and .translate to draw the message at the bottom.

"translate" is a fancy geometry word for moving stuff around. It adds the xs and ys together.

Let's run it:

$ cargo run --release

First text

ugh

Depending on your system's DPI settings, the text may either look fine or be the pixelated mess we see in the picture above.

Font rendering artefacts

If you see the artefacts (bear in mind that even if you don't, your users might so we should handle this), they're caused by a combination of two things:

  1. Window scaling due to DPI
  2. Quicksilver's default image scaling strategy (pixelate)

You can read more about DPI here:

https://docs.rs/glutin/0.19.0/glutin/dpi/index.html

If your system is configured for a DPI that's 1.3, the window size (with all its contents) will be scaled up to it. This is a very important accessibility feature and not handling it properly can make your program too small for people with bad eyesight or a "Retina display".

The problem here isn't the DPI itself, but how the image gets stretched.

By default, Quicksilver uses the Pixelate scale strategy which tries to preserve the individual pixels. This looks great at 2x, 3x etc. scales, but not so much at a 1.3x. Especially for text rendering.

We can switch to the Blur strategy instead. In main:

let settings = Settings {
    scale: quicksilver::graphics::ImageScaleStrategy::Blur,
    ..Default::default()
};

And now it looks blurry instead:

Blurry text

Not perfect, but it's easier on the eyes.

If you want to have a full control over your the window and text size, add this line at the beginning of your main function:

std::env::set_var("WINIT_HIDPI_FACTOR", "1.0");

That will force the DPI to be 1.0. Games are more sensitive to scaling than other application due to their pixel-based visual nature, so this can be OK. However you ought to provide a way of scaling the UI from within your game in that case! Ideally, defaulting to the system's DPI value.

Crisp text

We will still keep the Blur scaling strategy. Quicksilver's coordinates are floating point numbers and things like with_center can easily result in non-integer values. Again, these tend to look better with Blur.

Generating the game map

Time to draw the actual game. We need a map and later on, the player, some items and NPCs.

Tiles

The map consists of tiles that look like this:

#[derive(Clone, Debug, PartialEq)]
struct Tile {
    pos: Vector,
    glyph: char,
    color: Color,
}

The pos is a quicksilver::geom::Vector. Your typical two-dimensional struct with x & y fields.

You might consider defining your own types for position, size, etc. Vector uses f32 (you may prefer integers) and if you overload its meaning (e.g. using it for pixel as well as map tile coordinates), you can end up mixing them by accident.

A proper roguelike would use a procedural / random generation to build the map. We're just going to create an empty rectangle with # as the edges:

fn generate_map(size: Vector) -> Vec<Tile> {
    let width = size.x as usize;
    let height = size.y as usize;
    let mut map = Vec::with_capacity(width * height);
    for x in 0..width {
        for y in 0..height {
            let mut tile = Tile {
                pos: Vector::new(x as f32, y as f32),
                glyph: '.',
                color: Color::BLACK,
            };

            if x == 0 || x == width - 1 || y == 0 || y == height - 1 {
                tile.glyph = '#';
            };
            map.push(tile);
        }
    }
    map
}

We create a new Vec (Rust's growable array type -- not Quicksilver's Vector) and make it can hold all the tiles without reallocating.

If you know the size of a Vec in advance, call with_capacity instead of new. Pushing elements to it will be faster because it won't have to reallocate.

And then we create all the Tiles. The ones at the edge will be #, the rest is a ..

Entities

While the map is the static environment (walls, water, floor, etc.), entities are the interactive portions such as the player, items and NPCs.

The struct will have all the fields an entity might need:

#[derive(Clone, Debug, PartialEq)]
struct Entity {
    pos: Vector,
    glyph: char,
    color: Color,
    hp: i32,
    max_hp: i32,
}

Yep this meens food and doors would have hit points. That's not as weird as it might seem (think of fire destroying everything by lowering HP -- that can apply to any entity not just living things). If you've got zillions entities however, storing every field for every entity may be inefficient. Check out the entity-component-system pattern for an alternative.

Our generate_entities just returns a hardcoded list:

fn generate_entities() -> Vec<Entity> {
    vec![
        Entity {
            pos: Vector::new(9, 6),
            glyph: 'g',
            color: Color::RED,
            hp: 1,
            max_hp: 1,
        },
        Entity {
            pos: Vector::new(2, 4),
            glyph: 'g',
            color: Color::RED,
            hp: 1,
            max_hp: 1,
        },
        Entity {
            pos: Vector::new(7, 5),
            glyph: '%',
            color: Color::PURPLE,
            hp: 0,
            max_hp: 0,
        },
        Entity {
            pos: Vector::new(4, 8),
            glyph: '%',
            color: Color::PURPLE,
            hp: 0,
            max_hp: 0,
        },
    ]
}

Two Goblin NPCs and some food.

Let's add the tiles and entities to our Game struct:

struct Game {
    title: Asset<Image>,
    mononoki_font_info: Asset<Image>,
    map_size: Vector,
    map: Vec<Tile>,
    entities: Vec<Entity>,
}

We're adding the size of the map as well -- that will come in handy later.

Next, call both functions in Game::new:

let map_size = Vector::new(20, 15);
let map = generate_map(map_size);
let mut entities = generate_entities();

And make sure we actually return the new fields:

Ok(Self {
    title,
    mononoki_font_info,
    map_size,
    map,
    entities,
})

We need to add the player (represented, as always, by the @ symbol), too!

Having all the entities (monsters, items, NPCs, player, etc.) in one place (the entities Vec) is quite useful, but the player character is always a little special. We often need to access it directly to show its health bar, update it's position on key presses, etc.

So let's also store the player's index. That way we can look them up any time we want.

Put this in Game::new right after the generate_entities() call:

let player_id = entities.len();
entities.push(Entity {
    pos: Vector::new(5, 3),
    glyph: '@',
    color: Color::BLUE,
    hp: 3,
    max_hp: 5,
});

The player will have a blue colour and they will not be fully healed (so we can see write a nice two-colour health bar later).

Add player_id: usize to our Game definition:

struct Game {
    title: Asset<Image>,
    mononoki_font_info: Asset<Image>,
    map_size: Vector,
    map: Vec<Tile>,
    entities: Vec<Entity>,
    player_id: usize,
}

And return it at the end of new:

Ok(Self {
    ...
    player_id,
})

Building the tilemap

It's time to draw the map on the screen. We want to produce a grid of letters that's potentially changing every frame (as the characters move on the screen).

To do this we're going to build a spritesheet (also known as tileset or texture atlas). It is a single image that will contain all of our graphics. The graphical ones look something like this:

Tile set example by Daniel Schwen

Author: Daniel Schwen, license: CC SA 4.0

Ours is going to be built of letters not pictures, but the principle is the same. And if you want, you can replace it with actual graphics later.

This is one of the reasons we'll build an atlas rather than calling Font::render for each character or line on the map. We'd have to do it for images and this lets us swap them out (or support both) later. Plus it's more efficient.

Games usually build these atlases during development and then only ship the composite image. You only need to do it once, after all.

We're going to be a bit lazy and wasteful here, but you can (and probably should) do that in your build script instead.

One reason we do it in main.rs is that all our code is in one place. That makes this tutorial easier to follow. Not recommended for a bigger project.

First, we will list all the characters we're going to render. Put this in Game::new after our entity code:

let game_glyphs = "#@g.%";

These are the characters we're going to use. A bigger game will have more of these and you may want to generate them from your map and entities instead of hardcoding them like we do.

Then we let Quicksilver do its thing and render it into an Image just like before.

Drawing a part of an image is done via the subimage method. You give it a Rectangle and returns a new Image covering that portion and nothing else.

This will not clone the image's contents. Image contains a reference-counted pointer back to the source, so the operation is quick and doesn't take up a lot of memory.

We could either call subimage directly in our draw function, or we could generate a sub-image once for each glyph and then just reference those when drawing. We're going to do the latter and use a HashMap to get from a char to the corresponding Image.

There's a ton of other ways to do this. For example: create an image of all ASCII characters and then have all the subimages in a Vec<Image>. Each image's index would be its ASCII value. This would probably be faster, but it could waste a little more memory and you'll need to check that your char (a 32-bit Unicode value) can be converted to the right range. Also, what if you want to add some good-looking Chinese glyphs? You should measure and decide on trade-offs that suit your game.

We need to record the size of each tile (so we can pick it out of the tileset). Our font is twice as tall as it is wide, so 24x12 pixels should do nicely:

let tile_size_px = Vector::new(12, 24);

And build the tileset:

let tileset = Asset::new(Font::load(font_mononoki).and_then(move |text| {
    let tiles = text
        .render(game_glyphs, &FontStyle::new(tile_size_px.y, Color::WHITE))
        .expect("Could not render the font tileset.");
    let mut tileset = HashMap::new();
    for (index, glyph) in game_glyphs.chars().enumerate() {
        let pos = (index as i32 * tile_size_px.x as i32, 0);
        let tile = tiles.subimage(Rectangle::new(pos, tile_size_px));
        tileset.insert(glyph, tile);
    }
    Ok(tileset)
}));

The beginning is the same as our other font-rendering: we load the font and build the image.

The rest creates a new HashMap and then creates a new sub-image for every glyph.

This relies on the fact that every glyph has the same width. In other words, it only works for monospace fonts such as ononoki. If you want to use a proportional font (say Helvetica), you will need to build the font-map yourself. You can use the rusttype library to do it.

Add the tileset and tile_size_px to the Game struct:

struct Game {
    title: Asset<Image>,
    mononoki_font_info: Asset<Image>,
    map_size: Vector,
    map: Vec<Tile>,
    entities: Vec<Entity>,
    player_id: usize,
    tileset: Asset<HashMap<char, Image>>,
    tile_size_px: Vector,
}

and return them from new:

Ok(Self {
    ...
    tileset,
    tile_size_px,
})

We need to add quicksilver::geom::Rectangle and std::collections::HashMap to our imports:

use quicksilver::prelude::*;

use std::collections::HashMap;

Drawing the map

We've got the map and the tiles, so we can finally put them to use!

Drawing the map is easy: we calculate the position of each tile, grab the corresponding image and draw it on the window. Since tileset is an Asset, this must happen inside an execute block:

fn draw(&mut self, window: &mut Window) -> Result<()> {
    // ...

    let tile_size_px = self.tile_size_px;

    let (tileset, map) = (&mut self.tileset, &self.map);
    tileset.execute(|tileset| {
        for tile in map.iter() {
            if let Some(image) = tileset.get(&tile.glyph) {
                let pos_px = tile.pos.times(tile_size_px);
                window.draw(
                    &Rectangle::new(pos_px, image.area().size()),
                    Blended(&image, tile.color),
                );
            }
        }
        Ok(())
    })?;

    Ok(())
}

If we called self.tileset.excute without the let lines above it, it would mutably borrow the entire Game struct and we wouldn't be able to access self.map or self.tile_size_px. So we do a partial borrow and call execute on that.

Try removing the let lines and use self.map etc. in the draw function. See what happens!

The Vector::times method multiplies the corresponding Vector elements. So v1.times(v2) is the same as: Vector::new(v1.x * v2.x, v1.y * v2.y). This gets us from the tile position (from 0 to 20) to the pixel position on the screen (from 0 to 240).

There are a few different ways to multiply vectors in Maths (cross product, dot product, per-element), so they're all available as separate methods with their own names instead the v1 * v2 operator you might expect.

And finally, the Blended background option allows us to apply a colour to the pixels on the picture. Since our glyphs are white, this turns them into whatever colour we set.

And that should do it:

Tileset in the top-left corner

Looking good, but the map is in the top-left corner, obscured by the title text! Let's fix that.

We'd like to move the whole map out of the title's way. That means shifting each tile that we draw. Let's say 50 pixels to the right and 120 down.

let offset_px = Vector::new(50, 120);

And then in window.draw we'll add offset_px to pos_px in the Rectangle::new call:

window.draw(
    &Rectangle::new(offset_px + pos_px, image.area().size()),
    Blended(&image, tile.color),
);

Offset map

Better.

Adding a square font

This starts to look like a roguelike, but we can improve upon it. Why are the tiles not square? Personal preference aside (whatever floats your boat), in our case it's just an artefact of the font we're using.

We've picked mononoki, because we like it! It looks great, has visually distinct characters and even normal text looks decent in it (though when it comes to reading a block of actual text, nothing beats proportional fonts).

"we" == Tomas Sedovic. I like mononoki. It's awesome. If you disagree, pick a different font!

But it's not a square font.

If we were writing a terminal game or using a library that emulates one (such as libtcod), everything would be the same font and you'd have to choose between a square font (good for the map, bad for text) or a non-square one (good for text, bad for the map).

Neither is a great option, but all old-school roguelikes were that way.

We can do (arguably) better, however!

Let's just pick a second font with square proportions and use that for the map (and keep doing text with mononoki).

For a font with square proportions, you clearly can't do better than Square:

http://strlen.com/square/?s[]=font

It's licensed under CC BY 3.0. Download it and put square.ttf in the static folder.

You can also just keep using a non-square font and simply center each glyph into a square tile. I did that in my first game. It's fine.

We'll need to tweak a few things in Game::new. We'll add the new font file name and then replace font_mononoki in the tileset's Font::load with font_square:

let font_square = "square.ttf";
let game_glyphs = "#@g.%";
let tile_size_px = Vector::new(12, 24);
let tileset = Asset::new(Font::load(font_mononoki).and_then(move |text| {
    let tiles = text
        .render(game_glyphs, &FontStyle::new(tile_size_px.y, Color::WHITE))
        .expect("Could not render the font tileset.");
    let mut tileset = HashMap::new();
    for (index, glyph) in game_glyphs.chars().enumerate() {
        let pos = (index as i32 * tile_size_px.x as i32, 0);
        let tile = tiles.subimage(Rectangle::new(pos, tile_size_px));
        tileset.insert(glyph, tile);
    }
    Ok(tileset)
}));

You might wonder whether we should also update tile_size_px. We should! Look what happens if we don't:

Half square

Glitches like these are one of gamedev's lesser-known pleasures.

Make the tile size a proper square:

let tile_size_px = Vector::new(24, 24);

Square map

Take that, 1950s terminals!

Z3, one of the first computers with a textual terminal had 1408 bits of data memory. Our tileset image alone has 11,520 bytes.

Square credit

Since we've added another font, let's show our appreciation to its author too!

In Game::new:

let square_font_info = Asset::new(Font::load(font_mononoki).and_then(move |font| {
    font.render(
        "Square font by Wouter Van Oortmerssen, terms: CC BY 3.0",
        &FontStyle::new(20.0, Color::BLACK),
    )
}));

Add it to the Game struct:

square_font_info: Asset<Image>,

And then in Game::draw:

self.square_font_info.execute(|image| {
    window.draw(
        &image
            .area()
            .translate((2, window.screen_size().y as i32 - 30)),
        Img(&image),
    );
    Ok(())
})?;

Square font credits

Thanks a bunch, Wouter!

Drawing entities

Now that our map looks the way we want it, let's add the entities. The code is pretty much identical to how we draw the map:

let (tileset, entities) = (&mut self.tileset, &self.entities);
tileset.execute(|tileset| {
    for entity in entities.iter() {
        if let Some(image) = tileset.get(&entity.glyph) {
            let pos_px = offset_px + entity.pos.times(tile_size_px);
            window.draw(
                &Rectangle::new(pos_px, image.area().size()),
                Blended(&image, entity.color),
            );
        }
    }
    Ok(())
})?;

It's so similar that you might consider using the same structure for both and draw everything in one block. That's perfectly feasible, give it a go!

Entities!

We can see the player (@) a couple of (definitely friendly) goblins (g) and some purple food (%). Time to party!

You may also notice that the dots representing empty space are still visible. The images are just drawn on top of one another so if they don't cover something perfectly, it will peek through.

Spoiler alert: we will not fix that here. It's your first homework!

Health bar

One final piece of distinguished visual art: our protagonist's health bar!

We're going to get the player's entity, set the full bar's width at a hundred pixels and calculate how much of it should we show based on the player's hit points:

let player = &self.entities[self.player_id];
let full_health_width_px = 100.0;
let current_health_width_px =
    (player.hp as f32 / player.max_hp as f32) * full_health_width_px;

Next, let's calculate its position. We're going to place it at the right hand side of the map. That means getting the map's size in pixels plus the offset:

let map_size_px = self.map_size.times(tile_size_px);
let health_bar_pos_px = offset_px + Vector::new(map_size_px.x, 0.0);

And finally draw it. First we draw the full width in a somewhat transparent colour and then the current value in full red:

// Full health
window.draw(
    &Rectangle::new(health_bar_pos_px, (full_health_width_px, tile_size_px.y)),
    Col(Color::RED.with_alpha(0.5)),
);

// Current health
window.draw(
    &Rectangle::new(health_bar_pos_px, (current_health_width_px, tile_size_px.y)),
    Col(Color::RED),
);

We're using the quicksilver::graphics::Background::Col variant here. That's the final Background value -- representing the whole area filled with the given colour.

Health bar

Move the player around

Games have to be interactive. Let's move our player if any of the arrow keys are pressed:

/// Process keyboard and mouse, update the game state
fn update(&mut self, window: &mut Window) -> Result<()> {
    use ButtonState::*;

    let player = &mut self.entities[self.player_id];
    if window.keyboard()[Key::Left] == Pressed {
        player.pos.x -= 1.0;
    }
    if window.keyboard()[Key::Right] == Pressed {
        player.pos.x += 1.0;
    }
    if window.keyboard()[Key::Up] == Pressed {
        player.pos.y -= 1.0;
    }
    if window.keyboard()[Key::Down] == Pressed {
        player.pos.y += 1.0;
    }
    Ok(())
}

Straightforward stuff.

Finally, if you want to quit the game, call window.close():

if window.keyboard()[Key::Escape].is_down() {
    window.close();
}

Please make sure you don't ship your game with this left in! Someone will press Esc unintentionally and lose their progress (or at least be annoyed they have to restart the game). That someone will be me. Please add a confirmation step before closing the window.

As you can see, the player can walk through everything. The goblins, food, even the walls! This is fine if you're making a roguelike where you're a ghost, but probably not in most other circumstances.

We're not going to fix that here either! Your second homework.

Web Version

One last thing.

Running the game with cargo run builds the desktop version. But we promised that Quicksilver can do a web version too.

Run:

$ cargo web start --release --auto-reload

and go to:

http://localhost:8000

WebAssembly

It works! And it looks just like the desktop version. No changes necessary.

If you run this:

$ cargo web deploy --release

Everything will be added to your target/deploy directory. Upload it on the web, give people the link and they can play it right their in the browser -- no need to install anything!

GitHub Pages

You can publish your game to GitHub Pages like so. Assuming your GitHub username is sam and your repo is called roguelike, you can do this:

$ cd ~/code/roguelike  # or whetever your code is
$ git checkout -b gh-pages
$ cargo web deploy --release
$ cp -r target/deploy/* .
$ git add .
$ git commit -m "Add wasm build"
$ git push -u origin gh-pages

And now go to: https://sam.github.io/roguelike/

(of course swapping your user and repo name for the right values)

My repo is at quicksilver-roguelike and I'm @tomassedovic on GitHub, so you can check out my build here:

https://tomassedovic.github.io/quicksilver-roguelike/

Make your own game

This is where we end, but you are just beginning!

We've built something that has a lovely square map, readable text, filled rectangles and runs on Windows, macOS, Linux and the freaking web!

But it's not a real roguelike yet. In addition to the homework, it's missing some of these things:

  • procedural map generation
  • collision handling
  • monsters
  • AI
  • combat
  • items

Plus whatever unique twists you want to do! Go make games!

More resources

Quicksilver tutorial:

https://docs.rs/quicksilver/latest/quicksilver/tutorials/index.html

Quicksilver API docs:

https://docs.rs/quicksilver/latest/quicksilver/index.html

Rust's docs on std::collections:

https://doc.rust-lang.org/std/collections/index.html

You will be using collections in your games and this is an excellent overview of what's in the standard library and when you might want to use it.

The 7 Day Roguelike Challenge (7DRL):

https://7drl.com/