4. Gameplay

In this chapter we will be implementing the core of Klondike’s gameplay: how the cards move between the stock and the waste, the piles and the foundations.

Before we begin though, let’s clean up all those cards that we left scattered across the table in the previous chapter. Open the KlondikeGame class and erase the loop at the bottom of onLoad() that was adding 28 cards onto the table.

The piles

Another small refactoring that we need to do is to rename our components: StockStockPile, WasteWastePile, FoundationFoundationPile, and PileTableauPile. This is because these components have some common features in how they handle interactions with the cards, and it would be convenient to have all of them implement a common API. We will call the interface that they will all be implementing the Pile class.

Note

Refactors and changes in architecture happen during development all the time: it’s almost impossible to get the structure right on the first try. Do not be anxious about changing code that you have written in the past: it is a good habit to have.

After such a rename, we can begin implementing each of these components.

Stock pile

The stock is a place in the top-left corner of the playing field which holds the cards that are not currently in play. We will need to build the following functionality for this component:

  1. Ability to hold cards that are not currently in play, face down;

  2. Tapping the stock should reveal top 3 cards and move them to the waste pile;

  3. When the cards run out, there should be a visual indicating that this is the stock pile;

  4. When the cards run out, tapping the empty stock should move all the cards from the waste pile into the stock, turning them face down.

The first question that needs to be decided here is this: who is going to own the Card components? Previously we have been adding them directly to the game field, but now wouldn’t it be better to say that the cards belong to the Stock component, or to the waste, or piles, or foundations? While this approach is tempting, I believe it would make our life more complicated as we need to move a card from one place to another.

So, I decided to stick with my first approach: the Card components are owned directly by the KlondikeGame itself, whereas the StockPile and other piles are merely aware of which cards are currently placed there.

Having this in mind, let’s start implementing the StockPile component:

class StockPile extends PositionComponent {
  StockPile({super.position}) : super(size: KlondikeGame.cardSize);

  /// Which cards are currently placed onto this pile. The first card in the
  /// list is at the bottom, the last card is on top.
  final List<Card> _cards = [];

  void acquireCard(Card card) {
    assert(!card.isFaceUp);
    card.position = position;
    card.priority = _cards.length;
    _cards.add(card);
  }
}

Here the acquireCard() method stores the provided card into the internal list _cards; it also moves that card to the StockPile’s position and adjusts the cards priority so that they are displayed in the right order. However, this method does not mount the card as a child of the StockPile component – it remains belonging to the top-level game.

Speaking of the game class, let’s open the KlondikeGame and add the following lines to create a full deck of 52 cards and put them onto the stock pile (this should be added at the end of the onLoad method):

final cards = [
  for (var rank = 1; rank <= 13; rank++)
    for (var suit = 0; suit < 4; suit++)
      Card(rank, suit)
];
world.addAll(cards);
cards.forEach(stock.acquireCard);

This concludes the first step of our short plan at the beginning of this section. For the second step, though, we need to have a waste pile – so let’s make a quick detour and implement the WastePile class.

Waste pile

The waste is a pile next to the stock. During the course of the game we will be taking the cards from the top of the stock pile and putting them into the waste. The functionality of this class is quite simple: it holds a certain number of cards face up, fanning out the top 3.

Let’s start implementing the WastePile class same way as we did with the StockPile class, only now the cards are expected to be face up:

class WastePile extends PositionComponent {
  WastePile({super.position}) : super(size: KlondikeGame.cardSize);

  final List<Card> _cards = [];

  void acquireCard(Card card) {
    assert(card.isFaceUp);
    card.position = position;
    card.priority = _cards.length;
    _cards.add(card);
  }
}

So far, this puts all cards into a single neat pile, whereas we wanted a fan-out of top three. So, let’s add a dedicated method _fanOutTopCards() for this, which we will call at the end of each acquireCard():

  void _fanOutTopCards() {
    final n = _cards.length;
    for (var i = 0; i < n; i++) {
      _cards[i].position = position;
    }
    if (n == 2) {
      _cards[1].position.add(_fanOffset);
    } else if (n >= 3) {
      _cards[n - 2].position.add(_fanOffset);
      _cards[n - 1].position.addScaled(_fanOffset, 2);
    }
  }

The _fanOffset variable here helps determine the shift between cards in the fan, which I decided to be about 20% of the card’s width:

  final Vector2 _fanOffset = Vector2(KlondikeGame.cardWidth * 0.2, 0);

Now that the waste pile is ready, let’s get back to the StockPile.

Stock pile – tap to deal cards

The second item on our todo list is the first interactive functionality in the game: tap the stock pile to deal 3 cards onto the waste.

Adding tap functionality to the components in Flame is quite simple: we just add the mixin TapCallbacks to the component that we want to be tappable:

class StockPile extends PositionComponent with TapCallbacks { ... }

Oh, and we also need to say what we want to happen when the tap occurs. Here we want the top 3 cards to be turned face up and moved to the waste pile. So, add the following method to the StockPile class:

  @override
  void onTapUp(TapUpEvent event) {
  final wastePile = parent!.firstChild<WastePile>()!;
    for (var i = 0; i < 3; i++) {
      if (_cards.isNotEmpty) {
        final card = _cards.removeLast();
        card.flip();
        wastePile.acquireCard(card);
      }
    }
  }

You have probably noticed that the cards move from one pile to another immediately, which looks very unnatural. However, this is how it is going to be for now – we will defer making the game more smooth till the next chapter of the tutorial.

Also, the cards are organized in a well-defined order right now, starting from Kings and ending with Aces. This doesn’t make a very exciting gameplay though, so add line

    cards.shuffle();

in the KlondikeGame class right after the list of cards is created.

See also

For more information about tap functionality, see Tap Events.

Stock pile – visual representation

Currently, when the stock pile has no cards, it simply shows an empty space – there is no visual cue that this is where the stock is. Such cue is needed, though, because we want the user to be able to click the stock pile when it is empty in order to move all the cards from the waste back to the stock so that they can be dealt again.

In our case, the empty stock pile will have a card-like border, and a circle in the middle:

  @override
  void render(Canvas canvas) {
    canvas.drawRRect(KlondikeGame.cardRRect, _borderPaint);
    canvas.drawCircle(
      Offset(width / 2, height / 2),
      KlondikeGame.cardWidth * 0.3,
      _circlePaint,
    );
  }

where the paints are defined as

  final _borderPaint = Paint()
    ..style = PaintingStyle.stroke
    ..strokeWidth = 10
    ..color = const Color(0xFF3F5B5D);
  final _circlePaint = Paint()
    ..style = PaintingStyle.stroke
    ..strokeWidth = 100
    ..color = const Color(0x883F5B5D);

and the cardRRect in the KlondikeGame class as

  static final cardRRect = RRect.fromRectAndRadius(
    const Rect.fromLTWH(0, 0, cardWidth, cardHeight),
    const Radius.circular(cardRadius),
  );

Now when you click through the stock pile till the end, you should be able to see the placeholder for the stock cards.

Stock pile – refill from the waste

