docs: Tutorial for making a Klondike card game, steps 1 & 2 (#1477)

This commit is contained in:
Pasha Stetsenko
2022-03-27 13:53:42 -07:00
committed by GitHub
parent bd91fed597
commit 87031b3e5f
21 changed files with 576 additions and 7 deletions

View File

@ -50,12 +50,14 @@ button.flutter-app-button:after {
#flutter-app-overlay.active iframe {
border: none;
box-shadow: 0px 0px 9px 3px black;
box-shadow: 0px 0px 30px 0px #ffffff26;
display: none;
height: 80vh;
left: 50%;
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 80vw;
}
#flutter-app-overlay.active iframe.active {
@ -77,6 +79,7 @@ button.flutter-app-button:after {
text-align: center;
top: 10%;
width: 30px;
z-index: 1;
}
#flutter-app-close-button:hover {

View File

@ -1,5 +1,6 @@
#!/usr/bin/env python
import os
import re
import shutil
import subprocess
from docutils import nodes
@ -73,14 +74,14 @@ class FlutterAppDirective(SphinxDirective):
self._process_show_option()
self._process_sources_option()
self.source_build_dir = os.path.join(self.source_dir, 'build', 'web')
self.app_name = os.path.basename(self.source_dir)
self.app_name = self._get_app_name()
self.html_dir = '_static/apps/' + self.app_name
self.target_dir = os.path.abspath(
os.path.join('..', '_build', 'html', self.html_dir))
self._ensure_compiled()
page = self.options.get('page', '')
iframe_url = self.html_dir + '/index.html?' + page
iframe_url = '/' + self.html_dir + '/index.html?' + page
result = []
if 'popup' in self.modes:
result.append(Button(
@ -117,6 +118,10 @@ class FlutterAppDirective(SphinxDirective):
assert not abspath.endswith('/')
self.source_dir = abspath
def _get_app_name(self):
src = os.path.relpath(self.source_dir)
return '-'.join(word for word in re.split(r'\W', src) if word)
def _ensure_compiled(self):
need_compiling = (
('popup' in self.modes or 'widget' in self.modes) and
@ -143,8 +148,8 @@ class FlutterAppDirective(SphinxDirective):
check=True,
)
except subprocess.CalledProcessError as e:
cmd = e.cmd.join(' ')
raise self.severe(
cmd = ' '.join(e.cmd)
raise self.error(
f'Command `{cmd}` returned with exit status {e.returncode}\n' +
e.output.decode('utf-8'),
)

View File

@ -71,6 +71,14 @@ div.expander {
flex: 1;
}
p + ul {
margin-top: -0.5em;
}
ul ul.simple p {
margin-bottom: 1em;
}
/*----------------------------------------------------------------------------*
* Top navigation bar
@ -587,6 +595,7 @@ pre {
border-radius: 0.4em;
box-shadow: 0px 0px 3px black;
color: #888;
font-family: var(--font-mono);
font-size: 85%;
line-height: 125%;
margin: 0;

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

@ -0,0 +1 @@
include: package:flame_lint/analysis_options.yaml

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 KiB

View File

@ -0,0 +1,23 @@
import 'dart:html'; // ignore: avoid_web_libraries_in_flutter
import 'package:flutter/widgets.dart';
import 'step2/main.dart' as step2;
void main() {
var page = window.location.search ?? '';
if (page.startsWith('?')) {
page = page.substring(1);
}
switch (page) {
case 'step2':
step2.main();
break;
default:
runApp(
Directionality(
textDirection: TextDirection.ltr,
child: Text('Error: unknown page name "$page"'),
),
);
}
}

View File

@ -0,0 +1,6 @@
import 'package:flame/components.dart';
class Foundation extends PositionComponent {
@override
bool get debugMode => true;
}

View File

@ -0,0 +1,6 @@
import 'package:flame/components.dart';
class Pile extends PositionComponent {
@override
bool get debugMode => true;
}

View File

@ -0,0 +1,6 @@
import 'package:flame/components.dart';
class Stock extends PositionComponent {
@override
bool get debugMode => true;
}

View File

@ -0,0 +1,6 @@
import 'package:flame/components.dart';
class Waste extends PositionComponent {
@override
bool get debugMode => true;
}

View File

@ -0,0 +1,56 @@
import 'package:flame/components.dart';
import 'package:flame/experimental.dart';
import 'package:flame/game.dart';
import 'components/foundation.dart';
import 'components/pile.dart';
import 'components/stock.dart';
import 'components/waste.dart';
class KlondikeGame extends FlameGame {
final double cardGap = 175.0;
final double cardWidth = 1000.0;
final double cardHeight = 1400.0;
@override
Future<void> onLoad() async {
await images.load('klondike-sprites.png');
final stock = Stock()
..size = Vector2(cardWidth, cardHeight)
..position = Vector2(cardGap, cardGap);
final waste = Waste()
..size = Vector2(cardWidth * 1.5, cardHeight)
..position = Vector2(cardWidth + 2 * cardGap, cardGap);
final foundations = List.generate(
4,
(i) => Foundation()
..size = Vector2(cardWidth, cardHeight)
..position =
Vector2((i + 3) * (cardWidth + cardGap) + cardGap, cardGap),
);
final piles = List.generate(
7,
(i) => Pile()
..size = Vector2(cardWidth, cardHeight)
..position = Vector2(
cardGap + i * (cardWidth + cardGap),
cardHeight + 2 * cardGap,
),
);
final world = World()
..add(stock)
..add(waste)
..addAll(foundations)
..addAll(piles);
final camera = CameraComponent(world: world)
..viewfinder.visibleGameSize =
Vector2(cardWidth * 7 + cardGap * 8, 4 * cardHeight + 3 * cardGap)
..viewfinder.position = Vector2(cardWidth * 3.5 + cardGap * 4, 0)
..viewfinder.anchor = Anchor.topCenter;
add(world);
add(camera);
}
}

View File

@ -0,0 +1,9 @@
import 'package:flame/game.dart';
import 'package:flutter/widgets.dart';
import 'klondike_game.dart';
void main() {
final game = KlondikeGame();
runApp(GameWidget(game: game));
}

View File

@ -0,0 +1,17 @@
name: klondike
description: Klondike tutorial
version: 1.0.0
publish_to: none
environment:
sdk: ^2.15.0
dependencies:
flutter:
sdk: flutter
flame:
path: ../../../../packages/flame
flutter:
assets:
- assets/images/

View File

@ -0,0 +1,26 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="Klondike tutorial">
<base href="$FLUTTER_BASE_HREF">
<!-- iOS meta tags & icons -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="Klondike">
<title>Klondike</title>
</head>
<body>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', function () {
navigator.serviceWorker.register('flutter_service_worker.js');
});
}
</script>
<script src="main.dart.js" type="application/javascript"></script>
</body>
</html>

View File

@ -0,0 +1,18 @@
# Klondike game tutorial
[Klondike] is a popular solitaire card game. In this tutorial we will follow the step-by-step
process for coding this game using the Flame engine.
This tutorial assumes that you have at least some familiarity with common programming concepts, and
with the [Dart] programming language.
[Dart]: https://dart.dev/overview
[Klondike]: https://en.wikipedia.org/wiki/Klondike_(solitaire)
```{toctree}
:hidden:
1. Preparation <step1.md>
2. Scaffolding <step2.md>
```

View File

@ -0,0 +1,108 @@
# 1. Preparation
Before you begin any kind of game project, you need to give it a **name**. For
this tutorial the name will be simply `klondike`.
Having this name in mind, please head over to the [](../bare_flame_game.md)
tutorial and complete the necessary set up steps. When you come back, you should
already have the `main.dart` file with the following content:
```dart
import 'package:flame/game.dart';
import 'package:flutter/widgets.dart';
void main() {
final game = FlameGame();
runApp(GameWidget(game: game));
}
```
## Planning
The start of any project usually feels overwhelming. Where even to begin?
I always find it useful to create a rough sketch of what I am about to code,
so that it can serve as a reference point. My sketch for the Klondike game is
shown below:
![](../../images/tutorials/klondike-sketch.webp)
Here you can see both the general layout of the game, as well as names of
various objects. These names are the [standard terminology] for solitaire games.
Which is really lucky, because normally figuring out good names for various
classes is a quite challenging task.
Looking at this sketch, we can already imagine the high-level structure of the
game. Obviously, there will be a `Card` class, but also the `Stock` class, the
`Waste` class, a `Tableau` containing seven `Pile`s, and 4 `Foundation`s. There
may also be a `Deck`. All of these components will be tied together via the
`KlondikeGame` derived from the `FlameGame`.
## Assets
Another important aspect in any game development is the game's assets. These
includes images, sprites, animations, sounds, textures, data files, and so on.
In such a simple game as Klondike we won't need lots of fancy graphics, but
still some sprites will be needed in order to draw the cards.
In order to prepare the graphic assets, I first took a physical playing card and
measured it to be 63mm × 88mm, which is the ratio of approximately `1.4`. Thus,
I decided that my in-game cards should be rendered at 1000×1400 pixels, and I
should draw all my images with this scale in mind.
Note that the exact pixel dimensions are somewhat irrelevant here, since the
images will in the end be scaled up or down, according to the device's actual
resolution. Here I'm using probably a bigger resolution than necessary for
phones, but it would also work nicely for larger devices like an iPad.
And now, without further ado, the graphic assets for the Klondike game (don't
judge too harshly, I'm not an artist):
![](app/assets/images/klondike-sprites.png)
Right-click the image, choose "Save as...", and store it in the `assets/images`
folder of the project. At this point our project structure looks like this
(there are other files too, of course, but these are the important ones):
```text
klondike/
├─assets/
│ └─images/
│ └─klondike-sprites.png
├─lib/
│ └─main.dart
└─pubspec.yaml
```
By the way, this kind of file is called the _spritesheet_: it's just a
collection of multiple independent images in a single file. We are using a
spritesheet here for the simple reason that loading a single large image is
faster than many small images. In addition, rendering sprites that were
extracted from a single source image can be faster too, since Flutter will
optimize multiple such drawing commands into a single `drawAtlas` command.
Here are the contents of my spritesheet:
- Numerals 2, 3, 4, ..., K, A. In theory, we could have rendered these in the
game as text strings, but then we would need to also include a font as an
asset -- seems simpler to just have them as images instead.
- Suit marks: ♠, ♥, ♦, ♣. Again, we could have used Unicode characters for
these, but images are much easier to position precisely.
- Flame logo, for use on the backs of the cards.
- Pictures of a Jack, a Queen, and a King. Normally there would be four times
more of these, with a different character for each suite, but I got too
tired drawing these.
Also, you need to tell Flutter about this image (just having it inside the
`assets` folder is not enough). In order to do this, let's add the following
lines into the `pubspec.yaml` file:
```yaml
flutter:
assets:
- assets/images/
```
Alright, enough with preparing -- onward to coding!
[standard terminology]: https://en.wikipedia.org/wiki/Solitaire_terminology

View File

@ -0,0 +1,265 @@
# 2. Scaffolding
In this section we will use broad strokes in order to outline the main elements
of the game. This includes the main game class, and the general layout.
## KlondikeGame
In Flame universe, the `FlameGame` class is the cornerstone of any game. This
class runs the game loop, dispatches events, owns all the components that
comprise the game (the component tree), and usually also serves as the central
repository for the game's state.
So, create a new file called `klondike_game.dart` inside the `lib/` folder, and
declare the `KlondikeGame` class inside:
```dart
import 'package:flame/game.dart';
class KlondikeGame extends FlameGame {
@override
Future<void> onLoad() async {
await Images.load('klondike-sprites.png');
}
}
```
For now we only declared the `onLoad` method, which is a special handler that
is called when the game instance is attached to the Flutter widget tree for the
first time. You can think of it as a delayed asynchronous constructor.
Currently, the only thing that `onLoad` does is that it loads the sprites image
into the game; but we will be adding more soon. Any image or other resource that
you want to use in the game needs to be loaded first, which is a relatively slow
I/O operation, hence the need for `await` keyword.
Let's incorporate this class into the project so that it isn't orphaned. Open
the `main.dart` find the line which says `final game = FlameGame();` and replace
the `FlameGame` with `KlondikeGame`. You will need to import the class too.
After all is done, the file should look like this:
```dart
import 'package:flame/game.dart';
import 'package:flutter/widgets.dart';
import 'klondike_game.dart';
void main() {
final game = KlondikeGame();
runApp(GameWidget(game: game));
}
```
## Other classes
So far we have the main `KlondikeGame` class, and now we need to create objects
that we will add to the game. In Flame these objects are called _components_,
and when added to the game they form a "game component tree". All entities that
exist in the game must be components.
As we already mentioned in the previous chapter, our game mainly consists of
`Card` components. However, since drawing the cards will take some effort, we
will defer implementation of that class to the next chapter.
For now, let's create the container classes, as shown on the sketch. These are:
`Stock`, `Waste`, `Pile` and `Foundation`. In your project directory create a
sub-directory `components`, and then the file `components/stock.dart`. In that
file write
```dart
import 'package:flame/components.dart';
class Stock extends PositionComponent {
bool get debugMode => true;
}
```
Here we declare the `Stock` class as a `PositionComponent` (which is a component
that has a position and size). We also turn on the debug mode for this class so
that we can see it on the screen even though we don't have any rendering logic
yet.
Likewise, create three more files `components/foundation.dart`,
`components/pile.dart`, and `components/waste.dart`. For now all four classes
will have exactly the same logic inside, we'll be adding more functionality into
those classes in subsequent chapters.
## Game structure
Once we have some basic components, they need to be added to the game. It is
time to make a decision about the high-level structure of the game.
There exist multiple approaches here, which differ in their complexity,
extendability, and overall philosophy. The approach that we will be taking in
this tutorial is based on using the [World] component, together with a [Camera].
The idea behind this approach is the following: imagine that your game world
exists independently from the device, that it exists already in our heads, and
on the sketch, even though we haven't done any coding yet. This world will have
a certain size, and each element in the world will have certain coordinates. It
is up to us to decide what will be the size of the world, and what is the unit
of measurement for that size. The important part is that the world exists
independently from the device, and its dimensions likewise do not depend on the
pixel resolution of the screen.
All elements that are part of the world will be added to the `World` component,
and the `World` component will be then added to the game.
The second part of the overall structure is a camera (`CameraComponent`). The
purpose of the camera is to be able to look at the world, to make sure that it
renders at the right size on the screen of the user's device.
Thus, the overall structure of the component tree will look approximately like
this:
```text
KlondikeGame
├─ World
│ ├─ Stock
│ ├─ Waste
│ ├─ Foundation (×4)
│ └─ Pile (×7)
└─ CameraComponent
```
For this game I've been drawing my image assets having in mind the dimension of
a single card at 1000×1400 pixels. So, this will serve as the reference size for
determining the overall layout. Another important measurement that affects the
layout is the inter-card distance. It seems like it should be somewhere between
150 to 200 units (relative to the card width), so we will declare it as a
variable `cardGap` that can be adjusted later if needed. For simplicity, both
the vertical and horizontal inter-card distance will be the same, and the
minimum padding between the cards and the edges of the screen will also be equal
to `cardGap`.
Alright, let's put all this together and implement our `KlondikeGame` class:
```dart
class KlondikeGame extends FlameGame {
final double cardGap = 175.0;
final double cardWidth = 1000.0;
final double cardHeight = 1400.0;
@override
Future<void> onLoad() async {
await images.load('klondike-sprites.png');
final stock = Stock()
..size = Vector2(cardWidth, cardHeight)
..position = Vector2(cardGap, cardGap);
final waste = Waste()
..size = Vector2(cardWidth * 1.5, cardHeight)
..position = Vector2(cardWidth + 2 * cardGap, cardGap);
final foundations = List.generate(
4,
(i) => Foundation()
..size = Vector2(cardWidth, cardHeight)
..position =
Vector2((i + 3) * (cardWidth + cardGap) + cardGap, cardGap),
);
final piles = List.generate(
7,
(i) => Pile()
..size = Vector2(cardWidth, cardHeight)
..position = Vector2(
cardGap + i * (cardWidth + cardGap),
cardHeight + 2 * cardGap,
),
);
final world = World()
..add(stock)
..add(waste)
..addAll(foundations)
..addAll(piles);
add(world);
final camera = CameraComponent(world: world)
..viewfinder.visibleGameSize =
Vector2(cardWidth * 7 + cardGap * 8, 4 * cardHeight + 3 * cardGap)
..viewfinder.position = Vector2(cardWidth * 3.5 + cardGap * 4, 0)
..viewfinder.anchor = Anchor.topCenter;
add(camera);
}
}
```
Let's review what's happening here:
* First, we declare constants `cardWidth`, `cardHeight`, and `cardGap` which
describe the size of a card and the distance between cards.
* Then, there is the `onLoad` method that we have had before. It starts with
loading the main image asset, as before (though we are not using it yet).
* After that, we create components `stock`, `waste`, etc., setting their size
and position in the world. The positions are calculated using simple
arithmetics.
* Then we create the main `World` component, add to it all the components
that we just created, and finally add the `world` to the game.
* Lastly, we create a camera object to look at the `world`. Internally, the
camera consists of two parts: a viewport and a viewfinder. The default
viewport is `MaxViewport`, which takes up the entire available screen size --
this is exactly what we need for our game, so no need to change anything. The
viewfinder, on the other hand, needs to be set up to properly take into
account the dimensions of the underlying world.
- We want the entire card layout to be visible on the screen without the
need to scroll. In order to accomplish this, we specify that we want the
entire world size (which is `7*cardWidth + 8*cardGap` by
`4*cardHeight + 3*cardGap`) to be able to fit into the screen. The
`.visibleGameSize` setting ensures that no matter the size of the device,
the zoom level will be adjusted such that the specified chunk of the game
world will be visible.
+ The game size calculation is obtained like this: there are 7 cards in
the tableau and 6 gaps between them, add 2 more "gaps" to account for
padding, and you get the width of `7*cardWidth + 8*cardGap`.
Vertically, there are two rows of cards, but in the bottom row we
need some extra space to be able to display a tall pile -- by my
rough estimate, thrice the height of a card is sufficient for this --
which gives the total height of the game world as
`4*cardHeight + 3*cardGap`.
- Next, we specify which part of the world will be in the "center" of the
viewport. In this case I specify that the "center" of the viewport should
be at the top center of the screen, and the corresponding point within
the game world is at coordinates `[(7*cardWidth + 8*cardGap)/2, 0]`.
The reason for such choice for the viewfinder's position and anchor is
because of how we want it to respond if the game size becomes too wide or
too tall: in case of too wide we want it to be centered on the screen,
but if the screen is too tall, we want the content to be aligned at the
top.
* As a side note, you may be wondering when you need to `await` the result
of `add()`, and when you don't.
The short answer is: usually you don't need to wait, but if you want to, then
it won't hurt either.
If you check the documentation for `.add()` method, you'll see that the
returned future only waits until the component is finished loading, not until
it is actually mounted to the game. As such, you only have to wait for the
future from `.add()` if your logic requires that the component is fully
loaded before it can proceed. This is not very common.
If you don't `await` the future from `.add()`, then the component will be
added to the game anyways, and in the same amount of time.
If you run the game now, you should see the placeholders for where the various
components will be. If you are running the game in the browser, try resizing the
window and see how the game responds to this.
And this is it with this step -- we've created the basic game structure upon
which everything else will be built. In the next step, we'll learn how to render
the card objects, which are the most important visual objects in this game.
```{flutter-app}
:sources: ../tutorials/klondike/app
:page: step2
:show: popup
```
[World]: ../../flame/camera_component.md#world
[Camera]: ../../flame/camera_component.md#cameracomponent

View File

@ -3,11 +3,15 @@
This chapter contains only one tutorial for now, but we'll be adding more soon!
- [](bare_flame_game.md) -- this tutorial focuses on setting up your environment
for making a new Flame game. This "initial state" is assumed as a starting
for making a new Flame game. This "initial state" is assumed as a starting
point for all other tutorials.
- [](klondike/klondike.md) -- in this tutorial we will build the Klondike
solitaire card game.
```{toctree}
:hidden:
Bare Flame game <bare_flame_game.md>
Klondike <klondike/klondike.md>
```

View File

@ -5,6 +5,7 @@ packages:
- packages/**
- examples/**
- tutorials/**
- doc/**
command:
version:

View File

@ -102,7 +102,7 @@ class Viewfinder extends Component implements PositionProvider {
/// Set [zoom] level based on the [_visibleGameSize].
void _initZoom() {
if (isMounted && _visibleGameSize != null) {
if (parent != null && _visibleGameSize != null) {
final viewportSize = camera.viewport.size;
final zoomX = viewportSize.x / _visibleGameSize!.x;
final zoomY = viewportSize.y / _visibleGameSize!.y;