Part 1 - Drawing and Moving the Player
For getting set up for this tutorial, see Part 0.
This part will take you from printing “Hello, World!” to opening a window, drawing a ‘@’ symbol (representing the player character) and moving the player around with the arrow keys.
By the end of this part, the game will look like this:
This part is loosely based on this part of the python tcod tutorial.
Reference implementation branch for starting point: part-0-end
In this post:
Open a Window
Start by adding dependencies on chargrid
and chargrid_graphical
:
# Cargo.toml
...
[dependencies]
chargrid_graphical = "0.7" # graphical frontend for chargrid applications
chargrid = "0.4" # library for implementing chargrid applications
Now update your main function:
// src/main.rs
fn main() {
use chargrid_graphical::{Config, Context, Dimensions, FontBytes};
const CELL_SIZE_PX: f64 = 24.;
let context = Context::new(Config {
font_bytes: FontBytes {
normal: include_bytes!("./fonts/PxPlus_IBM_CGAthin.ttf").to_vec(),
bold: include_bytes!("./fonts/PxPlus_IBM_CGA.ttf").to_vec(),
},
title: "Chargrid Tutorial".to_string(),
window_dimensions_px: Dimensions {
width: 960.,
height: 720.,
},
cell_dimensions_px: Dimensions {
width: CELL_SIZE_PX,
height: CELL_SIZE_PX,
},
font_scale: Dimensions {
width: CELL_SIZE_PX,
height: CELL_SIZE_PX,
},
underline_width_cell_ratio: 0.1,
underline_top_offset_cell_ratio: 0.8,
resizable: false,
});
let app = App::new();
context.run_app(app);
}
This creates a new graphical context for running chargrid applications.
Chargrid is designed with the aim of being able to define an application which can run
in a window (as we are doing now), a unix terminal, or a web browser. For each of these “frontends”,
there is a Context
type which knows all the frontend-specific details, and knows how to take
an implementation of the chargrid::app::App
trait (see below) and run it by feeding it input
from the keyboard and mouse, and allowing it to render its output to the screen.
The graphical context is configured using a ContextDescriptor
which specifies the following details
about how to render a grid of characters in a window:
font_bytes
: which font to use to render characterstitle
: title of the windowwindow_dimensions
: size of the window in pixelscell_dimensions
: size of each cell of the grid in pixelsfont_dimensions
: size to render each character in pixels (usually the same ascell_dimensions
)font_source_dimensions
: size of each character in the source font in pixels (usually the same asfont_dimensions
)underline_width
: how much of the height of each cell should be taken up by the underline as a proportion of cell heightunderline_top_offset
: how far from the top of each cell should the underline begin as a proportion of cell height
Once the context has been created with Context::new
, the remaining two lines in main
at this stage are:
let app = App::new();
context.run_app(app);
This creates an App
(defined below) which will contain all the state and
logic of the application - a roguelike game in this case. As hinted above, our App
type will implement
the trait chargrid::app::App
which will tell chargrid how to run the application. Finally, context.run_app(app)
takes the application and, well, runs it, in a graphical context, sending it keyboard and mouse events received by the window,
and drawing the grid of characters to the window.
The App
type:
struct App {}
impl App {
fn new() -> Self {
Self {}
}
}
Currently the application has no state or logic, so this is just an empty struct for now.
Implement the chargrid::app::App
trait:
impl chargrid::app::App for App {
fn on_input(
&mut self,
input: chargrid::app::Input,
) -> Option<chargrid::app::ControlFlow> {
use chargrid::input::{keys, Input};
match input {
Input::Keyboard(keys::ETX) | Input::Keyboard(keys::ESCAPE) => {
Some(chargrid::app::ControlFlow::Exit)
}
_ => None,
}
}
fn on_frame<F, C>(
&mut self,
_since_last_frame: chargrid::app::Duration,
_view_context: chargrid::app::ViewContext<C>,
_frame: &mut F,
) -> Option<chargrid::app::ControlFlow>
where
F: chargrid::app::Frame,
C: chargrid::app::ColModify,
{
None
}
}
Every chargrid application must implement 2 methods:
on_input
is called each time a keyboard or mouse event occurs, and is passed a normalized representation of the eventon_frame
is called periodically, right before the context updates the contents of the window
Both methods return an Option<chargrid::app::ControlFlow>
. A chargrid::app::ControlFlow
is an enum
of control flow actions
the application can take. At the time of writing, it can only be used to specify that the application should be terminated.
The application doesn’t render anything yet, so on_frame
does nothing.
Since it’s annoying to have a program which opens a window that can’t be closed, on_input
terminates the application
by returning Some(chargrid::app::ControlFlow::Exit)
when certain keys are pressed. keys::ESCAPE
corresponds to the
escape key. keys::ETX
actually corresponds to the user closing the window (e.g. by pressing the ‘X’ button in its corner).
The name “ETX”, and the fact that this event pretends to be a keyboard event, is a remnant from the days when chargrid
applications could only run in unix terminals. When the user presses CTRL-C in a terminal, this manifests as a character
on standard input named “ETX” or “end of text”.
This is now a complete chargrid application! Run it with cargo run
and it will open an empty window:
Reference implementation branch: part-1.0
Draw the Player
Let’s place the player character in the centre of the game area, then render the player.
Start by adding some more dependencies to help represent locations and colours.
# Cargo.tom
...
[dependencies]
...
coord_2d = "0.3" # representation of 2D integer coordinates and sizes
rgb24 = "0.3" # representation of 24-bit colour
// src/main.rs
use coord_2d::{Coord, Size};
use rgb24::Rgb24;
fn main() {
...
Now we need to add the player’s coordinate to the App
type. We could introduce a new field directly to App
containing the coordinate, but we’ll do something a little different. Chargrid applications typically define two
top-level types - one for storing the application’s data, and another representing a view of the application’s data.
The data itself doesn’t know anything about how it will be rendered to the screen. The view knows how to render the
application’s data, and tends to have very little (if any) state of its own. In practice, applications tend to be
made up of several discrete visual elements, each representing some abstract data. It’s typical for the data and
view types in a chargrid app to be composed of simpler data and view types representing discrete application components.
The player’s location is part of the application’s data:
struct AppData {
player_coord: Coord,
}
impl AppData {
fn new(screen_size: Size) -> Self {
Self {
player_coord: screen_size.to_coord().unwrap() / 2,
}
}
}
Note that AppData::new
takes the screen size, so it can initialize the player’s location to the middle of the game area.
As is common, the app’s view has no state, and is just an empty struct:
struct AppView {}
impl AppView {
fn new() -> Self {
Self {}
}
}
The App
type now just combines the data and view:
struct App {
data: AppData,
view: AppView,
}
impl App {
fn new(screen_size: Size) -> Self {
Self {
data: AppData::new(screen_size),
view: AppView::new(),
}
}
}
We added an argument to App::new
, so update the call site in main
to pass the screen size:
fn main() {
...
let screen_size = Size::new(40, 30);
let app = App::new(screen_size);
context.run_app(app);
}
As mentioned above, the app’s view needs to know how to render the app’s data. In concrete terms, the type AppView
must implement the trait chargrid::render::View<&AppData>
.
impl<'a> chargrid::render::View<&'a AppData> for AppView {
fn view<F: chargrid::app::Frame, C: chargrid::app::ColModify>(
&mut self,
data: &'a AppData,
context: chargrid::app::ViewContext<C>,
frame: &mut F,
) {
let view_cell = chargrid::render::ViewCell::new()
.with_character('@')
.with_foreground(Rgb24::new_grey(255));
frame.set_cell_relative(data.player_coord, 0, view_cell, context);
}
}
Lots of new things here:
chargrid::app::Frame
represents the visible output of the application. Callingset_cell_relative
on it draws a character at a position in the window.chargrid::app::ColModify
represents the current colour modifier. In chargrid, views are often hierarchical, and a view may want to indicate that when a child view says “give that cell a bright-green background”, it actually means “give that cell a medium-green background”. This is mainly used to dim the game area while a menu is visible.
Now that the view is defined, invoke it in the on_frame
method to render the game:
impl chargrid::app::App for App {
...
fn on_frame<F, C>(
&mut self,
_since_last_frame: chargrid::app::Duration,
view_context: chargrid::app::ViewContext<C>,
frame: &mut F,
) -> Option<chargrid::app::ControlFlow>
where
F: chargrid::app::Frame,
C: chargrid::app::ColModify,
{
use chargrid::render::View;
self.view.view(&self.data, view_context, frame);
None
}
}
An ‘@’ sign will now be rendered in the centre of the screen:
Reference implementation branch: part-1.1
Move the Player
To add the most basic of gameplay, begin by adding one more dependency to let us talk about directions:
# Cargo.tom
...
[dependencies]
...
direction = "0.18" # representation of directions
This game will only allow movement in cardinal directions (north, south, east, west). Import the corresponding type:
// src/main.rs
...
use direction::CardinalDirection;
fn main() {
...
Add the screen size to the AppData
type so we can prevent the player from walking off the screen:
struct AppData {
screen_size: Size,
player_coord: Coord,
}
impl AppData {
fn new(screen_size: Size) -> Self {
Self {
screen_size,
player_coord: screen_size.to_coord().unwrap() / 2,
}
}
...
}
Add a helper method to AppData
for moving the player in a direction:
impl AppData {
...
fn maybe_move_player(&mut self, direction: CardinalDirection) {
let new_player_coord = self.player_coord + direction.coord();
if new_player_coord.is_valid(self.screen_size) {
self.player_coord = new_player_coord;
}
}
}
…and a method for handling input events which calls maybe_move_player
with the
directions corresponding to each arrow key:
impl AppData {
...
fn handle_input(&mut self, input: chargrid::input::Input) {
use chargrid::input::{Input, KeyboardInput};
match input {
Input::Keyboard(key) => match key {
KeyboardInput::Left => self.maybe_move_player(CardinalDirection::West),
KeyboardInput::Right => self.maybe_move_player(CardinalDirection::East),
KeyboardInput::Up => self.maybe_move_player(CardinalDirection::North),
KeyboardInput::Down => self.maybe_move_player(CardinalDirection::South),
_ => (),
},
_ => (),
}
}
}
Finally, call handle_input
from the on_input
method of App
’s implementation of chargrid::app::App
:
impl chargrid::app::App for App {
fn on_input(
&mut self,
input: chargrid::app::Input,
) -> Option<chargrid::app::ControlFlow> {
use chargrid::input::{keys, Input};
match input {
Input::Keyboard(keys::ETX) | Input::Keyboard(keys::ESCAPE) => {
Some(chargrid::app::ControlFlow::Exit)
}
other => {
self.data.handle_input(other);
None
}
}
}
...
}
That’s it! Run the game, press the arrow keys, and the player will move around.
Reference implementation branch: part-1.2