The last piece of functionality to add, is to move the cards back from the waste pile into the stock pile when the user taps on an empty stock. To implement this, we will modify the onTapUp() method like so:

  @override
  void onTapUp(TapUpEvent event) {
    final wastePile = parent!.firstChild<WastePile>()!;
    if (_cards.isEmpty) {
      wastePile.removeAllCards().reversed.forEach((card) {
        card.flip();
        acquireCard(card);
      });
    } else {
      for (var i = 0; i < 3; i++) {
        if (_cards.isNotEmpty) {
          final card = _cards.removeLast();
          card.flip();
          wastePile.acquireCard(card);
        }
      }
    }
  }

If you’re curious why we needed to reverse the list of cards removed from the waste pile, then it is because we want to simulate the entire waste pile being turned over at once, and not each card being flipped one by one in their places. You can check that this is working as intended by verifying that on each subsequent run through the stock pile, the cards are dealt in the same order as they were dealt in the first run.

The method WastePile.removeAllCards() still needs to be implemented though:

  List<Card> removeAllCards() {
    final cards = _cards.toList();
    _cards.clear();
    return cards;
  }

This pretty much concludes the StockPile functionality, and we already implemented the WastePile – so the only two components remaining are the FoundationPile and the TableauPile. We’ll start with the first one because it looks simpler.

Foundation piles

The foundation piles are the four piles in the top right corner of the game. This is where we will be building the ordered runs of cards from Ace to King. The functionality of this class is similar to the StockPile and the WastePile: it has to be able to hold cards face up, and there has to be some visual to show where the foundation is when there are no cards there.

First, let’s implement the card-holding logic:

class FoundationPile extends PositionComponent {
  FoundationPile({super.position}) : super(size: KlondikeGame.cardSize);

  final List<Card> _cards = [];

  void acquireCard(Card card) {
    assert(card.isFaceUp);
    card.position = position;
    card.priority = _cards.length;
    _cards.add(card);
  }
}

For visual representation of a foundation, I’ve decided to make a large icon of that foundation’s suit, in grey color. Which means we’d need to update the definition of the class to include the suit information:

class FoundationPile extends PositionComponent {
  FoundationPile(int intSuit, {super.position})
      : suit = Suit.fromInt(intSuit),
        super(size: KlondikeGame.cardSize);

  final Suit suit;
  ...
}

The code in the KlondikeGame class that generates the foundations will have to be adjusted accordingly in order to pass the suit index to each foundation.

Now, the rendering code for the foundation pile will look like this:

  @override
  void render(Canvas canvas) {
    canvas.drawRRect(KlondikeGame.cardRRect, _borderPaint);
    suit.sprite.render(
      canvas,
      position: size / 2,
      anchor: Anchor.center,
      size: Vector2.all(KlondikeGame.cardWidth * 0.6),
      overridePaint: _suitPaint,
    );
  }

Here we need to have two paint objects, one for the border and one for the suits:

  final _borderPaint = Paint()
    ..style = PaintingStyle.stroke
    ..strokeWidth = 10
    ..color = const Color(0x50ffffff);
  late final _suitPaint = Paint()
    ..color = suit.isRed? const Color(0x3a000000) : const Color(0x64000000)
    ..blendMode = BlendMode.luminosity;

The suit paint uses BlendMode.luminosity in order to convert the regular yellow/blue colors of the suit sprites into grayscale. The “color” of the paint is different depending whether the suit is red or black because the original luminosity of those sprites is different. Therefore, I had to pick two different colors in order to make them look the same in grayscale.

Tableau Piles

The last piece of the game to be implemented is the TableauPile component. There are seven of these piles in total, and they are where the majority of the game play is happening.

The TableauPile also needs a visual representation, in order to indicate that it’s a place where a King can be placed when it is empty. I believe it could be just an empty frame, and that should be sufficient:

class TableauPile extends PositionComponent {
  TableauPile({super.position}) : super(size: KlondikeGame.cardSize);

  final _borderPaint = Paint()
    ..style = PaintingStyle.stroke
    ..strokeWidth = 10
    ..color = const Color(0x50ffffff);

  @override
  void render(Canvas canvas) {
    canvas.drawRRect(KlondikeGame.cardRRect, _borderPaint);
  }
}

Oh, and the class will need to be able hold the cards too, obviously. Here, some of the cards will be face down, while others will be face up. Also we will need a small amount of vertical fanning, similar to how we did it for the WastePile component:

  /// Which cards are currently placed onto this pile.
  final List<Card> _cards = [];
  final Vector2 _fanOffset = Vector2(0, KlondikeGame.cardHeight * 0.05);

  void acquireCard(Card card) {
    if (_cards.isEmpty) {
      card.position = position;
    } else {
      card.position = _cards.last.position + _fanOffset;
    }
    card.priority = _cards.length;
    _cards.add(card);
  }

All that remains now is to head over to the KlondikeGame and make sure that the cards are dealt into the TableauPiles at the beginning of the game. Modify the code at the end of the onLoad() method so that it looks like this:

  @override
  Future<void> onLoad() async {
    ...

    final cards = [
      for (var rank = 1; rank <= 13; rank++)
        for (var suit = 0; suit < 4; suit++)
          Card(rank, suit)
    ];
    cards.shuffle();
    world.addAll(cards);

    int cardToDeal = cards.length - 1;
    for (var i = 0; i < 7; i++) {
      for (var j = i; j < 7; j++) {
        piles[j].acquireCard(cards[cardToDeal--]);
      }
      piles[i].flipTopCard();
    }
    for(int n = 0; n <= cardToDeal; n++) {
      stock.acquireCard(cards[n]);
    }
  }

Note how we deal the cards from the deck and place them into TableauPiles one by one, and only after that we put the remaining cards into the stock.

Recall that we decided earlier that all the cards would be owned by the KlondikeGame itself. So they are put into a generated List structure called cards, shuffled and added to the world. This List should always have 52 cards in it, so a descending index cardToDeal is used to deal 28 cards one by one from the top of the deck into piles that acquire references to the cards in the deck. An ascending index is used to deal the remaining 24 cards into the stock in correct shuffled order. At the end of the deal there are still 52 Card objects in the cards list. In the card piles we used removeList() to retrieve a card from a pile, but not here because it would remove cards from KlondikeGame’s ownership.

The flipTopCard method in the TableauPile class is as trivial as it sounds:

  void flipTopCard() {
    assert(_cards.last.isFaceDown);
    _cards.last.flip();
  }

If you run the game at this point, it would be nicely set up and look as if it was ready to play. Except that we can’t move the cards yet, which is kinda a deal-breaker here. So without further ado, presenting you the next section:

Moving the cards

Moving the cards is a somewhat more complicated topic than what we have had so far. We will split it into several smaller steps:

  1. Simple movement: grab a card and move it around.

  2. Ensure that the user can only move the cards that they are allowed to.

  3. Check that the cards are dropped at proper destinations.

  4. Drag a run of cards.

1. Simple movement

So, we want to be able to drag the cards on the screen. This is even simpler than making the StockPile tappable: just head over into the Card class and add the DragCallbacks mixin:

class Card extends PositionComponent with DragCallbacks {
}

