diff --git a/docs/tutorial/.gitignore b/docs/tutorial/.gitignore new file mode 100644 index 000000000..7585238ef --- /dev/null +++ b/docs/tutorial/.gitignore @@ -0,0 +1 @@ +book diff --git a/docs/tutorial/README.md b/docs/tutorial/README.md new file mode 100644 index 000000000..d1a675206 --- /dev/null +++ b/docs/tutorial/README.md @@ -0,0 +1,17 @@ +# Requirements + +Building the tutorial requires `mdbook`, which you can install with `cargo`: + +```sh +cargo install mdbook +``` + +# Building + +To build the tutorial, type: + +```sh +mdbook build +``` + +The output will be in the `book/html` subdirectory. To check it out, open it in your web browser. \ No newline at end of file diff --git a/docs/tutorial/book.toml b/docs/tutorial/book.toml new file mode 100644 index 000000000..22203fd8c --- /dev/null +++ b/docs/tutorial/book.toml @@ -0,0 +1,12 @@ +[book] +authors = ["SixtyFPS "] +language = "en" +multilingual = false +src = "src" +title = "SixtyFPS Memory Game Tutorial" + +[output.html] +# additional-js = ["highlight_60.js"] + +[output.linkcheck] # enable the "mdbook-linkcheck" renderer +optional = true diff --git a/docs/tutorial/src/SUMMARY.md b/docs/tutorial/src/SUMMARY.md new file mode 100644 index 000000000..24f2bed0e --- /dev/null +++ b/docs/tutorial/src/SUMMARY.md @@ -0,0 +1,10 @@ +# Summary + +- [Introduction](./introduction.md) +- [Getting Started](./getting_started.md) +- [Memory Tile](./memory_tile.md) +- [Polishing the Tile](./polishing_the_tile.md) +- [From One To Multiple Tiles](./from_one_to_multiple_tiles.md) +- [Creating The Tiles From Rust](./creating_the_tiles_from_rust.md) +- [Game Logic In Rust](./game_logic_in_rust.md) +- [Ideas For The Reader](./ideas_for_the_reader.md) diff --git a/docs/tutorial/src/creating_the_tiles_from_rust.md b/docs/tutorial/src/creating_the_tiles_from_rust.md new file mode 100644 index 000000000..00f82bf02 --- /dev/null +++ b/docs/tutorial/src/creating_the_tiles_from_rust.md @@ -0,0 +1,55 @@ +# Creating The Tiles From Rust + +The tiles in the game should have a random placement. We'll need to add the <`rand` dependency to +`Cargo.toml` for the randomization. + +```toml +[dependencies] +sixtyfps = "0.0.6" +rand = "0.8" # Added +``` + +What we'll do is take the list of tiles declared in the .60 language, duplicate it, and shuffle it. +We'll do so by accessing the `memory_tiles` property through the Rust code. For each top-level property, +a getter and a setter function is generated - in our case `get_memory_tiles` and `set_memory_tiles`. +Since `memory_tiles` is an array in the `.60` language, it is represented as a [`Rc`](https://sixtyfps.io/docs/rust/sixtyfps/trait.model). +We can't modify the model generated by the .60, but we can extract the tiles from it, and put it +in a [`VecModel`](https://sixtyfps.io/docs/rust/sixtyfps/struct.vecmodel) which implements the `Model` trait. +`VecModel` allows us to make modifications and we can use it to replace the static generated model. + +We modify the main function like so: + +```rust +fn main() { + use sixtyfps::Model; + + let main_window = MainWindow::new(); + + // Fetch the tiles from the model + let mut tiles: Vec<TileData> = + main_window.get_memory_tiles().iter().collect(); + // Duplicate them to ensure that we have pairs + tiles.extend(tiles.clone()); + + // Randomly mix the tiles + use rand::seq::SliceRandom; + let mut rng = rand::thread_rng(); + tiles.shuffle(&mut rng); + + // Assign the shuffled Vec to the model property + let tiles_model = + std::rc::Rc::new(sixtyfps::VecModel::from(tiles)); + main_window.set_memory_tiles( + sixtyfps::ModelHandle::new(tiles_model.clone())); + + main_window.run(); +} +``` + +Note that we clone the `tiles_model` because we'll use it later to update the game logic. + +Running this gives us a window on the screen that now shows a 4 by 4 grid of rectangles, which can show or obscure +the icons when clicking. There's only one last aspect missing now, the rules for the game. + + + diff --git a/docs/tutorial/src/from_one_to_multiple_tiles.md b/docs/tutorial/src/from_one_to_multiple_tiles.md new file mode 100644 index 000000000..ebb3bade3 --- /dev/null +++ b/docs/tutorial/src/from_one_to_multiple_tiles.md @@ -0,0 +1,70 @@ +# From One To Multiple Tiles + +After modeling a single tile, let's create a grid of them. For the grid to be our game board, we need two features: + +1. A data model: This shall be an array where each element describes the tile data structure, such as the + url of the image, whether the image shall be visible and if this tile has been solved. We modify the model + from Rust code. +1. A way of creating many instances of the tiles, with the above `.60` markup code. + +In SixtyFPS we can declare an array of structures using brackets, to create a model. We can use the `for` loop +to create many instances of the same element. In `.60` the for loop is declarative and automatically updates when +the model changes. We instantiate all the different `MemoryTile` elements and place them on a grid based on their +index with a little bit of spacing between the tiles. + +First, we copy the tile data structure definition and paste it at top inside the `sixtyfps!` macro: + +```60 +sixtyfps::sixtyfps!{ + +// Added: +struct TileData := { + image: image, + image_visible: bool, + solved: bool, +} + +MemoryTile := Rectangle { +// ... +``` + +Next, we replace the *`MainWindow` := { ... }* section at the bottom of the `sixtyfps!` macro with the following snippet: + +```60 +MainWindow := Window { + width: 326px; + height: 326px; + + property <[TileData]> memory_tiles: [ + { image: @image-url("icons/at.png") }, + { image: @image-url("icons/balance-scale.png") }, + { image: @image-url("icons/bicycle.png") }, + { image: @image-url("icons/bus.png") }, + { image: @image-url("icons/cloud.png") }, + { image: @image-url("icons/cogs.png") }, + { image: @image-url("icons/motorcycle.png") }, + { image: @image-url("icons/video.png") }, + ]; + for tile[i] in memory_tiles : MemoryTile { + x: mod(i, 4) * 74px; + y: floor(i / 4) * 74px; + width: 64px; + height: 64px; + icon: tile.image; + open_curtain: tile.image_visible || tile.solved; + // propagate the solved status from the model to the tile + solved: tile.solved; + clicked => { + tile.image_visible = !tile.image_visible; + } + } +} +``` + +The `for tile[i] in memory_tiles :` syntax declares a variable `tile` which contains the data of one element from the `memory_tiles` array, +and a variable `i` which is the index of the tile. We use the `i` index to calculate the position of tile based on its row and column, +using the modulo and integer division to create a 4 by 4 grid. + +Running this gives us a window that shows 8 tiles, which can be opened individually. + + diff --git a/docs/tutorial/src/game_logic_in_rust.md b/docs/tutorial/src/game_logic_in_rust.md new file mode 100644 index 000000000..ada4802e8 --- /dev/null +++ b/docs/tutorial/src/game_logic_in_rust.md @@ -0,0 +1,110 @@ +# Game Logic In Rust + +We'll implement the rules of the game in Rust as well. The general philosophy of SixtyFPS is that merely the user +interface is implemented in the `.60` language and the business logic in your favorite programming +language. The game rules shall enforce that at most two tiles have their curtain open. If the tiles match, then we +consider them solved and they remain open. Otherwise we wait for a little while, so the player can memorize +the location of the icons, and then close them again. + +We'll modify the `.60` markup inside the `sixtyfps!` macro to signal to the Rust code when the user clicks on a tile. +Two changes to `MainWindow` are needed: We need to add a way for the MainWindow to call to the Rust code that it should +check if a pair of tiles has been solved. And we need to add a property that Rust code can toggle to disable further +tile interaction, to prevent the player from opening more tiles than allowed. No cheating allowed! First, we paste +the callback and property declarations into `MainWindow`: + + +```60 +... +MainWindow := Window { + callback check_if_pair_solved(); // Added + property disable_tiles; // Added + + width: 326px; + height: 326px; + + property <[TileData]> memory_tiles: [ + { image: img!"icons/at.png" }, +... +``` + +The last change to the `.60` markup is to act when the `MemoryTile` signals that it was clicked on. We add the following handler: + +```60 +... +MainWindow := Window { + ... + for tile[i] in memory_tiles : MemoryTile { + x: mod(i, 4) * 74px; + y: floor(i / 4) * 74px; + width: 64px; + height: 64px; + icon: tile.image; + open_curtain: tile.image_visible || tile.solved; + // propagate the solved status from the model to the tile + solved: tile.solved; + + clicked => { + // old: tile.image_visible = !tile.image_visible; + // new: + if (!root.disable_tiles) { + tile.image_visible = !tile.image_visible; + root.check_if_pair_solved(); + } + } + } +} +``` + +On the Rust side, we can now add an handler to the `check_if_pair_solved` callback, that will check if +two tiles are opened. If they match, the `solved` property is set to true in the model. If they don't +match, start a timer that will close them after one second. While the timer is running, we disable every tile so +one cannot click anything during this time. + +Insert this code before the `main_window.run()` call: + +```rust +// ... + let main_window_weak = main_window.as_weak(); + main_window.on_check_if_pair_solved(move || { + let mut flipped_tiles = + tiles_model.iter().enumerate().filter(|(_, tile)| { + tile.image_visible && !tile.solved + }); + + if let (Some((t1_idx, mut t1)), Some((t2_idx, mut t2))) = + (flipped_tiles.next(), flipped_tiles.next()) + { + let is_pair_solved = t1 == t2; + if is_pair_solved { + t1.solved = true; + tiles_model.set_row_data(t1_idx, t1.clone()); + t2.solved = true; + tiles_model.set_row_data(t2_idx, t2.clone()); + } else { + let main_window = main_window_weak.unwrap(); + main_window.set_disable_tiles(true); + let tiles_model = tiles_model.clone(); + sixtyfps::Timer::single_shot( + std::time::Duration::from_secs(1), + move || { + main_window + .set_disable_tiles(false); + t1.image_visible = false; + tiles_model.set_row_data(t1_idx, t1); + t2.image_visible = false; + tiles_model.set_row_data(t2_idx, t2); + } + ); + } + } + }); + + main_window.run(); +``` + +Notice that we take a [Weak](https://sixtyfps.io/docs/rust/sixtyfps/struct.weak) pointer of our `main_window`. This is very +important because capturing a copy of the `main_window` itself within the callback handler would result in a circular ownership. +The `MainWindow` owns the callback handler, which itself owns a reference to the `MainWindow`, which must be weak +instead of strong to avoid a memory leak. + +And that's it, now we can run the game! \ No newline at end of file diff --git a/docs/tutorial/src/getting_started.md b/docs/tutorial/src/getting_started.md new file mode 100644 index 000000000..00d89f14d --- /dev/null +++ b/docs/tutorial/src/getting_started.md @@ -0,0 +1,38 @@ +# Getting Started + +We assume that you are a somewhat familiar with Rust, and that you know how to create a Rust application with +`cargo new`. The [Rust Getting Started Guide](https://www.rust-lang.org/learn/get-started) can help you get set up. + +First, we create a new cargo project: + +```sh +cargo new memory +cd memory +``` + +Then we edit `Cargo.toml` to add the sixtyfps dependency: + +```toml +[dependencies] +sixtyfps = "0.0.6" +``` + +Finally we copy the hello world program from the [SixtyFPS documentation](https://sixtyfps.io/docs/rust/sixtyfps/) into our `src/main.rs`: + +```rust +sixtyfps::sixtyfps!{ + MainWindow := Window { + Text { + text: "hello world"; + color: green; + } + } +} +fn main() { + MainWindow::new().run(); +} +``` + +We run this example with `cargo run` and a window will appear with the green "Hello World" greeting. + +![Screenshot of initial tutorial app showing Hello World](https://sixtyfps.io/blog/memory-game-tutorial/getting-started.png "Hello World") \ No newline at end of file diff --git a/docs/tutorial/src/ideas_for_the_reader.md b/docs/tutorial/src/ideas_for_the_reader.md new file mode 100644 index 000000000..63a0484c8 --- /dev/null +++ b/docs/tutorial/src/ideas_for_the_reader.md @@ -0,0 +1,15 @@ +# Ideas For The Reader + +The game is visually a little bare. Here are some ideas how you could make further changes to enhance it: + +* The tiles could have rounded corners, to look a little less sharp. The [border-radius](https://sixtyfps.io/docs/rust/sixtyfps/docs/builtin_elements/index.html#rectangle) + property of *Rectangle* can be used to achieve that. + +* In real world memory games, the back of the tiles often have some common graphic. You could add an image with + the help of another *[Image](https://sixtyfps.io/docs/rust/sixtyfps/docs/builtin_elements/index.html#image)* + element. Note that you may have to use *Rectangle*'s *[clip](https://sixtyfps.io/docs/rust/sixtyfps/docs/builtin_elements/index.html#properties-1) property* + element around it to ensure that the image is clipped away when the curtain effect opens. + +Let us know in the comments on Github Discussions how you polished your code, or feel free to ask questions about +how to implement something. + diff --git a/docs/tutorial/src/introduction.md b/docs/tutorial/src/introduction.md new file mode 100644 index 000000000..bfc3139c5 --- /dev/null +++ b/docs/tutorial/src/introduction.md @@ -0,0 +1,8 @@ +# Introduction + +This tutorial will introduce you to the SixtyFPS UI framework in a playful way by implementing a little memory game. We are going to combine the `.60` language for the graphics with the game rules implemented in the Rust programming language. + +Before we start, here's a sneak preview of how the game is going to look when finished: + + \ No newline at end of file diff --git a/docs/tutorial/src/memory_tile.md b/docs/tutorial/src/memory_tile.md new file mode 100644 index 000000000..15947dd84 --- /dev/null +++ b/docs/tutorial/src/memory_tile.md @@ -0,0 +1,45 @@ +# Memory Tile + +With the skeleton in place, let's look at the first element of the game, the memory tile. It will be the +visual building block that consists of an underlying filled rectangle background, the icon image. Later we'll add a +covering rectangle that acts as a curtain. The background rectangle is declared to be 64 logical pixels wide and tall, +and it is filled with a soothing tone of blue. Note how lengths in the `.60` language have a unit, here +the `px` suffix. That makes the code easier to read and the compiler can detect when your're accidentally +mixing values with different units attached to them. + +We copy the following code inside of the `sixtyfps!` macro: + +```60 +MemoryTile := Rectangle { + width: 64px; + height: 64px; + background: #3960D5; + + Image { + source: @image-url("icons/bus.png"); + width: parent.width; + height: parent.height; + } +} + +MainWindow := Window { + MemoryTile {} +} +``` + +Inside the `Rectangle` we place an `Image` element that loads an icon with the `@image-url()` macro. The path is +relative to the folder in which the `Cargo.toml` is located. This icon and others we're going to +use later need to be installed first. You can download a [Zip archive](https://sixtyfps.io/blog/memory-game-tutorial/icons.zip) +that we have prepared and extract it with the following two commands: + +```sh +curl -O https://sixtyfps.io/blog/memory-game-tutorial/icons.zip +unzip icons.zip +``` + +This should unpack an `icons` directory containing a bunch of icons. + +Running the program with `cargo run` gives us a window on the screen that shows the icon of a bus on a +blue background. + +![Screenshot of the first tile](https://sixtyfps.io/blog/memory-game-tutorial/memory-tile.png "Memory Tile Screenshot") diff --git a/docs/tutorial/src/polishing_the_tile.md b/docs/tutorial/src/polishing_the_tile.md new file mode 100644 index 000000000..006d09ead --- /dev/null +++ b/docs/tutorial/src/polishing_the_tile.md @@ -0,0 +1,81 @@ +# Polishing the Tile + +Next, let's add a curtain like cover that opens up when clicking. We achieve this by declaring two rectangles +below the Image, so that they are drawn afterwards and thus on top of the image. +The TouchArea element declares a transparent rectangular region that allows +reacting to user input such as a mouse click or tap. We use that to forward a callback to the MainWindow +that the tile was clicked on. In the MainWindow we react by flipping a custom open_curtain property. +That in turn is used in property bindings for the animated width and x properties. Let's look at the two states a bit +more in detail: + +|*open_curtain* value: |false|true| +|-----------------------|-----|----| +|Left curtain rectangle |Fill the left half by setting the width *width* to half the parent's width|Width of zero makes the rectangle invisible| +|Right curtain rectangle|Fill the right half by setting *x* and *width* to half of the parent's width|*width* of zero makes the rectangle invisible. *x* is moved to the right, to slide the curtain open when animated| + +In order to make our tile extensible, the hard-coded icon name is replaced with an *icon* +property that can be set from the outside when instantiating the element. For the final polish, we add a +*solved* property that we use to animate the color to a shade of green when we've found a pair, later. We +replace the code inside the `sixtyfps!` macro with the following: + +```60 +MemoryTile := Rectangle { + callback clicked; + property open_curtain; + property solved; + property icon; + + height: 64px; + width: 64px; + background: solved ? #34CE57 : #3960D5; + animate background { duration: 800ms; } + + Image { + source: icon; + width: parent.width; + height: parent.height; + } + + // Left curtain + Rectangle { + background: #193076; + width: open_curtain ? 0px : (parent.width / 2); + height: parent.height; + animate width { duration: 250ms; easing: ease-in; } + } + + // Right curtain + Rectangle { + background: #193076; + x: open_curtain ? parent.width : (parent.width / 2); + width: open_curtain ? 0px : (parent.width / 2); + height: parent.height; + animate width { duration: 250ms; easing: ease-in; } + animate x { duration: 250ms; easing: ease-in; } + } + + TouchArea { + clicked => { + // Delegate to the user of this element + root.clicked(); + } + } +} +MainWindow := Window { + MemoryTile { + icon: @image-url("icons/bus.png"); + clicked => { + self.open_curtain = !self.open_curtain; + } + } +} +``` + +Note the use of `root` and `self` in the code. `root` refers to the outermost +element in the component, that's the `MemoryTile` in this case. `self` refers +to the current element. + +Running this gives us a window on the screen with a rectangle that opens up to show us the bus icon, when clicking on +it. Subsequent clicks will close and open the curtain again. + +