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:
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:
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
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:
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:
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
, andcardGap
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 theworld
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 isMaxViewport
, 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
by4*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 as4*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 ofadd()
, 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.