The next step is to implement the actual drag event callbacks: onDragStart, onDragUpdate, and onDragEnd.

When the drag gesture is initiated, the first thing that we need to do is to raise the priority of the card, so that it is rendered above all others. Without this, the card would be occasionally “sliding beneath” other cards, which would look most unnatural:

  @override
  void onDragStart(DragStartEvent event) {
    priority = 100;
  }

During the drag, the onDragUpdate event will be called continuously. Using this callback we will be updating the position of the card so that it follows the movement of the finger (or the mouse). The event object passed to this callback contains the most recent coordinate of the point of touch, and also the localDelta property – which is the displacement vector since the previous call of onDragUpdate, considering the camera zoom.

  @override
  void onDragUpdate(DragUpdateEvent event) {
    position += event.delta;
  }

So far this allows you to grab any card and drag it anywhere around the table. What we want, however, is to be able to restrict where the card is allowed or not allowed to go. This is where the core of the logic of the game begins.

2. Move only allowed cards

The first restriction that we impose is that the user should only be able to drag the cards that we allow, which include: (1) the top card of a waste pile, (2) the top card of a foundation pile, and (3) any face-up card in a tableau pile.

Thus, in order to determine whether a card can be moved or not, we need to know which pile it currently belongs to. There could be several ways that we go about it, but seemingly the most straightforward is to let every card keep a reference to the pile in which it currently resides.

So, let’s start by defining the abstract interface Pile that all our existing piles will be implementing:

abstract class Pile {
  bool canMoveCard(Card card);
}

We will expand this class further later, but for now let’s make sure that each of the classes StockPile, WastePile, FoundationPile, and TableauPile are marked as implementing this interface:

class StockPile extends PositionComponent with TapCallbacks implements Pile {
  ...
  @override
  bool canMoveCard(Card card) => false;
}

class WastePile extends PositionComponent implements Pile {
  ...
  @override
  bool canMoveCard(Card card) => _cards.isNotEmpty && card == _cards.last;
}

class FoundationPile extends PositionComponent implements Pile {
  ...
  @override
  bool canMoveCard(Card card) => _cards.isNotEmpty && card == _cards.last;
}

class TableauPile extends PositionComponent implements Pile {
  ...
  @override
  bool canMoveCard(Card card) => _cards.isNotEmpty && card == _cards.last;
}

We also wanted to let every Card know which pile it is currently in. For this, add the field Pile? pile into the Card class, and make sure to set it in each pile’s acquireCard() method, like so:

  void acquireCard(Card card) {
    ...
    card.pile = this;
  }

Now we can put this new functionality to use: go into the Card.onDragStart() method and modify it so that it would check whether the card is allowed to be moved before starting the drag:

  void onDragStart(DragStartEvent event) {
    if (pile?.canMoveCard(this) ?? false) {
      super.onDragStart(event);
      priority = 100;
    }
  }

We have also added a call to super.onDragStart() which sets an _isDragged variable to true in the DragCallbacks mixin, we need to check this flag via the public isDragged getter in the onDragUpdate() method and use super.onDragEnd() in onDragEnd() so the flag is set back to false:

  @override
  void onDragUpdate(DragUpdateEvent event) {
    if (!isDragged) {
      return;
    }
    position += event.delta;
  }

  @override
  void onDragEnd(DragEndEvent event) {
    super.onDragEnd(event);
  }

Now only the proper cards can be dragged, but they still drop at random positions on the table, so let’s work on that.

3. Dropping the cards at proper locations

At this point what we want to do is to figure out where the dragged card is being dropped. More specifically, we want to know into which pile it is being dropped. This can be achieved by using the componentsAtPoint() API, which allows you to query which components are located at a given position on the screen.

Thus, my first attempt at revising the onDragEnd callback looks like this:

  @override
  void onDragEnd(DragEndEvent event) {
    if (!isDragged) {
      return;
    }
    super.onDragEnd(event);
    final dropPiles = parent!
        .componentsAtPoint(position + size / 2)
        .whereType<Pile>()
        .toList();
    if (dropPiles.isNotEmpty) {
      // if (card is allowed to be dropped into this pile) {
      //   remove the card from the current pile
      //   add the card into the new pile
      // }
    }
    // return the card to where it was originally
  }

This still contains several placeholders for the functionality that still needs to be implemented, so let’s get to it.

First piece of the puzzle is the “is card allowed to be dropped here?” check. To implement this, first head over into the Pile class and add the canAcceptCard() abstract method:

abstract class Pile {
  ...
  bool canAcceptCard(Card card);
}

Obviously this now needs to be implemented for every Pile subclass, so let’s get to it:

class FoundationPile ... implements Pile {
  ...
  @override
  bool canAcceptCard(Card card) {
    final topCardRank = _cards.isEmpty? 0 : _cards.last.rank.value;
    return card.suit == suit && card.rank.value == topCardRank + 1;
  }
}

class TableauPile ... implements Pile {
  ...
  @override
  bool canAcceptCard(Card card) {
    if (_cards.isEmpty) {
      return card.rank.value == 13;
    } else {
      final topCard = _cards.last;
      return card.suit.isRed == !topCard.suit.isRed &&
          card.rank.value == topCard.rank.value - 1;
    }
  }
}

(for the StockPile and the WastePile the method should just return false, since no cards should be dropped there).

Alright, next part is the “remove the card from its current pile”. Once again, let’s head over to the Pile class and add the removeCard() abstract method:

abstract class Pile {
  ...
  void removeCard(Card card);
}

Then we need to re-visit all four pile subclasses and implement this method:

class StockPile ... implements Pile {
  ...
  @override
  void removeCard(Card card) => throw StateError('cannot remove cards from here');
}

class WastePile ... implements Pile {
  ...
  @override
  void removeCard(Card card) {
    assert(canMoveCard(card));
    _cards.removeLast();
    _fanOutTopCards();
  }
}

class FoundationPile ... implements Pile {
  ...
  @override
  void removeCard(Card card) {
    assert(canMoveCard(card));
    _cards.removeLast();
  }
}

class TableauPile ... implements Pile {
  ...
  @override
  void removeCard(Card card) {
    assert(_cards.contains(card) && card.isFaceUp);
    final index = _cards.indexOf(card);
    _cards.removeRange(index, _cards.length);
    if (_cards.isNotEmpty && _cards.last.isFaceDown) {
      flipTopCard();
    }
  }
}

The next action in our pseudo-code is to “add the card to the new pile”. But this one we have already implemented: it’s the acquireCard() method. So all we need is to declare it in the Pile interface:

abstract class Pile {
  ...
  void acquireCard(Card card);
}

The last piece that’s missing is “return the card to where it was”. You can probably guess how we are going to go about this one: add the returnCard() method into the Pile interface, and then implement this method in all four pile subclasses:

class StockPile ... implements Pile {
  ...
  @override
  void returnCard(Card card) => throw StateError('cannot remove cards from here');
}

class WastePile ... implements Pile {
  ...
  @override
  void returnCard(Card card) {
    card.priority = _cards.indexOf(card);
    _fanOutTopCards();
  }
}

