mirror of
https://github.com/flame-engine/flame.git
synced 2025-11-01 10:38:17 +08:00
docs: Tutorial for making a Klondike card game, steps 1 & 2 (#1477)
This commit is contained in:
@ -50,12 +50,14 @@ button.flutter-app-button:after {
|
|||||||
|
|
||||||
#flutter-app-overlay.active iframe {
|
#flutter-app-overlay.active iframe {
|
||||||
border: none;
|
border: none;
|
||||||
box-shadow: 0px 0px 9px 3px black;
|
box-shadow: 0px 0px 30px 0px #ffffff26;
|
||||||
display: none;
|
display: none;
|
||||||
|
height: 80vh;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
|
width: 80vw;
|
||||||
}
|
}
|
||||||
|
|
||||||
#flutter-app-overlay.active iframe.active {
|
#flutter-app-overlay.active iframe.active {
|
||||||
@ -77,6 +79,7 @@ button.flutter-app-button:after {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
top: 10%;
|
top: 10%;
|
||||||
width: 30px;
|
width: 30px;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
#flutter-app-close-button:hover {
|
#flutter-app-close-button:hover {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from docutils import nodes
|
from docutils import nodes
|
||||||
@ -73,14 +74,14 @@ class FlutterAppDirective(SphinxDirective):
|
|||||||
self._process_show_option()
|
self._process_show_option()
|
||||||
self._process_sources_option()
|
self._process_sources_option()
|
||||||
self.source_build_dir = os.path.join(self.source_dir, 'build', 'web')
|
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.html_dir = '_static/apps/' + self.app_name
|
||||||
self.target_dir = os.path.abspath(
|
self.target_dir = os.path.abspath(
|
||||||
os.path.join('..', '_build', 'html', self.html_dir))
|
os.path.join('..', '_build', 'html', self.html_dir))
|
||||||
self._ensure_compiled()
|
self._ensure_compiled()
|
||||||
|
|
||||||
page = self.options.get('page', '')
|
page = self.options.get('page', '')
|
||||||
iframe_url = self.html_dir + '/index.html?' + page
|
iframe_url = '/' + self.html_dir + '/index.html?' + page
|
||||||
result = []
|
result = []
|
||||||
if 'popup' in self.modes:
|
if 'popup' in self.modes:
|
||||||
result.append(Button(
|
result.append(Button(
|
||||||
@ -117,6 +118,10 @@ class FlutterAppDirective(SphinxDirective):
|
|||||||
assert not abspath.endswith('/')
|
assert not abspath.endswith('/')
|
||||||
self.source_dir = abspath
|
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):
|
def _ensure_compiled(self):
|
||||||
need_compiling = (
|
need_compiling = (
|
||||||
('popup' in self.modes or 'widget' in self.modes) and
|
('popup' in self.modes or 'widget' in self.modes) and
|
||||||
@ -143,8 +148,8 @@ class FlutterAppDirective(SphinxDirective):
|
|||||||
check=True,
|
check=True,
|
||||||
)
|
)
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
cmd = e.cmd.join(' ')
|
cmd = ' '.join(e.cmd)
|
||||||
raise self.severe(
|
raise self.error(
|
||||||
f'Command `{cmd}` returned with exit status {e.returncode}\n' +
|
f'Command `{cmd}` returned with exit status {e.returncode}\n' +
|
||||||
e.output.decode('utf-8'),
|
e.output.decode('utf-8'),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -71,6 +71,14 @@ div.expander {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
p + ul {
|
||||||
|
margin-top: -0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul ul.simple p {
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/*----------------------------------------------------------------------------*
|
/*----------------------------------------------------------------------------*
|
||||||
* Top navigation bar
|
* Top navigation bar
|
||||||
@ -587,6 +595,7 @@ pre {
|
|||||||
border-radius: 0.4em;
|
border-radius: 0.4em;
|
||||||
box-shadow: 0px 0px 3px black;
|
box-shadow: 0px 0px 3px black;
|
||||||
color: #888;
|
color: #888;
|
||||||
|
font-family: var(--font-mono);
|
||||||
font-size: 85%;
|
font-size: 85%;
|
||||||
line-height: 125%;
|
line-height: 125%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
BIN
doc/images/tutorials/klondike-sketch.webp
Normal file
BIN
doc/images/tutorials/klondike-sketch.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
1
doc/tutorials/klondike/app/analysis_options.yaml
Normal file
1
doc/tutorials/klondike/app/analysis_options.yaml
Normal file
@ -0,0 +1 @@
|
|||||||
|
include: package:flame_lint/analysis_options.yaml
|
||||||
BIN
doc/tutorials/klondike/app/assets/images/klondike-sprites.png
Normal file
BIN
doc/tutorials/klondike/app/assets/images/klondike-sprites.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 468 KiB |
23
doc/tutorials/klondike/app/lib/main.dart
Normal file
23
doc/tutorials/klondike/app/lib/main.dart
Normal 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"'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
import 'package:flame/components.dart';
|
||||||
|
|
||||||
|
class Foundation extends PositionComponent {
|
||||||
|
@override
|
||||||
|
bool get debugMode => true;
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
import 'package:flame/components.dart';
|
||||||
|
|
||||||
|
class Pile extends PositionComponent {
|
||||||
|
@override
|
||||||
|
bool get debugMode => true;
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
import 'package:flame/components.dart';
|
||||||
|
|
||||||
|
class Stock extends PositionComponent {
|
||||||
|
@override
|
||||||
|
bool get debugMode => true;
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
import 'package:flame/components.dart';
|
||||||
|
|
||||||
|
class Waste extends PositionComponent {
|
||||||
|
@override
|
||||||
|
bool get debugMode => true;
|
||||||
|
}
|
||||||
56
doc/tutorials/klondike/app/lib/step2/klondike_game.dart
Normal file
56
doc/tutorials/klondike/app/lib/step2/klondike_game.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
doc/tutorials/klondike/app/lib/step2/main.dart
Normal file
9
doc/tutorials/klondike/app/lib/step2/main.dart
Normal 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));
|
||||||
|
}
|
||||||
17
doc/tutorials/klondike/app/pubspec.yaml
Normal file
17
doc/tutorials/klondike/app/pubspec.yaml
Normal 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/
|
||||||
26
doc/tutorials/klondike/app/web/index.html
Normal file
26
doc/tutorials/klondike/app/web/index.html
Normal 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>
|
||||||
18
doc/tutorials/klondike/klondike.md
Normal file
18
doc/tutorials/klondike/klondike.md
Normal 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>
|
||||||
|
```
|
||||||
108
doc/tutorials/klondike/step1.md
Normal file
108
doc/tutorials/klondike/step1.md
Normal 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:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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):
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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
|
||||||
265
doc/tutorials/klondike/step2.md
Normal file
265
doc/tutorials/klondike/step2.md
Normal 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
|
||||||
@ -6,8 +6,12 @@ This chapter contains only one tutorial for now, but we'll be adding more soon!
|
|||||||
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.
|
point for all other tutorials.
|
||||||
|
|
||||||
|
- [](klondike/klondike.md) -- in this tutorial we will build the Klondike
|
||||||
|
solitaire card game.
|
||||||
|
|
||||||
```{toctree}
|
```{toctree}
|
||||||
:hidden:
|
:hidden:
|
||||||
|
|
||||||
Bare Flame game <bare_flame_game.md>
|
Bare Flame game <bare_flame_game.md>
|
||||||
|
Klondike <klondike/klondike.md>
|
||||||
```
|
```
|
||||||
|
|||||||
@ -5,6 +5,7 @@ packages:
|
|||||||
- packages/**
|
- packages/**
|
||||||
- examples/**
|
- examples/**
|
||||||
- tutorials/**
|
- tutorials/**
|
||||||
|
- doc/**
|
||||||
|
|
||||||
command:
|
command:
|
||||||
version:
|
version:
|
||||||
|
|||||||
@ -102,7 +102,7 @@ class Viewfinder extends Component implements PositionProvider {
|
|||||||
|
|
||||||
/// Set [zoom] level based on the [_visibleGameSize].
|
/// Set [zoom] level based on the [_visibleGameSize].
|
||||||
void _initZoom() {
|
void _initZoom() {
|
||||||
if (isMounted && _visibleGameSize != null) {
|
if (parent != null && _visibleGameSize != null) {
|
||||||
final viewportSize = camera.viewport.size;
|
final viewportSize = camera.viewport.size;
|
||||||
final zoomX = viewportSize.x / _visibleGameSize!.x;
|
final zoomX = viewportSize.x / _visibleGameSize!.x;
|
||||||
final zoomY = viewportSize.y / _visibleGameSize!.y;
|
final zoomY = viewportSize.y / _visibleGameSize!.y;
|
||||||
|
|||||||
Reference in New Issue
Block a user