class FoundationPile ... implements Pile {
  ...
  @override
  void returnCard(Card card) {
    card.position = position;
    card.priority = _cards.indexOf(card);
  }
}

class TableauPile ... implements Pile {
  ...
  @override
  void returnCard(Card card) {
    final index = _cards.indexOf(card);
    card.position =
        index == 0 ? position : _cards[index - 1].position + _fanOffset;
    card.priority = index;
  }
}

Now, putting this all together, the Card’s onDragEnd method will look like this:

  @override
  void onDragEnd(DragEndEvent event) {
    if (!isDragged) {
      return;
    }
    super.onDragEnd(event);
    final dropPiles = parent!
        .componentsAtPoint(position + size / 2)
        .whereType<Pile>()
        .toList();
    if (dropPiles.isNotEmpty) {
      if (dropPiles.first.canAcceptCard(this)) {
        pile!.removeCard(this);
        dropPiles.first.acquireCard(this);
        return;
      }
    }
    pile!.returnCard(this);
  }

Ok, that was quite a lot of work – but if you run the game now, you’d be able to move the cards properly from one pile to another, and they will never go where they are not supposed to go. The only thing that remains is to be able to move multiple cards at once between tableau piles. So take a short break, and then on to the next section!

4. Moving a run of cards

In this section we will be implementing the necessary changes to allow us to move small stacks of cards between the tableau piles. Before we begin, though, we need to make a small fix first.

You have probably noticed when running the game in the previous section that the cards in the tableau piles clamp too closely together. That is, they are at the correct distance when they face down, but they should be at a larger distance when they face up, which is not currently the case. This makes it really difficult to see which cards are available for dragging.

So, let’s head over into the TableauPile class and create a new method layOutCards(), whose job would be to ensure that all cards currently in the pile have the right positions:

  final Vector2 _fanOffset1 = Vector2(0, KlondikeGame.cardHeight * 0.05);
  final Vector2 _fanOffset2 = Vector2(0, KlondikeGame.cardHeight * 0.20);

  void layOutCards() {
    if (_cards.isEmpty) {
      return;
    }
    _cards[0].position.setFrom(position);
    for (var i = 1; i < _cards.length; i++) {
      _cards[i].position
        ..setFrom(_cards[i - 1].position)
        ..add(_cards[i - 1].isFaceDown ? _fanOffset1 : _fanOffset2);
    }
  }

Make sure to call this method at the end of removeCard(), returnCard(), and acquireCard() – replacing any current logic that handles card positioning.

Another problem that you may have noticed is that for taller card stacks it becomes hard to place a card there. This is because our logic for determining in which pile the card is being dropped checks whether the center of the card is inside any of the TableauPile components – but those components have only the size of a single card! To fix this inconsistency, all we need is to declare that the height of the tableau pile is at least as tall as all the cards in it, or even higher. Add this line at the end of the layOutCards() method:

    height = KlondikeGame.cardHeight * 1.5 + _cards.last.y - _cards.first.y;

The factor 1.5 here adds a little bit extra space at the bottom of each pile. The card to be dropped should be overlapping the hitbox by a little over half its width and height. If you are approaching from below, it would be just overlapping the nearest card (i.e. the one that is fully visible). You can temporarily turn the debug mode on to see the hitboxes.

Illustration of Tableau Pile Hitboxes

Ok, let’s get to our main topic: how to move a stack of cards at once.

First thing that we’re going to add is the list of attachedCards for every card. This list will be non-empty only when the card is being dragged while having other cards on top. Add the following declaration to the Card class:

  final List<Card> attachedCards = [];

Now, in order to create this list in onDragStart, we need to query the TableauPile for the list of cards that are on top of the given card. Let’s add such a method into the TableauPile class:

  List<Card> cardsOnTop(Card card) {
    assert(card.isFaceUp && _cards.contains(card));
    final index = _cards.indexOf(card);
    return _cards.getRange(index + 1, _cards.length).toList();
  }

While we are in the TableauPile class, let’s also update the canMoveCard() method to allow dragging cards that are not necessarily on top:

  @override
  bool canMoveCard(Card card) => card.isFaceUp;

Heading back into the Card class, we can use this method in order to populate the list of attachedCards when the card starts to move:

  @override
  void onDragStart(DragStartEvent event) {
    if (pile?.canMoveCard(this) ?? false) {
      super.onDragStart();
      priority = 100;
      if (pile is TableauPile) {
        attachedCards.clear();
        final extraCards = (pile! as TableauPile).cardsOnTop(this);
        for (final card in extraCards) {
          card.priority = attachedCards.length + 101;
          attachedCards.add(card);
        }
      }
    }
  }

Now all we need to do is to make sure that the attached cards are also moved with the main card in the onDragUpdate method:

  @override
  void onDragUpdate(DragUpdateEvent event) {
    if (!isDragged) {
      return;
    }
    final delta = event.delta;
    position.add(delta);
    attachedCards.forEach((card) => card.position.add(delta));
  }

This does the trick, almost. All that remains is to fix any loose ends. For example, we don’t want to let the user drop a stack of cards onto a foundation pile, so let’s head over into the FoundationPile class and modify the canAcceptCard() method accordingly:

  @override
  bool canAcceptCard(Card card) {
    final topCardRank = _cards.isEmpty ? 0 : _cards.last.rank.value;
    return card.suit == suit &&
        card.rank.value == topCardRank + 1 &&
        card.attachedCards.isEmpty;
  }

Secondly, we need to properly take care of the stack of card as it is being dropped into a tableau pile. So, go back into the Card class and update its onDragEnd() method to also move the attached cards into the pile, and the same when it comes to returning the cards into the old pile:

  @override
  void onDragEnd(DragEndEvent event) {
    if (!isDragged) {
      return;
    }
    super.onDragEnd(event);
    final dropPiles = parent!
        .componentsAtPoint(position + size / 2)
        .whereType<Pile>()
        .toList();
    if (dropPiles.isNotEmpty) {
      if (dropPiles.first.canAcceptCard(this)) {
        pile!.removeCard(this);
        dropPiles.first.acquireCard(this);
        if (attachedCards.isNotEmpty) {
          attachedCards.forEach((card) => dropPiles.first.acquireCard(card));
          attachedCards.clear();
        }
        return;
      }
    }
    pile!.returnCard(this);
    if (attachedCards.isNotEmpty) {
      attachedCards.forEach((card) => pile!.returnCard(card));
      attachedCards.clear();
    }
  }

Well, this is it! The game is now fully playable. Press the button below to see what the resulting code looks like, or to play it live. In the next section we will discuss how to make it more animated with the help of effects.

components/card.dart
  1import 'dart:math';
  2import 'dart:ui';
  3
  4import 'package:flame/components.dart';
  5import 'package:flame/events.dart';
  6import '../klondike_game.dart';
  7import '../pile.dart';
  8import '../rank.dart';
  9import '../suit.dart';
 10import 'tableau_pile.dart';
 11
 12class Card extends PositionComponent with DragCallbacks {
 13  Card(int intRank, int intSuit)
 14      : rank = Rank.fromInt(intRank),
 15        suit = Suit.fromInt(intSuit),
 16        super(size: KlondikeGame.cardSize);
 17
 18  final Rank rank;
 19  final Suit suit;
 20  Pile? pile;
 21  bool _faceUp = false;
 22  bool _isDragging = false;
 23  final List<Card> attachedCards = [];
 24
 25  bool get isFaceUp => _faceUp;
 26  bool get isFaceDown => !_faceUp;
 27  void flip() => _faceUp = !_faceUp;
 28
 29  @override
 30  String toString() => rank.label + suit.label; // e.g. "Q♠" or "10♦"
 31
 32  //#region Rendering
 33
 34  @override
 35  void render(Canvas canvas) {
 36    if (_faceUp) {
 37      _renderFront(canvas);
 38    } else {
 39      _renderBack(canvas);
 40    }
 41  }
 42
 43  static final Paint backBackgroundPaint = Paint()
 44    ..color = const Color(0xff380c02);
 45  static final Paint backBorderPaint1 = Paint()
 46    ..color = const Color(0xffdbaf58)
 47    ..style = PaintingStyle.stroke
 48    ..strokeWidth = 10;
 49  static final Paint backBorderPaint2 = Paint()
 50    ..color = const Color(0x5CEF971B)
 51    ..style = PaintingStyle.stroke
 52    ..strokeWidth = 35;
 53  static final RRect cardRRect = RRect.fromRectAndRadius(
 54    KlondikeGame.cardSize.toRect(),
 55    const Radius.circular(KlondikeGame.cardRadius),
 56  );
 57  static final RRect backRRectInner = cardRRect.deflate(40);
 58  static final Sprite flameSprite = klondikeSprite(1367, 6, 357, 501);
 59
 60  void _renderBack(Canvas canvas) {
 61    canvas.drawRRect(cardRRect, backBackgroundPaint);
 62    canvas.drawRRect(cardRRect, backBorderPaint1);
 63    canvas.drawRRect(backRRectInner, backBorderPaint2);
 64    flameSprite.render(canvas, position: size / 2, anchor: Anchor.center);
 65  }
 66
 67  static final Paint frontBackgroundPaint = Paint()
 68    ..color = const Color(0xff000000);
 69  static final Paint redBorderPaint = Paint()
 70    ..color = const Color(0xffece8a3)
 71    ..style = PaintingStyle.stroke
 72    ..strokeWidth = 10;
 73  static final Paint blackBorderPaint = Paint()
 74    ..color = const Color(0xff7ab2e8)
 75    ..style = PaintingStyle.stroke
 76    ..strokeWidth = 10;
 77  static final blueFilter = Paint()
 78    ..colorFilter = const ColorFilter.mode(
 79      Color(0x880d8bff),
 80      BlendMode.srcATop,
 81    );
 82  static final Sprite redJack = klondikeSprite(81, 565, 562, 488);
 83  static final Sprite redQueen = klondikeSprite(717, 541, 486, 515);
 84  static final Sprite redKing = klondikeSprite(1305, 532, 407, 549);
 85  static final Sprite blackJack = klondikeSprite(81, 565, 562, 488)
 86    ..paint = blueFilter;
 87  static final Sprite blackQueen = klondikeSprite(717, 541, 486, 515)
 88    ..paint = blueFilter;
 89  static final Sprite blackKing = klondikeSprite(1305, 532, 407, 549)
 90    ..paint = blueFilter;
 91
 92  void _renderFront(Canvas canvas) {
 93    canvas.drawRRect(cardRRect, frontBackgroundPaint);
 94    canvas.drawRRect(
 95      cardRRect,
 96      suit.isRed ? redBorderPaint : blackBorderPaint,
 97    );
 98
 99    final rankSprite = suit.isBlack ? rank.blackSprite : rank.redSprite;
100    final suitSprite = suit.sprite;
101    _drawSprite(canvas, rankSprite, 0.1, 0.08);
102    _drawSprite(canvas, suitSprite, 0.1, 0.18, scale: 0.5);
103    _drawSprite(canvas, rankSprite, 0.1, 0.08, rotate: true);
104    _drawSprite(canvas, suitSprite, 0.1, 0.18, scale: 0.5, rotate: true);
105    switch (rank.value) {
106      case 1:
107        _drawSprite(canvas, suitSprite, 0.5, 0.5, scale: 2.5);
108        break;
109      case 2:
110        _drawSprite(canvas, suitSprite, 0.5, 0.25);
111        _drawSprite(canvas, suitSprite, 0.5, 0.25, rotate: true);
112        break;
113      case 3:
114        _drawSprite(canvas, suitSprite, 0.5, 0.2);
115        _drawSprite(canvas, suitSprite, 0.5, 0.5);
116        _drawSprite(canvas, suitSprite, 0.5, 0.2, rotate: true);
117        break;
118      case 4:
119        _drawSprite(canvas, suitSprite, 0.3, 0.25);
120        _drawSprite(canvas, suitSprite, 0.7, 0.25);
121        _drawSprite(canvas, suitSprite, 0.3, 0.25, rotate: true);
122        _drawSprite(canvas, suitSprite, 0.7, 0.25, rotate: true);
123        break;
124      case 5:
125        _drawSprite(canvas, suitSprite, 0.3, 0.25);
126        _drawSprite(canvas, suitSprite, 0.7, 0.25);
127        _drawSprite(canvas, suitSprite, 0.3, 0.25, rotate: true);
128        _drawSprite(canvas, suitSprite, 0.7, 0.25, rotate: true);
129        _drawSprite(canvas, suitSprite, 0.5, 0.5);
130        break;
131      case 6:
132        _drawSprite(canvas, suitSprite, 0.3, 0.25);
133        _drawSprite(canvas, suitSprite, 0.7, 0.25);
134        _drawSprite(canvas, suitSprite, 0.3, 0.5);
135        _drawSprite(canvas, suitSprite, 0.7, 0.5);
136        _drawSprite(canvas, suitSprite, 0.3, 0.25, rotate: true);
137        _drawSprite(canvas, suitSprite, 0.7, 0.25, rotate: true);
138        break;
139      case 7:
140        _drawSprite(canvas, suitSprite, 0.3, 0.2);
141        _drawSprite(canvas, suitSprite, 0.7, 0.2);
142        _drawSprite(canvas, suitSprite, 0.5, 0.35);
143        _drawSprite(canvas, suitSprite, 0.3, 0.5);
144        _drawSprite(canvas, suitSprite, 0.7, 0.5);
145        _drawSprite(canvas, suitSprite, 0.3, 0.2, rotate: true);
146        _drawSprite(canvas, suitSprite, 0.7, 0.2, rotate: true);
147        break;
148      case 8:
149        _drawSprite(canvas, suitSprite, 0.3, 0.2);
150        _drawSprite(canvas, suitSprite, 0.7, 0.2);
151        _drawSprite(canvas, suitSprite, 0.5, 0.35);
152        _drawSprite(canvas, suitSprite, 0.3, 0.5);
153        _drawSprite(canvas, suitSprite, 0.7, 0.5);
154        _drawSprite(canvas, suitSprite, 0.3, 0.2, rotate: true);
155        _drawSprite(canvas, suitSprite, 0.7, 0.2, rotate: true);
156        _drawSprite(canvas, suitSprite, 0.5, 0.35, rotate: true);
157        break;
158      case 9:
159        _drawSprite(canvas, suitSprite, 0.3, 0.2);
160        _drawSprite(canvas, suitSprite, 0.7, 0.2);
161        _drawSprite(canvas, suitSprite, 0.5, 0.3);
162        _drawSprite(canvas, suitSprite, 0.3, 0.4);
163        _drawSprite(canvas, suitSprite, 0.7, 0.4);
164        _drawSprite(canvas, suitSprite, 0.3, 0.2, rotate: true);
165        _drawSprite(canvas, suitSprite, 0.7, 0.2, rotate: true);
166        _drawSprite(canvas, suitSprite, 0.3, 0.4, rotate: true);
167        _drawSprite(canvas, suitSprite, 0.7, 0.4, rotate: true);
168        break;
169      case 10:
170        _drawSprite(canvas, suitSprite, 0.3, 0.2);
171        _drawSprite(canvas, suitSprite, 0.7, 0.2);
172        _drawSprite(canvas, suitSprite, 0.5, 0.3);
173        _drawSprite(canvas, suitSprite, 0.3, 0.4);
174        _drawSprite(canvas, suitSprite, 0.7, 0.4);
175        _drawSprite(canvas, suitSprite, 0.3, 0.2, rotate: true);
176        _drawSprite(canvas, suitSprite, 0.7, 0.2, rotate: true);
177        _drawSprite(canvas, suitSprite, 0.5, 0.3, rotate: true);
178        _drawSprite(canvas, suitSprite, 0.3, 0.4, rotate: true);
179        _drawSprite(canvas, suitSprite, 0.7, 0.4, rotate: true);
180        break;
181      case 11:
182        _drawSprite(canvas, suit.isRed ? redJack : blackJack, 0.5, 0.5);
183        break;
184      case 12:
185        _drawSprite(canvas, suit.isRed ? redQueen : blackQueen, 0.5, 0.5);
186        break;
187      case 13:
188        _drawSprite(canvas, suit.isRed ? redKing : blackKing, 0.5, 0.5);
189        break;
190    }
191  }
192
193  void _drawSprite(
194    Canvas canvas,
195    Sprite sprite,
196    double relativeX,
197    double relativeY, {
198    double scale = 1,
199    bool rotate = false,
200  }) {
201    if (rotate) {
202      canvas.save();
203      canvas.translate(size.x / 2, size.y / 2);
204      canvas.rotate(pi);
205      canvas.translate(-size.x / 2, -size.y / 2);
206    }
207    sprite.render(
208      canvas,
209      position: Vector2(relativeX * size.x, relativeY * size.y),
210      anchor: Anchor.center,
211      size: sprite.srcSize.scaled(scale),
212    );
213    if (rotate) {
214      canvas.restore();
215    }
216  }
217
218  //#endregion
219
220  //#region Dragging
221
222  @override
223  void onDragStart(DragStartEvent event) {
224    super.onDragStart(event);
225    if (pile?.canMoveCard(this) ?? false) {
226      _isDragging = true;
227      priority = 100;
228      if (pile is TableauPile) {
229        attachedCards.clear();
230        final extraCards = (pile! as TableauPile).cardsOnTop(this);
231        for (final card in extraCards) {
232          card.priority = attachedCards.length + 101;
233          attachedCards.add(card);
234        }
235      }
236    }
237  }
238
239  @override
240  void onDragUpdate(DragUpdateEvent event) {
241    if (!_isDragging) {
242      return;
243    }
244    final delta = event.localDelta;
245    position.add(delta);
246    attachedCards.forEach((card) => card.position.add(delta));
247  }
248
249  @override
250  void onDragEnd(DragEndEvent event) {
251    super.onDragEnd(event);
252    if (!_isDragging) {
253      return;
254    }
255    _isDragging = false;
256    final dropPiles = parent!
257        .componentsAtPoint(position + size / 2)
258        .whereType<Pile>()
259        .toList();
260    if (dropPiles.isNotEmpty) {
261      if (dropPiles.first.canAcceptCard(this)) {
262        pile!.removeCard(this);
263        dropPiles.first.acquireCard(this);
264        if (attachedCards.isNotEmpty) {
265          attachedCards.forEach((card) => dropPiles.first.acquireCard(card));
266          attachedCards.clear();
267        }
268        return;
269      }
270    }
271    pile!.returnCard(this);
272    if (attachedCards.isNotEmpty) {
273      attachedCards.forEach((card) => pile!.returnCard(card));
274      attachedCards.clear();
275    }
276  }
277
278  //#endregion
279}
components/foundation_pile.dart
 1import 'dart:ui';
 2
 3import 'package:flame/components.dart';
 4
 5import '../klondike_game.dart';
 6import '../pile.dart';
 7import '../suit.dart';
 8import 'card.dart';
 9
10class FoundationPile extends PositionComponent implements Pile {
11  FoundationPile(int intSuit, {super.position})
12      : suit = Suit.fromInt(intSuit),
13        super(size: KlondikeGame.cardSize);
14
15  final Suit suit;
16  final List<Card> _cards = [];
17
18  //#region Pile API
19
20  @override
21  bool canMoveCard(Card card) {
22    return _cards.isNotEmpty && card == _cards.last;
23  }
24
25  @override
26  bool canAcceptCard(Card card) {
27    final topCardRank = _cards.isEmpty ? 0 : _cards.last.rank.value;
28    return card.suit == suit &&
29        card.rank.value == topCardRank + 1 &&
30        card.attachedCards.isEmpty;
31  }
32
33  @override
34  void removeCard(Card card) {
35    assert(canMoveCard(card));
36    _cards.removeLast();
37  }
38
39  @override
40  void returnCard(Card card) {
41    card.position = position;
42    card.priority = _cards.indexOf(card);
43  }
44
45  @override
46  void acquireCard(Card card) {
47    assert(card.isFaceUp);
48    card.position = position;
49    card.priority = _cards.length;
50    card.pile = this;
51    _cards.add(card);
52  }
53
54  //#endregion
55
56  //#region Rendering
57
58  final _borderPaint = Paint()
59    ..style = PaintingStyle.stroke
60    ..strokeWidth = 10
61    ..color = const Color(0x50ffffff);
62  late final _suitPaint = Paint()
63    ..color = suit.isRed ? const Color(0x3a000000) : const Color(0x64000000)
64    ..blendMode = BlendMode.luminosity;
65
66  @override
67  void render(Canvas canvas) {
68    canvas.drawRRect(KlondikeGame.cardRRect, _borderPaint);
69    suit.sprite.render(
70      canvas,
71      position: size / 2,
72      anchor: Anchor.center,
73      size: Vector2.all(KlondikeGame.cardWidth * 0.6),
74      overridePaint: _suitPaint,
75    );
76  }
77
78  //#endregion
79}
components/stock_pile.dart
 1import 'dart:ui';
 2
 3import 'package:flame/components.dart';
 4import 'package:flame/events.dart';
 5
 6import '../klondike_game.dart';
 7import '../pile.dart';
 8import 'card.dart';
 9import 'waste_pile.dart';
10
11class StockPile extends PositionComponent with TapCallbacks implements Pile {
12  StockPile({super.position}) : super(size: KlondikeGame.cardSize);
13
14  /// Which cards are currently placed onto this pile. The first card in the
15  /// list is at the bottom, the last card is on top.
16  final List<Card> _cards = [];
17
18  //#region Pile API
19
20  @override
21  bool canMoveCard(Card card) => false;
22
23  @override
24  bool canAcceptCard(Card card) => false;
25
26  @override
27  void removeCard(Card card) => throw StateError('cannot remove cards');
28
29  @override
30  void returnCard(Card card) => throw StateError('cannot remove cards');
31
32  @override
33  void acquireCard(Card card) {
34    assert(card.isFaceDown);
35    card.pile = this;
36    card.position = position;
37    card.priority = _cards.length;
38    _cards.add(card);
39  }
40
41  //#endregion
42
43  @override
44  void onTapUp(TapUpEvent event) {
45    final wastePile = parent!.firstChild<WastePile>()!;
46    if (_cards.isEmpty) {
47      wastePile.removeAllCards().reversed.forEach((card) {
48        card.flip();
49        acquireCard(card);
50      });
51    } else {
52      for (var i = 0; i < 3; i++) {
53        if (_cards.isNotEmpty) {
54          final card = _cards.removeLast();
55          card.flip();
56          wastePile.acquireCard(card);
57        }
58      }
59    }
60  }
61
62  //#region Rendering
63
64  final _borderPaint = Paint()
65    ..style = PaintingStyle.stroke
66    ..strokeWidth = 10
67    ..color = const Color(0xFF3F5B5D);
68  final _circlePaint = Paint()
69    ..style = PaintingStyle.stroke
70    ..strokeWidth = 100
71    ..color = const Color(0x883F5B5D);
72
73  @override
74  void render(Canvas canvas) {
75    canvas.drawRRect(KlondikeGame.cardRRect, _borderPaint);
76    canvas.drawCircle(
77      Offset(width / 2, height / 2),
78      KlondikeGame.cardWidth * 0.3,
79      _circlePaint,
80    );
81  }
82
83  //#endregion
84}
components/tableau_pile.dart
 1import 'dart:ui';
 2
 3import 'package:flame/components.dart';
 4
 5import '../klondike_game.dart';
 6import '../pile.dart';
 7import 'card.dart';
 8
 9class TableauPile extends PositionComponent implements Pile {
10  TableauPile({super.position}) : super(size: KlondikeGame.cardSize);
11
12  /// Which cards are currently placed onto this pile.
13  final List<Card> _cards = [];
14  final Vector2 _fanOffset1 = Vector2(0, KlondikeGame.cardHeight * 0.05);
15  final Vector2 _fanOffset2 = Vector2(0, KlondikeGame.cardHeight * 0.20);
16
17  //#region Pile API
18
19  @override
20  bool canMoveCard(Card card) => card.isFaceUp;
21
22  @override
23  bool canAcceptCard(Card card) {
24    if (_cards.isEmpty) {
25      return card.rank.value == 13;
26    } else {
27      final topCard = _cards.last;
28      return card.suit.isRed == !topCard.suit.isRed &&
29          card.rank.value == topCard.rank.value - 1;
30    }
31  }
32
33  @override
34  void removeCard(Card card) {
35    assert(_cards.contains(card) && card.isFaceUp);
36    final index = _cards.indexOf(card);
37    _cards.removeRange(index, _cards.length);
38    if (_cards.isNotEmpty && _cards.last.isFaceDown) {
39      flipTopCard();
40    }
41    layOutCards();
42  }
43
44  @override
45  void returnCard(Card card) {
46    card.priority = _cards.indexOf(card);
47    layOutCards();
48  }
49
50  @override
51  void acquireCard(Card card) {
52    card.pile = this;
53    card.priority = _cards.length;
54    _cards.add(card);
55    layOutCards();
56  }
57
58  //#endregion
59
60  void flipTopCard() {
61    assert(_cards.last.isFaceDown);
62    _cards.last.flip();
63  }
64
65  void layOutCards() {
66    if (_cards.isEmpty) {
67      return;
68    }
69    _cards[0].position.setFrom(position);
70    for (var i = 1; i < _cards.length; i++) {
71      _cards[i].position
72        ..setFrom(_cards[i - 1].position)
73        ..add(_cards[i - 1].isFaceDown ? _fanOffset1 : _fanOffset2);
74    }
75    height = KlondikeGame.cardHeight * 1.5 + _cards.last.y - _cards.first.y;
76  }
77
78  List<Card> cardsOnTop(Card card) {
79    assert(card.isFaceUp && _cards.contains(card));
80    final index = _cards.indexOf(card);
81    return _cards.getRange(index + 1, _cards.length).toList();
82  }
83
84  //#region Rendering
85
86  final _borderPaint = Paint()
87    ..style = PaintingStyle.stroke
88    ..strokeWidth = 10
89    ..color = const Color(0x50ffffff);
90
91  @override
92  void render(Canvas canvas) {
93    canvas.drawRRect(KlondikeGame.cardRRect, _borderPaint);
94  }
95
96  //#endregion
97}
components/waste_pile.dart
 1import 'package:flame/components.dart';
 2
 3import '../klondike_game.dart';
 4import '../pile.dart';
 5import 'card.dart';
 6
 7class WastePile extends PositionComponent implements Pile {
 8  WastePile({super.position}) : super(size: KlondikeGame.cardSize);
 9
10  final List<Card> _cards = [];
11  final Vector2 _fanOffset = Vector2(KlondikeGame.cardWidth * 0.2, 0);
12
13  //#region Pile API
14
15  @override
16  bool canMoveCard(Card card) => _cards.isNotEmpty && card == _cards.last;
17
18  @override
19  bool canAcceptCard(Card card) => false;
20
21  @override
22  void removeCard(Card card) {
23    assert(canMoveCard(card));
24    _cards.removeLast();
25    _fanOutTopCards();
26  }
27
28  @override
29  void returnCard(Card card) {
30    card.priority = _cards.indexOf(card);
31    _fanOutTopCards();
32  }
33
34  @override
35  void acquireCard(Card card) {
36    assert(card.isFaceUp);
37    card.pile = this;
38    card.position = position;
39    card.priority = _cards.length;
40    _cards.add(card);
41    _fanOutTopCards();
42  }
43
44  //#endregion
45
46  List<Card> removeAllCards() {
47    final cards = _cards.toList();
48    _cards.clear();
49    return cards;
50  }
51
52  void _fanOutTopCards() {
53    final n = _cards.length;
54    for (var i = 0; i < n; i++) {
55      _cards[i].position = position;
56    }
57    if (n == 2) {
58      _cards[1].position.add(_fanOffset);
59    } else if (n >= 3) {
60      _cards[n - 2].position.add(_fanOffset);
61      _cards[n - 1].position.addScaled(_fanOffset, 2);
62    }
63  }
64}
klondike_game.dart
 1import 'dart:ui';
 2
 3import 'package:flame/components.dart';
 4import 'package:flame/flame.dart';
 5import 'package:flame/game.dart';
 6
 7import 'components/card.dart';
 8import 'components/foundation_pile.dart';
 9import 'components/stock_pile.dart';
10import 'components/tableau_pile.dart';
11import 'components/waste_pile.dart';
12
13class KlondikeGame extends FlameGame {
14  static const double cardGap = 175.0;
15  static const double cardWidth = 1000.0;
16  static const double cardHeight = 1400.0;
17  static const double cardRadius = 100.0;
18  static final Vector2 cardSize = Vector2(cardWidth, cardHeight);
19  static final cardRRect = RRect.fromRectAndRadius(
20    const Rect.fromLTWH(0, 0, cardWidth, cardHeight),
21    const Radius.circular(cardRadius),
22  );
23
24  @override
25  Future<void> onLoad() async {
26    await Flame.images.load('klondike-sprites.png');
27
28    final stock = StockPile(position: Vector2(cardGap, cardGap));
29    final waste =
30        WastePile(position: Vector2(cardWidth + 2 * cardGap, cardGap));
31    final foundations = List.generate(
32      4,
33      (i) => FoundationPile(
34        i,
35        position: Vector2((i + 3) * (cardWidth + cardGap) + cardGap, cardGap),
36      ),
37    );
38    final piles = List.generate(
39      7,
40      (i) => TableauPile(
41        position: Vector2(
42          cardGap + i * (cardWidth + cardGap),
43          cardHeight + 2 * cardGap,
44        ),
45      ),
46    );
47
48    world.add(stock);
49    world.add(waste);
50    world.addAll(foundations);
51    world.addAll(piles);
52
53    camera.viewfinder.visibleGameSize =
54        Vector2(cardWidth * 7 + cardGap * 8, 4 * cardHeight + 3 * cardGap);
55    camera.viewfinder.position = Vector2(cardWidth * 3.5 + cardGap * 4, 0);
56    camera.viewfinder.anchor = Anchor.topCenter;
57
58    final cards = [
59      for (var rank = 1; rank <= 13; rank++)
60        for (var suit = 0; suit < 4; suit++) Card(rank, suit),
61    ];
62    cards.shuffle();
63    world.addAll(cards);
64
65    var cardToDeal = cards.length - 1;
66    for (var i = 0; i < 7; i++) {
67      for (var j = i; j < 7; j++) {
68        piles[j].acquireCard(cards[cardToDeal--]);
69      }
70      piles[i].flipTopCard();
71    }
72    for (var n = 0; n <= cardToDeal; n++) {
73      stock.acquireCard(cards[n]);
74    }
75  }
76}
77
78Sprite klondikeSprite(double x, double y, double width, double height) {
79  return Sprite(
80    Flame.images.fromCache('klondike-sprites.png'),
81    srcPosition: Vector2(x, y),
82    srcSize: Vector2(width, height),
83  );
84}
main.dart
1import 'package:flame/game.dart';
2import 'package:flutter/widgets.dart';
3
4import 'klondike_game.dart';
5
6void main() {
7  final game = KlondikeGame();
8  runApp(GameWidget(game: game));
9}
pile.dart
 1import 'components/card.dart';
 2
 3abstract class Pile {
 4  /// Returns true if the [card] can be taken away from this pile and moved
 5  /// somewhere else.
 6  bool canMoveCard(Card card);
 7
 8  /// Returns true if the [card] can be placed on top of this pile. The [card]
 9  /// may have other cards "attached" to it.
10  bool canAcceptCard(Card card);
11
12  /// Removes [card] from this pile; this method will only be called for a card
13  /// that both belong to this pile, and for which [canMoveCard] returns true.
14  void removeCard(Card card);
15
16  /// Places a single [card] on top of this pile. This method will only be
17  /// called for a card for which [canAcceptCard] returns true.
18  void acquireCard(Card card);
19
20  /// Returns the [card] (which already belongs to this pile) in its proper
21  /// place.
22  void returnCard(Card card);
23}
rank.dart
 1import 'package:flame/components.dart';
 2import 'package:flutter/foundation.dart';
 3import 'klondike_game.dart';
 4
 5@immutable
 6class Rank {
 7  factory Rank.fromInt(int value) {
 8    assert(
 9      value >= 1 && value <= 13,
10      'value is outside of the bounds of what a rank can be',
11    );
12    return _singletons[value - 1];
13  }
14
15  Rank._(
16    this.value,
17    this.label,
18    double x1,
19    double y1,
20    double x2,
21    double y2,
22    double w,
23    double h,
24  )   : redSprite = klondikeSprite(x1, y1, w, h),
25        blackSprite = klondikeSprite(x2, y2, w, h);
26
27  final int value;
28  final String label;
29  final Sprite redSprite;
30  final Sprite blackSprite;
31
32  static final List<Rank> _singletons = [
33    Rank._(1, 'A', 335, 164, 789, 161, 120, 129),
34    Rank._(2, '2', 20, 19, 15, 322, 83, 125),
35    Rank._(3, '3', 122, 19, 117, 322, 80, 127),
36    Rank._(4, '4', 213, 12, 208, 315, 93, 132),
37    Rank._(5, '5', 314, 21, 309, 324, 85, 125),
38    Rank._(6, '6', 419, 17, 414, 320, 84, 129),
39    Rank._(7, '7', 509, 21, 505, 324, 92, 128),
40    Rank._(8, '8', 612, 19, 607, 322, 78, 127),
41    Rank._(9, '9', 709, 19, 704, 322, 84, 130),
42    Rank._(10, '10', 810, 20, 805, 322, 137, 127),
43    Rank._(11, 'J', 15, 170, 469, 167, 56, 126),
44    Rank._(12, 'Q', 92, 168, 547, 165, 132, 128),
45    Rank._(13, 'K', 243, 170, 696, 167, 92, 123),
46  ];
47}
suit.dart
 1import 'package:flame/sprite.dart';
 2import 'package:flutter/foundation.dart';
 3import 'klondike_game.dart';
 4
 5@immutable
 6class Suit {
 7  factory Suit.fromInt(int index) {
 8    assert(
 9      index >= 0 && index <= 3,
10      'index is outside of the bounds of what a suit can be',
11    );
12    return _singletons[index];
13  }
14
15  Suit._(this.value, this.label, double x, double y, double w, double h)
16      : sprite = klondikeSprite(x, y, w, h);
17
18  final int value;
19  final String label;
20  final Sprite sprite;
21
22  static final List<Suit> _singletons = [
23    Suit._(0, '♥', 1176, 17, 172, 183),
24    Suit._(1, '♦', 973, 14, 177, 182),
25    Suit._(2, '♣', 974, 226, 184, 172),
26    Suit._(3, '♠', 1178, 220, 176, 182),
27  ];
28
29  /// Hearts and Diamonds are red, while Clubs and Spades are black.
30  bool get isRed => value <= 1;
31  bool get isBlack => value >= 2;
32}