5. Animations, restarting, buttons and a New World

In this chapter we will be showing various ways to make the Klondike game more fun and easier to play. The topics to be covered are:

  • Klondike Draw 1 and Draw 3

  • Animating and automating moves

  • Detecting and celebrating a win

  • Ending and restarting the game

  • How to implement your own FlameGame world

  • Simple action-buttons

  • Anchors and co-ordinates

  • Random number generation and seeding

  • Effects and EffectControllers

The Klondike draw

The Klondike patience game (or solitaire game in the USA) has two main variants: Draw 3 and Draw 1. Currently the Klondike Flame Game is Draw 3, which is a lot more difficult than Draw 1, because although you can see 3 cards, you can only move one of them and that move changes the “phase” of other cards. So different cards are going to become available — not easy.

In Klondike Draw 1 just one card at a time is drawn from the Stock and shown, so every card in it is available, and you can go through the Stock as many times as you like, just as in Klondike Draw 3.

So how do we implement Klondike Draw 1? Clearly only the Stock and Waste piles are involved, so maybe we should have KlondikeGame provide a value 1 or 3 to each of them. They both have code for constructors, so we could just add an extra parameter to that code, but in Flame there is another way, which works even if your component has a default constructor (no code for it) or your game has many game-wide values. Let us call our value klondikeDraw. In your class declaration add the HasGameReference<MyGame> mixin, then write game.klondikeDraw wherever you need the value 1 or 3. For class StockPile we will have:

class StockPile extends PositionComponent
    with TapCallbacks, HasGameReference<KlondikeGame>
    implements Pile {

and

  @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 < game.klondikeDraw; i++) {
        if (_cards.isNotEmpty) {
          final card = _cards.removeLast();
          card.flip();
          wastePile.acquireCard(card);
        }
      }
    }
  }

For class WastePile we will have:

class WastePile extends PositionComponent
    with HasGameReference<KlondikeGame>
    implements Pile {

and

  void _fanOutTopCards() {
    if (game.klondikeDraw == 1) {   // No fan-out in Klondike Draw 1.
      return;
    }
    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);
    }
  }

That makes the Stock and Waste piles play either Klondike Draw 1 or Klondike Draw 3, but how do you tell them which variant to play? For now, we will add a place-holder to the KlondikeGame class. We just comment out whichever one we do not want and then rebuild.

  // final int klondikeDraw = 3;
  final int klondikeDraw = 1;

This is fine as a temporary measure, when we have not yet decided how to handle some aspect of our design, but ultimately we will have to provide some kind of input for the player to choose which flavor of Klondike to play, such as a menu screen, a settings screen or a button. Flame can incorporate Flutter widgets into a game and the next Tutorial (Ember) shows how to add a menu widget, as its final step.

Making cards move

In Flame, if we need a component to do something, we use an Effect - a special component that can attach to another component, such as a card, and modify its properties. That includes any kind of motion (or change of position). We also need an EffectController, which provides timing for an effect: when to start, how long to go for and what Curve to follow. The latter is not a curve in space. It is a time-curve that specifies accelerations and decelerations during the time of the effect, such as start moving a card quickly and then slow down as it approaches its destination.

To move a card, we will add a doMove() method to the Card class. It will require a to location to go to. Optional parameters are speed: (default 10.0), start: (default zero), curve: (default Curves.easeOutQuad) and onComplete: (default null, i.e. no callback when the move finishes). Speed is in card widths per second. Usually we will provide a callback, because a bit of gameplay must be done after the animated move. The default curve: parameter gives us a fast-in/slow-out move, much as a human player would do. So the following code is added to the end of the Card class:

  void doMove(
    Vector2 to, {
    double speed = 10.0,
    double start = 0.0,
    Curve curve = Curves.easeOutQuad,
    VoidCallback? onComplete,
  }) {
    assert(speed > 0.0, 'Speed must be > 0 widths per second');
    final dt = (to - position).length / (speed * size.x);
    assert(dt > 0.0, 'Distance to move must be > 0');
    priority = 100;
    add(
      MoveToEffect(
        to,
        EffectController(duration: dt, startDelay: start, curve: curve),
        onComplete: () {
          onComplete?.call();
        },
      ),
    );
  }

To make this code compile we need to import 'package:flame/effects.dart' and 'package:flutter/animation.dart' at the top of the components/card.dart file. That done, we can start using the new method to return the card(s) gracefully to where they came from, after being dropped in an invalid position. First, we need a private data item to store a card’s position when a drag-and-drop started. So let us insert new lines in two places as shown below:

  bool _isDragging = false;
  Vector2 _whereCardStarted = Vector2(0, 0);

  final List<Card> attachedCards = [];
      _isDragging = true;
      priority = 100;
      // Copy each co-ord, else _whereCardStarted changes as the position does.
      _whereCardStarted = Vector2(position.x, position.y);
      if (pile is TableauPile) {

It would be a mistake to write _whereCardStarted = position; here. In Dart, that would just copy a reference: so _whereCardStarted would point to the same data as position while the drag occurred and the card’s position data changed. We can get around this by copying the card’s current X and Y co-ordinates into a new Vector2 object.

To animate cards being returned to their original piles after an invalid drag-and-drop, we replace five lines at the end of the onDragEnd() method with:

    // Invalid drop (middle of nowhere, invalid pile or invalid card for pile).
    doMove(
      _whereCardStarted,
      onComplete: () {
        pile!.returnCard(this);
      },
    );
    if (attachedCards.isNotEmpty) {
      attachedCards.forEach((card) {
        final offset = card.position - position;
        card.doMove(
          _whereCardStarted + offset,
          onComplete: () {
            pile!.returnCard(card);
          },
        );
      });
      attachedCards.clear();
    }

In each case, we use the default speed of 10 card-widths per second. Notice how the onComplete: parameters are used to return each card to the pile where it started. It will then be added back to that pile’s list of contents. Notice also that the list of attached cards (if any) is cleared immediately, as the animated cards start to move. This does not matter, because each moving card has a MoveToEffect and an EffectController added to it and these contain all the data needed to get the right card to the right place at the right time. Thus no important information is lost by clearing the attached cards early. Also, by default, the MoveToEffect and EffectController in each moving card automatically get detached and deleted by Flame when the show is over.

Some other automatic and animated moves we can try are dealing the cards, flipping cards from Stock to Waste pile, turning cards over automatically on the tableau piles, and settling cards into place after a valid drag-and-drop. We will have a look at animating a flip first.

Animating a card-flip

Flutter and Flame do not yet support 3-D effects (as at October 2023), but we can emulate them. To make a card look as if it is turning over, we will shrink the width of the back-view, switch to the front view and expand back to full width. The code uses quite a few features of Effects and EffectControllers:

  void turnFaceUp({
    double time = 0.3,
    double start = 0.0,
    VoidCallback? onComplete,
  }) {
    assert(!_isFaceUpView, 'Card must be face-down before turning face-up.');
    assert(time > 0.0, 'Time to turn card over must be > 0');
    _isAnimatedFlip = true;
    anchor = Anchor.topCenter;
    position += Vector2(width / 2, 0);
    priority = 100;
    add(
      ScaleEffect.to(
        Vector2(scale.x / 100, scale.y),
        EffectController(
          startDelay: start,
          curve: Curves.easeOutSine,
          duration: time / 2,
          onMax: () {
            _isFaceUpView = true;
          },
          reverseDuration: time / 2,
          onMin: () {
            _isAnimatedFlip = false;
            _faceUp = true;
            anchor = Anchor.topLeft;
            position -= Vector2(width / 2, 0);
          },
        ),
        onComplete: () {
          onComplete?.call();
        },
      ),
    );
  }

So how does all this work? We have a default time of 0.3 seconds for the flip to occur, a start time and an optional callback on completion, as before. Now we add a ScaleEffect to the card, which shrinks it almost to zero width, but leaves the height unchanged. However, that must take only half the time, then we must switch from the face-down to the face-up view of the card and expand it back out, also in half the time.

This is where we use some of the fancier parameters of the EffectController class. The duration: is set to time / 2 and we use an onMax: callback, with inline code to change the view to face-up. That callback will happen after time / 2, when the Effect (whatever it is) has reached its maximum (i.e. in this case, the view of the card has shrunk to a thin vertical line). After the switch to face-up view, the EffectController will take the Effect into reverse for reverseDuration: time / 2. Everything is reversed: the view of the card expands and the curve: of time is applied in reverse order. In total, the timing follows a sine curve from 0 to pi, giving a smooth animation in which the width of the card-view is always the projection into 2-D of its 3-D position. Wow! That’s a lot of work for a little EffectController!

We are not there yet! If you were to run just the add() part of the code, you would see some ugly things happening. Yeah, yeah, been there, done that — when I was preparing this code! First off, the card shrinks to a line at its left. That is because all cards in this game have an Anchor at topLeft, which is the point used to set the card’s position. We would like the card to flip around its vertical center-line. Easy, just set anchor = Anchor.topCenter first: that makes the card flip realistically, but it jumps by half a card-width to the left before flipping.

Long story short, see the lines between assert( and add( and their reversal in the onMin: callback, which occurs when the Effect is finished, but before the final onComplete: callback. At the beginning, the card’s rendering priority is set to 100, so that it will ride above all other cards in the neighborhood. That value cannot always be saved and restored because we may not know what the card’s priority should be in whatever Pile is receiving it. So we have made sure that the receiver is always called in the onComplete: option, using a method that will adjust the positions and priorities of the cards in the pile.

Last but not least, in the preceding code, notice the use of the variable _isAnimatedFlip. This is a bool variable defined and initialized near the start of class Card in file components/card.dart, along with another new bool called _isFaceUpView. Initially these are set false, along with the existing bool _faceUp = false variable. What is the significance of these variables? It is huge. A few lines further down, we see:

  @override
  void render(Canvas canvas) {
    if (_isFaceUpView) {
      _renderFront(canvas);
    } else {
      _renderBack(canvas);
    }
  }

This is the code that makes every card visible on the screen, in either face-up or face-down state. At the end of Klondike Tutorial Step 4, the if statement was if (_faceUp) {. This was OK because all moves of cards were instantaneous (leaving aside drags and drops): any change in the card’s face-up or face-down state could be rendered at the Flame Engine’s next tick or soon after. This posed no problem when we started to animate card moves, provided there were no flips involved. However, when we tapped a non-empty Stock Pile, the executed code was:

  final card = _cards.removeLast();
  card.flip;
  wastePile.acquireCard(card);

And the first thing wastePile.acquireCard( does is assert(card.isFaceUp);, which fails if an animated flip is keeping the card face-down while the first half of the flip is occurring.

Model and View

Clearly the card cannot be in two states at once: it is not Schrödinger’s cat! We can resolve the dilemma by using two definitions of “face-up”: a Model type and a View type. The View version is used in rendering and animation (i.e. what appears on the screen) and the Model version in the logic of the game, the gameplay and its error-checks. That way, we do not have to revise all the logic of the Piles in this game in order to animate some of it. A more complex game might benefit from separating the Model and the View during the design and early coding stages — even into separate classes. In this game we are using just a little separation of Model and View. The _isAnimatedFlip variable is true while there is an animated flip going on, otherwise false, and the Card class’s flip() function is expanded to:

  void flip() {
    if (_isAnimatedFlip) {
      // Let the animation determine the FaceUp/FaceDown state.
      _faceUp = _isFaceUpView;
    } else {
      // No animation: flip and render the card immediately.
      _faceUp = !_faceUp;
      _isFaceUpView = _faceUp;
    }
  }

In the Klondike Tutorial game we are still having to trigger a Model update in the onComplete: callback of the flip animation. It might be nice, for impatient or rapid-fingered players, to transfer a card from Stock Pile to Waste Pile instantaneously, in the Model, leaving the animation in the View to catch up later, with no onComplete: callback. That way, you could flip through the Stock Pile very rapidly, by tapping fast. However, that is beyond the scope of this Tutorial.

Ending and restarting the game

As it stands, there is no easy way to finish the Klondike Tutorial game and start another, even if you have won. We can only close the app and start it again. And there is no “reward” for winning.

There are various ways to tackle this, depending on the simplicity or complexity of your game and on how long the onLoad() method is likely to take. They can range from writing your own GameWidget, to doing a few simple re-initializations in your Game class (i.e. KlondikeGame in this case).

In the GameWidget case you would supply the Game with a VoidCallback function parameter named reset or restart. When the callback is invoked, it would use the Flutter conventions of a StatefulWidget (e.g. setState(() {});) to force the widget to be rebuilt and replaced, thus releasing all references to the current Game instance, its state and all of its memory. There could also be Flutter code to run a menu or other startup screen.

Re-initialization should be undertaken only if the operations involved are few and simple. Otherwise coding errors could lead to subtle problems, memory leaks and crashes in your game. It might be the easiest way to go in Klondike (as it is in the Ember Tutorial). Basically, we must clear all the card references out of all the Piles and then re-shuffle (or not) and re-deal, possibly changing from Klondike Draw 3 to Klondike Draw 1 or vice-versa.

Well, that was not as easy as it looked! Re-initializing the Piles and each Card was easy enough, but the difficult bit came next… Whether the player wins or restarts without winning, we have 52 cards spread around various piles on the screen, some face-up and maybe some face-down. We would like to animate the deal later, so it would be nice to collect the cards into a neat face-down pile at top left, in the Stock Pile area: not the actual Stock Pile yet, because that gets created during the deal.

Writing a simple little loop to set each Card face-down and use its doMove method to make it move independently to the top left fails. It causes one of those “subtle problems” referred to earlier. The cards all travel at the same speed but arrive at different times. The deal then produces messy Tableau Piles with several cards out of position. Also the animated move of all the cards to the Stock Pile area was a bit ugly.

The problem of the messy Tableau Piles was fixable, but at this point the reviewer of the code and documentation proposed a completely new approach which avoids re-initializing anything and creates all the Components from scratch, which is the preferred Flutter/Flame way of doing things.

A New World

Start and restart actions

We wish to provide the following actions in the Klondike game:

  • A first start,

  • Any number of restarts with a new deal,

  • Any number of restarts with the same deal as before,

  • A switch between Klondike Draw 1 and Draw 3 and restart with a new deal, and

  • Have fun before restarting with a new deal (we’ll keep that as a surprise for later).

The proposal is to have a new KlondikeWorld class, which replaces the default world provided by FlameGame. The new world contains (almost) everything we need to play the game and is created or re-created during each of the above actions.

A stripped-down KlondikeGame class

Here is the new code for the KlondikeGame class (what is left of it).

enum Action { newDeal, sameDeal, changeDraw, haveFun }

class KlondikeGame extends FlameGame<KlondikeWorld> {
  static const double cardGap = 175.0;
  static const double topGap = 500.0;
  static const double cardWidth = 1000.0;
  static const double cardHeight = 1400.0;
  static const double cardRadius = 100.0;
  static const double cardSpaceWidth = cardWidth + cardGap;
  static const double cardSpaceHeight = cardHeight + cardGap;
  static final Vector2 cardSize = Vector2(cardWidth, cardHeight);
  static final cardRRect = RRect.fromRectAndRadius(
    const Rect.fromLTWH(0, 0, cardWidth, cardHeight),
    const Radius.circular(cardRadius),
  );

  // Constant used when creating Random seed.
  static const int maxInt = 0xFFFFFFFE; // = (2 to the power 32) - 1

  // This KlondikeGame constructor also initiates the first KlondikeWorld.
  KlondikeGame() : super(world: KlondikeWorld());

  // These three values persist between games and are starting conditions
  // for the next game to be played in KlondikeWorld. The actual seed is
  // computed in KlondikeWorld but is held here in case the player chooses
  // to replay a game by selecting Action.sameDeal.
  int klondikeDraw = 1;
  int seed = 1;
  Action action = Action.newDeal;
}

Huh! What happened to the onLoad() method? And what’s this seed thing? And how does KlondikeWorld get into the act? Well, everything that used to be in the onLoad() method is now in the onLoad() method of KlondikeWorld, which is an extension of the World class and is a type of Component, so it can have an onLoad() method, as can any Component type. The content of the method is much the same as before, except that world.add( becomes just add(. It also brings in some addButton() references, but more on these later.

Using a Random Number Generator seed

The seed is a common games-programming technique in any programming environment. Usually it allows you to start a Random Number Generator from a known point (called the seed) and give your game reproducible behavior when you are in the development and testing stage. Here it is used to provide exactly the same deal of the Klondike cards when the player requests Same deal.

Introducing the new KlondikeWorld class

The class KlondikeGame declaration specifies that this extension of the FlameGame class must have a world of type KlondikeWorld (i.e. FlameGame<KlondikeWorld>). Didn’t know we could do that for a game, did we? So how does the first instance of KlondikeWorld get created? It’s all in the KlondikeGame constructor code:

  KlondikeGame() : super(world: KlondikeWorld());

The constructor itself is a default constructor, but the colon : begins a constructor initialization sequence which creates our world for the first time.

Buttons

We are going to use some buttons to activate the various ways of restarting the Klondike Game. First we extend Flame’s ButtonComponent to create class FlatButton, adapted from a Flat Button which used to be in Flame’s Examples pages. ButtonComponent uses two PositionComponents, one for when the button is in its normal state (up) and one for when it is pressed. The two components are mounted and rendered alternately as the user presses the button and releases it. To press the button, tap and hold it down.

In our button, the two components are the button’s outlines - the buttonDown: one makes the outline of the button turn red when it is pressed, as a warning, because the four button-actions all end the current game and start another. That is also why they are positioned at the top of the canvas, above all the cards, where you are less likely to press them accidentally. If you do press one and have second thoughts, keep pressing and slide away, then the button will have no effect.

The four buttons trigger the restart actions described above and are labelled New deal, Same deal, Draw 1 3 and Have fun. Flame also has a SpriteButtonComponent, based on two alternating Sprites, a HudButtonComponent and an AdvancedButtonComponent. For further types of buttons and controllers, it would be best to use a Flutter overlay, menu or settings widget and have access to Flutter’s widgets for radio buttons, dropdown lists, sliders, etc. For the purposes of this Tutorial our FlatButton will do fine.

We use the addButton() method, during our world’s onLoad(), to set up our four buttons and add them to our world.

    playAreaSize =
        Vector2(7 * cardSpaceWidth + cardGap, 4 * cardSpaceHeight + topGap);
    final gameMidX = playAreaSize.x / 2;

    addButton('New deal', gameMidX, Action.newDeal);
    addButton('Same deal', gameMidX + cardSpaceWidth, Action.sameDeal);
    addButton('Draw 1 or 3', gameMidX + 2 * cardSpaceWidth, Action.changeDraw);
    addButton('Have fun', gameMidX + 3 * cardSpaceWidth, Action.haveFun);

That places them above our four Foundation piles and centrally aligned with them. The first Foundation pile happens to be aligned around the top-center of the screen, so the first button is centred above it.

Anchors and co-ordinates

The expressions here and in the addButton() method may seem odd because the cards and piles all have Anchor.topLeft but the buttons have Anchor.center. The position co-ordinates of a Card are where its top-left corner goes, but the position co-ordinates of a FlatButton are where its center goes and the various parts of a FlatButton are arranged (internally) around its center. These examples can give us some insight into how co-ordinate systems work in Flame.

The deal() method

The last thing the KlondikeWorld’s onLoad() method does is call the deal() method to shuffle and deal the cards. This method is now in the KlondikeWorld class and so are the checkWin() and letsCelebrate() methods, but more about those later. The deal process is the same as before but now includes some animation:

  void deal() {
    assert(cards.length == 52, 'There are ${cards.length} cards: should be 52');

    if (game.action != Action.sameDeal) {
      // New deal: change the Random Number Generator's seed.
      game.seed = Random().nextInt(KlondikeGame.maxInt);
      if (game.action == Action.changeDraw) {
        game.klondikeDraw = (game.klondikeDraw == 3) ? 1 : 3;
      }
    }
    // For the "Same deal" option, re-use the previous seed, else use a new one.
    cards.shuffle(Random(game.seed));

    var cardToDeal = cards.length - 1;
    var nMovingCards = 0;
    for (var i = 0; i < 7; i++) {
      for (var j = i; j < 7; j++) {
        final card = cards[cardToDeal--];
        card.doMove(
          tableauPiles[j].position,
          start: nMovingCards * 0.15,
          onComplete: () {
            tableauPiles[j].acquireCard(card);
            nMovingCards--;
            if (nMovingCards == 0) {
              var delayFactor = 0;
              for (final tableauPile in tableauPiles) {
                delayFactor++;
                tableauPile.flipTopCard(start: delayFactor * 0.15);
              }
            }
          },
        );
        nMovingCards++;
      }
    }
    for (var n = 0; n <= cardToDeal; n++) {
      stock.acquireCard(cards[n]);
    }
  }

First we implement the Action value for this game. In the very first game, the KlondikeGame class sets defaults of Action.newDeal and klondikeDraw = 1, but after that the player can select an action by pressing and releasing a button and KlondikeWorld saves it in KlondikeGame — or the player wins the game, in which case Action.newDeal is selected and saved automatically. The action usually generates and saves a new seed, but that is skipped if we have Action.sameDeal. Then we shuffle the cards, using whatever seed applies.

The deal logic is the same as we used in Klondike Tutorial Step 4 and the animation is fairly easy. We just use card.doMove( for each card, with a changing destination and an increasing start: value, counting each moving card as it departs. For a few milliseconds after the loops terminate nMovingCards will be at a maximum of 28 (i.e. 1 + 2 + 3 + 4 + 5 + 6 + 7) and the remaining 24 cards will go into a properly constructed Stock Pile.

Then cards will be arriving over the next second or so and a problem arises. The cards do not necessarily arrive in the order they are sent from the Stock Pile area. If we start turning over the last cards in the columns too soon, we might turn over the wrong card and mess up the deal. The following printout of the deal shows how arrivals can get out of order. The j variable is the Tableau Pile number and i is the card’s position in the pile. The King of Hearts for Pile 6 is arriving before the Queen of Clubs that is the last card in Pile 5. And there are two more cards to go in Pile 6.

flutter: Move done, i 3, j 6, 6♠ 5 moving cards.
flutter: Move done, i 4, j 5, 9♥ 4 moving cards.
flutter: Move done, i 4, j 6, K♥ 3 moving cards.
flutter: Move done, i 5, j 5, Q♣ 2 moving cards.
flutter: Move done, i 5, j 6, 2♠ 1 moving cards.
flutter: Move done, i 6, j 6, 10♠ 0 moving cards.
flutter: Pile 0 [Q♦]
flutter: Pile 1 [J♣, Q♥]
flutter: Pile 2 [5♥, 5♦, J♦]
flutter: Pile 3 [A♠, Q♠, A♥, 5♠]
flutter: Pile 4 [8♦, 10♣, 7♥, 3♥, 4♥]
flutter: Pile 5 [4♠, 8♣, 5♣, 2♥, 9♥, Q♣]
flutter: Pile 6 [4♣, 3♦, K♦, 6♠, K♥, 2♠, 10♠]

So we count off the cards in the onComplete() callback code as they arrive. Only when all 28 cards have arrived do we start turning over the last card of each Tableau Pile. When the deal has been completed our KlondikeWorld is also complete and ready for play.

More animations of moves

The Card class’s doMove() and turnFaceUp() methods have been combined into a doMoveAndFlip() method, which is used to draw cards from the Stock Pile. The dropping of a card or cards onto a pile after drag-and-drop also uses doMove() to settle the drop more gracefully. Finally, there is a shortcut to auto-move a card onto its Foundation Pile if it is ready to go out. This adds TapCallbacks to the Card class and an onTapUp() callback as follows:

  onTapUp(TapUpEvent event) {
    if (isFaceUp) {
      final suitIndex = suit.value;
      if (game.foundations[suitIndex].canAcceptCard(this)) {
        pile!.removeCard(this);
        doMove(
          game.foundations[suitIndex].position,
          onComplete: () {
            game.foundations[suitIndex].acquireCard(this);
          },
        );
      }
    } else if (pile is StockPile) {
      game.stock.onTapUp(event);
    }
  }

If a card is ready to go out, just tap on it and it will move automatically to the correct Foundation Pile for its suit. This saves a load of dragging-and-dropping when you are close to winning the game! There is nothing new in the above code, except that if you tap the top card of the Stock Pile, the Card object receives the tap first and forwards it on to the stock object.

A graphics glitch

If you moved multiple cards from one Tableau Pile to another, the internal code of the TableauPile class would formerly (in Tutorial Step 4) move the cards into place abruptly, as soon as the drag-and-drop ended. In the new code (Step 5), drags and drops use essentially the same code as before, so it is tempting to get that code to do a multi-card move as a series of animated moves each completing with an acquireCard call. But this caused some ugly graphics glitches. It appears they were due to acquireCard also calling the layoutCards() method of TableauPile and instantly re-arranging all the cards in the pile, every time a card was acquired. The problem has been solved (with some difficulty as it turned out), by adding a dropCards method to TableauPile, which mimics some of the existing actions while dovetailing some card animations in as well.

The lesson to be learned is that it is worth giving some attention to animation and time-dependent concerns at Game Design time. When was that? Back in Klondike Tutorial Step 1 Preparation and Step 2 Scaffolding.

Winning the game

You win the game when all cards in all suits, Ace to King, have been moved to the Foundation Piles, 13 cards in each pile. The game now has code to recognize that event — an isFull test added to the FoundationPile’s acquireCard() method, a callback to KlondikeWorld and a test as to whether all four Foundations are full. Here is the code:

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

  final VoidCallback checkWin;

  final Suit suit;
  final List<Card> _cards = [];

  //#region Pile API

  bool get isFull => _cards.length == 13;
  void acquireCard(Card card) {
    assert(card.isFaceUp);
    card.position = position;
    card.priority = _cards.length;
    card.pile = this;
    _cards.add(card);
    if (isFull) {
      checkWin(); // Get KlondikeWorld to check all FoundationPiles.
    }
  }
  void checkWin()
  {
    var nComplete = 0;
    for (final f in foundations) {
      if (f.isFull) {
        nComplete++;
      }
    }
    if (nComplete == foundations.length) {
      letsCelebrate();
    }
  }

It is often possible to calculate whether you can win from a given position of the cards in a Klondike game, or could have won but missed a vital move. It is frequently possible to calculate whether the initial deal is winnable: a percentage of Klondike deals are not. But all that is far beyond the scope of this Tutorial, so for now it is up to the player to work out whether to keep playing and try to win — or give up and press one of the buttons.

Ending a game and re-starting it

A game ends either after the player wins or they press and release one of the buttons. At that point the KlondikeGame class must hold all the data needed to start a new game, namely an Action value, a klondikeDraw value (1 or 3) and a seed from the previous game. Each button has an onReleased: callback provided by the addButton() method in KlondikeWorld, with code as follows:

      onReleased: () {
        if (action == Action.haveFun) {
          // Shortcut to the "win" sequence, for Tutorial purposes only.
          letsCelebrate();
        } else {
          // Restart with a new deal or the same deal as before.
          game.action = action;
          game.world = KlondikeWorld();
        }
      },

The letsCelebrate() method is normally invoked only when the player wins. The functions of the other three buttons are to set the Action value in KlondikeGame and to set world in FlameGame to refer to a new KlondikeWorld, thus replacing the current one and leaving the former KlondikeWorld’s storage to be disposed of by Garbage Collect. FlameGame will continue on to trigger KlondikeWorld’s onLoad() method.

The letsCelebrate() method ends with similar code, but forces a new deal:

              game.action = Action.newDeal;
              game.world = KlondikeWorld();

The Have fun button

When you win the Klondike Game, the letsCelebrate() method puts on a little display. To save you having to play and win a whole game before you see it — and to test the method, we have provided the Have fun button. Of course a real game could not have such a button…

Well, this is it! The game is now more playable.

We could do more, but this game is a Tutorial above all else. Press the buttons below to see what the final code looks like, or to play it live.

But it is also time to have a look at the Ember Tutorial!

components/card.dart
  1import 'dart:math';
  2import 'dart:ui';
  3
  4import 'package:flame/components.dart';
  5import 'package:flame/effects.dart';
  6import 'package:flame/events.dart';
  7import 'package:flutter/animation.dart';
  8
  9import '../klondike_game.dart';
 10import '../klondike_world.dart';
 11import '../pile.dart';
 12import '../rank.dart';
 13import '../suit.dart';
 14import 'foundation_pile.dart';
 15import 'stock_pile.dart';
 16import 'tableau_pile.dart';
 17
 18class Card extends PositionComponent
 19    with DragCallbacks, TapCallbacks, HasWorldReference<KlondikeWorld> {
 20  Card(int intRank, int intSuit, {this.isBaseCard = false})
 21      : rank = Rank.fromInt(intRank),
 22        suit = Suit.fromInt(intSuit),
 23        super(
 24          size: KlondikeGame.cardSize,
 25        );
 26
 27  final Rank rank;
 28  final Suit suit;
 29  Pile? pile;
 30
 31  // A Base Card is rendered in outline only and is NOT playable. It can be
 32  // added to the base of a Pile (e.g. the Stock Pile) to allow it to handle
 33  // taps and short drags (on an empty Pile) with the same behavior and
 34  // tolerances as for regular cards (see KlondikeGame.dragTolerance) and using
 35  // the same event-handling code, but with different handleTapUp() methods.
 36  final bool isBaseCard;
 37
 38  bool _faceUp = false;
 39  bool _isAnimatedFlip = false;
 40  bool _isFaceUpView = false;
 41  bool _isDragging = false;
 42  Vector2 _whereCardStarted = Vector2(0, 0);
 43
 44  final List<Card> attachedCards = [];
 45
 46  bool get isFaceUp => _faceUp;
 47  bool get isFaceDown => !_faceUp;
 48  void flip() {
 49    if (_isAnimatedFlip) {
 50      // Let the animation determine the FaceUp/FaceDown state.
 51      _faceUp = _isFaceUpView;
 52    } else {
 53      // No animation: flip and render the card immediately.
 54      _faceUp = !_faceUp;
 55      _isFaceUpView = _faceUp;
 56    }
 57  }
 58
 59  @override
 60  String toString() => rank.label + suit.label; // e.g. "Q♠" or "10♦"
 61
 62  //#region Rendering
 63
 64  @override
 65  void render(Canvas canvas) {
 66    if (isBaseCard) {
 67      _renderBaseCard(canvas);
 68      return;
 69    }
 70    if (_isFaceUpView) {
 71      _renderFront(canvas);
 72    } else {
 73      _renderBack(canvas);
 74    }
 75  }
 76
 77  static final Paint backBackgroundPaint = Paint()
 78    ..color = const Color(0xff380c02);
 79  static final Paint backBorderPaint1 = Paint()
 80    ..color = const Color(0xffdbaf58)
 81    ..style = PaintingStyle.stroke
 82    ..strokeWidth = 10;
 83  static final Paint backBorderPaint2 = Paint()
 84    ..color = const Color(0x5CEF971B)
 85    ..style = PaintingStyle.stroke
 86    ..strokeWidth = 35;
 87  static final RRect cardRRect = RRect.fromRectAndRadius(
 88    KlondikeGame.cardSize.toRect(),
 89    const Radius.circular(KlondikeGame.cardRadius),
 90  );
 91  static final RRect backRRectInner = cardRRect.deflate(40);
 92  static final Sprite flameSprite = klondikeSprite(1367, 6, 357, 501);
 93
 94  void _renderBack(Canvas canvas) {
 95    canvas.drawRRect(cardRRect, backBackgroundPaint);
 96    canvas.drawRRect(cardRRect, backBorderPaint1);
 97    canvas.drawRRect(backRRectInner, backBorderPaint2);
 98    flameSprite.render(canvas, position: size / 2, anchor: Anchor.center);
 99  }
100
101  void _renderBaseCard(Canvas canvas) {
102    canvas.drawRRect(cardRRect, backBorderPaint1);
103  }
104
105  static final Paint frontBackgroundPaint = Paint()
106    ..color = const Color(0xff000000);
107  static final Paint redBorderPaint = Paint()
108    ..color = const Color(0xffece8a3)
109    ..style = PaintingStyle.stroke
110    ..strokeWidth = 10;
111  static final Paint blackBorderPaint = Paint()
112    ..color = const Color(0xff7ab2e8)
113    ..style = PaintingStyle.stroke
114    ..strokeWidth = 10;
115  static final blueFilter = Paint()
116    ..colorFilter = const ColorFilter.mode(
117      Color(0x880d8bff),
118      BlendMode.srcATop,
119    );
120  static final Sprite redJack = klondikeSprite(81, 565, 562, 488);
121  static final Sprite redQueen = klondikeSprite(717, 541, 486, 515);
122  static final Sprite redKing = klondikeSprite(1305, 532, 407, 549);
123  static final Sprite blackJack = klondikeSprite(81, 565, 562, 488)
124    ..paint = blueFilter;
125  static final Sprite blackQueen = klondikeSprite(717, 541, 486, 515)
126    ..paint = blueFilter;
127  static final Sprite blackKing = klondikeSprite(1305, 532, 407, 549)
128    ..paint = blueFilter;
129
130  void _renderFront(Canvas canvas) {
131    canvas.drawRRect(cardRRect, frontBackgroundPaint);
132    canvas.drawRRect(
133      cardRRect,
134      suit.isRed ? redBorderPaint : blackBorderPaint,
135    );
136
137    final rankSprite = suit.isBlack ? rank.blackSprite : rank.redSprite;
138    final suitSprite = suit.sprite;
139    _drawSprite(canvas, rankSprite, 0.1, 0.08);
140    _drawSprite(canvas, suitSprite, 0.1, 0.18, scale: 0.5);
141    _drawSprite(canvas, rankSprite, 0.1, 0.08, rotate: true);
142    _drawSprite(canvas, suitSprite, 0.1, 0.18, scale: 0.5, rotate: true);
143    switch (rank.value) {
144      case 1:
145        _drawSprite(canvas, suitSprite, 0.5, 0.5, scale: 2.5);
146      case 2:
147        _drawSprite(canvas, suitSprite, 0.5, 0.25);
148        _drawSprite(canvas, suitSprite, 0.5, 0.25, rotate: true);
149      case 3:
150        _drawSprite(canvas, suitSprite, 0.5, 0.2);
151        _drawSprite(canvas, suitSprite, 0.5, 0.5);
152        _drawSprite(canvas, suitSprite, 0.5, 0.2, rotate: true);
153      case 4:
154        _drawSprite(canvas, suitSprite, 0.3, 0.25);
155        _drawSprite(canvas, suitSprite, 0.7, 0.25);
156        _drawSprite(canvas, suitSprite, 0.3, 0.25, rotate: true);
157        _drawSprite(canvas, suitSprite, 0.7, 0.25, rotate: true);
158      case 5:
159        _drawSprite(canvas, suitSprite, 0.3, 0.25);
160        _drawSprite(canvas, suitSprite, 0.7, 0.25);
161        _drawSprite(canvas, suitSprite, 0.3, 0.25, rotate: true);
162        _drawSprite(canvas, suitSprite, 0.7, 0.25, rotate: true);
163        _drawSprite(canvas, suitSprite, 0.5, 0.5);
164      case 6:
165        _drawSprite(canvas, suitSprite, 0.3, 0.25);
166        _drawSprite(canvas, suitSprite, 0.7, 0.25);
167        _drawSprite(canvas, suitSprite, 0.3, 0.5);
168        _drawSprite(canvas, suitSprite, 0.7, 0.5);
169        _drawSprite(canvas, suitSprite, 0.3, 0.25, rotate: true);
170        _drawSprite(canvas, suitSprite, 0.7, 0.25, rotate: true);
171      case 7:
172        _drawSprite(canvas, suitSprite, 0.3, 0.2);
173        _drawSprite(canvas, suitSprite, 0.7, 0.2);
174        _drawSprite(canvas, suitSprite, 0.5, 0.35);
175        _drawSprite(canvas, suitSprite, 0.3, 0.5);
176        _drawSprite(canvas, suitSprite, 0.7, 0.5);
177        _drawSprite(canvas, suitSprite, 0.3, 0.2, rotate: true);
178        _drawSprite(canvas, suitSprite, 0.7, 0.2, rotate: true);
179      case 8:
180        _drawSprite(canvas, suitSprite, 0.3, 0.2);
181        _drawSprite(canvas, suitSprite, 0.7, 0.2);
182        _drawSprite(canvas, suitSprite, 0.5, 0.35);
183        _drawSprite(canvas, suitSprite, 0.3, 0.5);
184        _drawSprite(canvas, suitSprite, 0.7, 0.5);
185        _drawSprite(canvas, suitSprite, 0.3, 0.2, rotate: true);
186        _drawSprite(canvas, suitSprite, 0.7, 0.2, rotate: true);
187        _drawSprite(canvas, suitSprite, 0.5, 0.35, rotate: true);
188      case 9:
189        _drawSprite(canvas, suitSprite, 0.3, 0.2);
190        _drawSprite(canvas, suitSprite, 0.7, 0.2);
191        _drawSprite(canvas, suitSprite, 0.5, 0.3);
192        _drawSprite(canvas, suitSprite, 0.3, 0.4);
193        _drawSprite(canvas, suitSprite, 0.7, 0.4);
194        _drawSprite(canvas, suitSprite, 0.3, 0.2, rotate: true);
195        _drawSprite(canvas, suitSprite, 0.7, 0.2, rotate: true);
196        _drawSprite(canvas, suitSprite, 0.3, 0.4, rotate: true);
197        _drawSprite(canvas, suitSprite, 0.7, 0.4, rotate: true);
198      case 10:
199        _drawSprite(canvas, suitSprite, 0.3, 0.2);
200        _drawSprite(canvas, suitSprite, 0.7, 0.2);
201        _drawSprite(canvas, suitSprite, 0.5, 0.3);
202        _drawSprite(canvas, suitSprite, 0.3, 0.4);
203        _drawSprite(canvas, suitSprite, 0.7, 0.4);
204        _drawSprite(canvas, suitSprite, 0.3, 0.2, rotate: true);
205        _drawSprite(canvas, suitSprite, 0.7, 0.2, rotate: true);
206        _drawSprite(canvas, suitSprite, 0.5, 0.3, rotate: true);
207        _drawSprite(canvas, suitSprite, 0.3, 0.4, rotate: true);
208        _drawSprite(canvas, suitSprite, 0.7, 0.4, rotate: true);
209      case 11:
210        _drawSprite(canvas, suit.isRed ? redJack : blackJack, 0.5, 0.5);
211      case 12:
212        _drawSprite(canvas, suit.isRed ? redQueen : blackQueen, 0.5, 0.5);
213      case 13:
214        _drawSprite(canvas, suit.isRed ? redKing : blackKing, 0.5, 0.5);
215    }
216  }
217
218  void _drawSprite(
219    Canvas canvas,
220    Sprite sprite,
221    double relativeX,
222    double relativeY, {
223    double scale = 1,
224    bool rotate = false,
225  }) {
226    if (rotate) {
227      canvas.save();
228      canvas.translate(size.x / 2, size.y / 2);
229      canvas.rotate(pi);
230      canvas.translate(-size.x / 2, -size.y / 2);
231    }
232    sprite.render(
233      canvas,
234      position: Vector2(relativeX * size.x, relativeY * size.y),
235      anchor: Anchor.center,
236      size: sprite.srcSize.scaled(scale),
237    );
238    if (rotate) {
239      canvas.restore();
240    }
241  }
242
243  //#endregion
244
245  //#region Card-Dragging
246
247  @override
248  void onTapCancel(TapCancelEvent event) {
249    if (pile is StockPile) {
250      _isDragging = false;
251      handleTapUp();
252    }
253  }
254
255  @override
256  void onDragStart(DragStartEvent event) {
257    super.onDragStart(event);
258    if (pile is StockPile) {
259      _isDragging = false;
260      return;
261    }
262    // Clone the position, else _whereCardStarted changes as the position does.
263    _whereCardStarted = position.clone();
264    attachedCards.clear();
265    if (pile?.canMoveCard(this, MoveMethod.drag) ?? false) {
266      _isDragging = true;
267      priority = 100;
268      if (pile is TableauPile) {
269        final extraCards = (pile! as TableauPile).cardsOnTop(this);
270        for (final card in extraCards) {
271          card.priority = attachedCards.length + 101;
272          attachedCards.add(card);
273        }
274      }
275    }
276  }
277
278  @override
279  void onDragUpdate(DragUpdateEvent event) {
280    if (!_isDragging) {
281      return;
282    }
283    final delta = event.localDelta;
284    position.add(delta);
285    attachedCards.forEach((card) => card.position.add(delta));
286  }
287
288  @override
289  void onDragEnd(DragEndEvent event) {
290    super.onDragEnd(event);
291    if (!_isDragging) {
292      return;
293    }
294    _isDragging = false;
295
296    // If short drag, return card to Pile and treat it as having been tapped.
297    final shortDrag =
298        (position - _whereCardStarted).length < KlondikeGame.dragTolerance;
299    if (shortDrag && attachedCards.isEmpty) {
300      doMove(
301        _whereCardStarted,
302        onComplete: () {
303          pile!.returnCard(this);
304          // Card moves to its Foundation Pile next, if valid, or it stays put.
305          handleTapUp();
306        },
307      );
308      return;
309    }
310
311    // Find out what is under the center-point of this card when it is dropped.
312    final dropPiles = parent!
313        .componentsAtPoint(position + size / 2)
314        .whereType<Pile>()
315        .toList();
316    if (dropPiles.isNotEmpty) {
317      if (dropPiles.first.canAcceptCard(this)) {
318        // Found a Pile: move card(s) the rest of the way onto it.
319        pile!.removeCard(this, MoveMethod.drag);
320        if (dropPiles.first is TableauPile) {
321          // Get TableauPile to handle positions, priorities and moves of cards.
322          (dropPiles.first as TableauPile).dropCards(this, attachedCards);
323          attachedCards.clear();
324        } else {
325          // Drop a single card onto a FoundationPile.
326          final dropPosition = (dropPiles.first as FoundationPile).position;
327          doMove(
328            dropPosition,
329            onComplete: () {
330              dropPiles.first.acquireCard(this);
331            },
332          );
333        }
334        return;
335      }
336    }
337
338    // Invalid drop (middle of nowhere, invalid pile or invalid card for pile).
339    doMove(
340      _whereCardStarted,
341      onComplete: () {
342        pile!.returnCard(this);
343      },
344    );
345    if (attachedCards.isNotEmpty) {
346      attachedCards.forEach((card) {
347        final offset = card.position - position;
348        card.doMove(
349          _whereCardStarted + offset,
350          onComplete: () {
351            pile!.returnCard(card);
352          },
353        );
354      });
355      attachedCards.clear();
356    }
357  }
358
359  //#endregion
360
361  //#region Card-Tapping
362
363  // Tap a face-up card to make it auto-move and go out (if acceptable), but
364  // if it is face-down and on the Stock Pile, pass the event to that pile.
365
366  @override
367  void onTapUp(TapUpEvent event) {
368    handleTapUp();
369  }
370
371  void handleTapUp() {
372    // Can be called by onTapUp or after a very short (failed) drag-and-drop.
373    // We need to be more user-friendly towards taps that include a short drag.
374    if (pile?.canMoveCard(this, MoveMethod.tap) ?? false) {
375      final suitIndex = suit.value;
376      if (world.foundations[suitIndex].canAcceptCard(this)) {
377        pile!.removeCard(this, MoveMethod.tap);
378        doMove(
379          world.foundations[suitIndex].position,
380          onComplete: () {
381            world.foundations[suitIndex].acquireCard(this);
382          },
383        );
384      }
385    } else if (pile is StockPile) {
386      world.stock.handleTapUp(this);
387    }
388  }
389
390  //#endRegion
391
392  //#region Effects
393
394  void doMove(
395    Vector2 to, {
396    double speed = 10.0,
397    double start = 0.0,
398    int startPriority = 100,
399    Curve curve = Curves.easeOutQuad,
400    VoidCallback? onComplete,
401  }) {
402    assert(speed > 0.0, 'Speed must be > 0 widths per second');
403    final dt = (to - position).length / (speed * size.x);
404    assert(dt > 0, 'Distance to move must be > 0');
405    add(
406      CardMoveEffect(
407        to,
408        EffectController(duration: dt, startDelay: start, curve: curve),
409        transitPriority: startPriority,
410        onComplete: () {
411          onComplete?.call();
412        },
413      ),
414    );
415  }
416
417  void doMoveAndFlip(
418    Vector2 to, {
419    double speed = 10.0,
420    double start = 0.0,
421    Curve curve = Curves.easeOutQuad,
422    VoidCallback? whenDone,
423  }) {
424    assert(speed > 0.0, 'Speed must be > 0 widths per second');
425    final dt = (to - position).length / (speed * size.x);
426    assert(dt > 0, 'Distance to move must be > 0');
427    priority = 100;
428    add(
429      MoveToEffect(
430        to,
431        EffectController(duration: dt, startDelay: start, curve: curve),
432        onComplete: () {
433          turnFaceUp(
434            onComplete: whenDone,
435          );
436        },
437      ),
438    );
439  }
440
441  void turnFaceUp({
442    double time = 0.3,
443    double start = 0.0,
444    VoidCallback? onComplete,
445  }) {
446    assert(!_isFaceUpView, 'Card must be face-down before turning face-up.');
447    assert(time > 0.0, 'Time to turn card over must be > 0');
448    assert(start >= 0.0, 'Start tim must be >= 0');
449    _isAnimatedFlip = true;
450    anchor = Anchor.topCenter;
451    position += Vector2(width / 2, 0);
452    priority = 100;
453    add(
454      ScaleEffect.to(
455        Vector2(scale.x / 100, scale.y),
456        EffectController(
457          startDelay: start,
458          curve: Curves.easeOutSine,
459          duration: time / 2,
460          onMax: () {
461            _isFaceUpView = true;
462          },
463          reverseDuration: time / 2,
464          onMin: () {
465            _isAnimatedFlip = false;
466            _faceUp = true;
467            anchor = Anchor.topLeft;
468            position -= Vector2(width / 2, 0);
469          },
470        ),
471        onComplete: () {
472          onComplete?.call();
473        },
474      ),
475    );
476  }
477
478  //#endregion
479}
480
481class CardMoveEffect extends MoveToEffect {
482  CardMoveEffect(
483    super.destination,
484    super.controller, {
485    super.onComplete,
486    this.transitPriority = 100,
487  });
488
489  final int transitPriority;
490
491  @override
492  void onStart() {
493    super.onStart(); // Flame connects MoveToEffect to EffectController.
494    parent?.priority = transitPriority;
495  }
496}
components/flat_button.dart
 1import 'package:flame/components.dart';
 2import 'package:flame/input.dart';
 3import 'package:flame/text.dart';
 4import 'package:flutter/material.dart';
 5
 6class FlatButton extends ButtonComponent {
 7  FlatButton(
 8    String text, {
 9    super.size,
10    super.onReleased,
11    super.position,
12  }) : super(
13          button: ButtonBackground(const Color(0xffece8a3)),
14          buttonDown: ButtonBackground(Colors.red),
15          children: [
16            TextComponent(
17              text: text,
18              textRenderer: TextPaint(
19                style: TextStyle(
20                  fontSize: 0.5 * size!.y,
21                  fontWeight: FontWeight.bold,
22                  color: const Color(0xffdbaf58),
23                ),
24              ),
25              position: size / 2.0,
26              anchor: Anchor.center,
27            ),
28          ],
29          anchor: Anchor.center,
30        );
31}
32
33class ButtonBackground extends PositionComponent with HasAncestor<FlatButton> {
34  final _paint = Paint()..style = PaintingStyle.stroke;
35
36  late double cornerRadius;
37
38  ButtonBackground(Color color) {
39    _paint.color = color;
40  }
41
42  @override
43  void onMount() {
44    super.onMount();
45    size = ancestor.size;
46    cornerRadius = 0.3 * size.y;
47    _paint.strokeWidth = 0.05 * size.y;
48  }
49
50  late final _background = RRect.fromRectAndRadius(
51    size.toRect(),
52    Radius.circular(cornerRadius),
53  );
54
55  @override
56  void render(Canvas canvas) {
57    canvas.drawRRect(_background, _paint);
58  }
59}
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, this.checkWin, {super.position})
12      : suit = Suit.fromInt(intSuit),
13        super(size: KlondikeGame.cardSize);
14
15  final VoidCallback checkWin;
16
17  final Suit suit;
18  final List<Card> _cards = [];
19
20  //#region Pile API
21
22  bool get isFull => _cards.length == 13;
23
24  @override
25  bool canMoveCard(Card card, MoveMethod method) =>
26      _cards.isNotEmpty && card == _cards.last && method != MoveMethod.tap;
27
28  @override
29  bool canAcceptCard(Card card) {
30    final topCardRank = _cards.isEmpty ? 0 : _cards.last.rank.value;
31    return card.suit == suit &&
32        card.rank.value == topCardRank + 1 &&
33        card.attachedCards.isEmpty;
34  }
35
36  @override
37  void removeCard(Card card, MoveMethod method) {
38    assert(canMoveCard(card, method));
39    _cards.removeLast();
40  }
41
42  @override
43  void returnCard(Card card) {
44    card.position = position;
45    card.priority = _cards.indexOf(card);
46  }
47
48  @override
49  void acquireCard(Card card) {
50    assert(card.isFaceUp);
51    card.position = position;
52    card.priority = _cards.length;
53    card.pile = this;
54    _cards.add(card);
55    if (isFull) {
56      checkWin(); // Get KlondikeWorld to check all FoundationPiles.
57    }
58  }
59
60  //#endregion
61
62  //#region Rendering
63
64  final _borderPaint = Paint()
65    ..style = PaintingStyle.stroke
66    ..strokeWidth = 10
67    ..color = const Color(0x50ffffff);
68  late final _suitPaint = Paint()
69    ..color = suit.isRed ? const Color(0x3a000000) : const Color(0x64000000)
70    ..blendMode = BlendMode.luminosity;
71
72  @override
73  void render(Canvas canvas) {
74    canvas.drawRRect(KlondikeGame.cardRRect, _borderPaint);
75    suit.sprite.render(
76      canvas,
77      position: size / 2,
78      anchor: Anchor.center,
79      size: Vector2.all(KlondikeGame.cardWidth * 0.6),
80      overridePaint: _suitPaint,
81    );
82  }
83
84  //#endregion
85}
components/stock_pile.dart
 1import 'dart:ui';
 2
 3import 'package:flame/components.dart';
 4
 5import '../klondike_game.dart';
 6import '../pile.dart';
 7import 'card.dart';
 8import 'waste_pile.dart';
 9
10class StockPile extends PositionComponent
11    with HasGameReference<KlondikeGame>
12    implements Pile {
13  StockPile({super.position}) : super(size: KlondikeGame.cardSize);
14
15  /// Which cards are currently placed onto this pile. The first card in the
16  /// list is at the bottom, the last card is on top.
17  final List<Card> _cards = [];
18
19  //#region Pile API
20
21  @override
22  bool canMoveCard(Card card, MoveMethod method) => false;
23  // Can be moved by onTapUp callback (see below).
24
25  @override
26  bool canAcceptCard(Card card) => false;
27
28  @override
29  void removeCard(Card card, MoveMethod method) =>
30      throw StateError('cannot remove cards');
31
32  @override
33  // Card cannot be removed but could have been dragged out of place.
34  void returnCard(Card card) => card.priority = _cards.indexOf(card);
35
36  @override
37  void acquireCard(Card card) {
38    assert(card.isFaceDown);
39    card.pile = this;
40    card.position = position;
41    card.priority = _cards.length;
42    _cards.add(card);
43  }
44
45  //#endregion
46
47  void handleTapUp(Card card) {
48    final wastePile = parent!.firstChild<WastePile>()!;
49    if (_cards.isEmpty) {
50      assert(card.isBaseCard, 'Stock Pile is empty, but no Base Card present');
51      card.position = position; // Force Base Card (back) into correct position.
52      wastePile.removeAllCards().reversed.forEach((card) {
53        card.flip();
54        acquireCard(card);
55      });
56    } else {
57      for (var i = 0; i < game.klondikeDraw; i++) {
58        if (_cards.isNotEmpty) {
59          final card = _cards.removeLast();
60          card.doMoveAndFlip(
61            wastePile.position,
62            whenDone: () {
63              wastePile.acquireCard(card);
64            },
65          );
66        }
67      }
68    }
69  }
70
71  //#region Rendering
72
73  final _borderPaint = Paint()
74    ..style = PaintingStyle.stroke
75    ..strokeWidth = 10
76    ..color = const Color(0xFF3F5B5D);
77  final _circlePaint = Paint()
78    ..style = PaintingStyle.stroke
79    ..strokeWidth = 100
80    ..color = const Color(0x883F5B5D);
81
82  @override
83  void render(Canvas canvas) {
84    canvas.drawRRect(KlondikeGame.cardRRect, _borderPaint);
85    canvas.drawCircle(
86      Offset(width / 2, height / 2),
87      KlondikeGame.cardWidth * 0.3,
88      _circlePaint,
89    );
90  }
91
92  //#endregion
93}
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, MoveMethod method) =>
 21      card.isFaceUp && (method == MoveMethod.drag || card == _cards.last);
 22  // Drag can move multiple cards: tap can move last card only (to Foundation).
 23
 24  @override
 25  bool canAcceptCard(Card card) {
 26    if (_cards.isEmpty) {
 27      return card.rank.value == 13;
 28    } else {
 29      final topCard = _cards.last;
 30      return card.suit.isRed == !topCard.suit.isRed &&
 31          card.rank.value == topCard.rank.value - 1;
 32    }
 33  }
 34
 35  @override
 36  void removeCard(Card card, MoveMethod method) {
 37    assert(_cards.contains(card) && card.isFaceUp);
 38    final index = _cards.indexOf(card);
 39    _cards.removeRange(index, _cards.length);
 40    if (_cards.isNotEmpty && _cards.last.isFaceDown) {
 41      flipTopCard();
 42      return;
 43    }
 44    layOutCards();
 45  }
 46
 47  @override
 48  void returnCard(Card card) {
 49    card.priority = _cards.indexOf(card);
 50    layOutCards();
 51  }
 52
 53  @override
 54  void acquireCard(Card card) {
 55    card.pile = this;
 56    card.priority = _cards.length;
 57    _cards.add(card);
 58    layOutCards();
 59  }
 60
 61  //#endregion
 62
 63  void dropCards(Card firstCard, [List<Card> attachedCards = const []]) {
 64    final cardList = [firstCard];
 65    cardList.addAll(attachedCards);
 66    Vector2 nextPosition = _cards.isEmpty ? position : _cards.last.position;
 67    var nCardsToMove = cardList.length;
 68    for (final card in cardList) {
 69      card.pile = this;
 70      card.priority = _cards.length;
 71      if (_cards.isNotEmpty) {
 72        nextPosition =
 73            nextPosition + (card.isFaceDown ? _fanOffset1 : _fanOffset2);
 74      }
 75      _cards.add(card);
 76      card.doMove(
 77        nextPosition,
 78        startPriority: card.priority,
 79        onComplete: () {
 80          nCardsToMove--;
 81          if (nCardsToMove == 0) {
 82            calculateHitArea(); // Expand the hit-area.
 83          }
 84        },
 85      );
 86    }
 87  }
 88
 89  void flipTopCard({double start = 0.1}) {
 90    assert(_cards.last.isFaceDown);
 91    _cards.last.turnFaceUp(
 92      start: start,
 93      onComplete: layOutCards,
 94    );
 95  }
 96
 97  void layOutCards() {
 98    if (_cards.isEmpty) {
 99      calculateHitArea(); // Shrink hit-area when all cards have been removed.
100      return;
101    }
102    _cards[0].position.setFrom(position);
103    _cards[0].priority = 0;
104    for (var i = 1; i < _cards.length; i++) {
105      _cards[i].priority = i;
106      _cards[i].position
107        ..setFrom(_cards[i - 1].position)
108        ..add(_cards[i - 1].isFaceDown ? _fanOffset1 : _fanOffset2);
109    }
110    calculateHitArea(); // Adjust hit-area to more cards or fewer cards.
111  }
112
113  void calculateHitArea() {
114    height = KlondikeGame.cardHeight * 1.5 +
115        (_cards.length < 2 ? 0.0 : _cards.last.y - _cards.first.y);
116  }
117
118  List<Card> cardsOnTop(Card card) {
119    assert(card.isFaceUp && _cards.contains(card));
120    final index = _cards.indexOf(card);
121    return _cards.getRange(index + 1, _cards.length).toList();
122  }
123
124  //#region Rendering
125
126  final _borderPaint = Paint()
127    ..style = PaintingStyle.stroke
128    ..strokeWidth = 10
129    ..color = const Color(0x50ffffff);
130
131  @override
132  void render(Canvas canvas) {
133    canvas.drawRRect(KlondikeGame.cardRRect, _borderPaint);
134  }
135
136  //#endregion
137}
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
 8    with HasGameReference<KlondikeGame>
 9    implements Pile {
10  WastePile({super.position}) : super(size: KlondikeGame.cardSize);
11
12  final List<Card> _cards = [];
13  final Vector2 _fanOffset = Vector2(KlondikeGame.cardWidth * 0.2, 0);
14
15  //#region Pile API
16
17  @override
18  bool canMoveCard(Card card, MoveMethod method) =>
19      _cards.isNotEmpty && card == _cards.last; // Tap and drag are both OK.
20
21  @override
22  bool canAcceptCard(Card card) => false;
23
24  @override
25  void removeCard(Card card, MoveMethod method) {
26    assert(canMoveCard(card, method));
27    _cards.removeLast();
28    _fanOutTopCards();
29  }
30
31  @override
32  void returnCard(Card card) {
33    card.priority = _cards.indexOf(card);
34    _fanOutTopCards();
35  }
36
37  @override
38  void acquireCard(Card card) {
39    assert(card.isFaceUp);
40    card.pile = this;
41    card.position = position;
42    card.priority = _cards.length;
43    _cards.add(card);
44    _fanOutTopCards();
45  }
46
47  //#endregion
48
49  List<Card> removeAllCards() {
50    final cards = _cards.toList();
51    _cards.clear();
52    return cards;
53  }
54
55  void _fanOutTopCards() {
56    if (game.klondikeDraw == 1) {
57      // No fan-out in Klondike Draw 1.
58      return;
59    }
60    final n = _cards.length;
61    for (var i = 0; i < n; i++) {
62      _cards[i].position = position;
63    }
64    if (n == 2) {
65      _cards[1].position.add(_fanOffset);
66    } else if (n >= 3) {
67      _cards[n - 2].position.add(_fanOffset);
68      _cards[n - 1].position.addScaled(_fanOffset, 2);
69    }
70  }
71}
klondike_game.dart
 1import 'dart:ui';
 2
 3import 'package:flame/components.dart';
 4import 'package:flame/flame.dart';
 5import 'package:flame/game.dart';
 6
 7import 'klondike_world.dart';
 8
 9enum Action { newDeal, sameDeal, changeDraw, haveFun }
10
11class KlondikeGame extends FlameGame<KlondikeWorld> {
12  static const double cardGap = 175.0;
13  static const double topGap = 500.0;
14  static const double cardWidth = 1000.0;
15  static const double cardHeight = 1400.0;
16  static const double cardRadius = 100.0;
17  static const double cardSpaceWidth = cardWidth + cardGap;
18  static const double cardSpaceHeight = cardHeight + cardGap;
19  static final Vector2 cardSize = Vector2(cardWidth, cardHeight);
20  static final cardRRect = RRect.fromRectAndRadius(
21    const Rect.fromLTWH(0, 0, cardWidth, cardHeight),
22    const Radius.circular(cardRadius),
23  );
24
25  /// Constant used to decide when a short drag is treated as a TapUp event.
26  static const double dragTolerance = cardWidth / 5;
27
28  /// Constant used when creating Random seed.
29  static const int maxInt = 0xFFFFFFFE; // = (2 to the power 32) - 1
30
31  // This KlondikeGame constructor also initiates the first KlondikeWorld.
32  KlondikeGame() : super(world: KlondikeWorld());
33
34  // These three values persist between games and are starting conditions
35  // for the next game to be played in KlondikeWorld. The actual seed is
36  // computed in KlondikeWorld but is held here in case the player chooses
37  // to replay a game by selecting Action.sameDeal.
38  int klondikeDraw = 1;
39  int seed = 1;
40  Action action = Action.newDeal;
41}
42
43Sprite klondikeSprite(double x, double y, double width, double height) {
44  return Sprite(
45    Flame.images.fromCache('klondike-sprites.png'),
46    srcPosition: Vector2(x, y),
47    srcSize: Vector2(width, height),
48  );
49}
klondike_world.dart
  1import 'dart:math';
  2
  3import 'package:flame/components.dart';
  4import 'package:flame/flame.dart';
  5
  6import 'components/card.dart';
  7import 'components/flat_button.dart';
  8import 'components/foundation_pile.dart';
  9import 'components/stock_pile.dart';
 10import 'components/tableau_pile.dart';
 11import 'components/waste_pile.dart';
 12
 13import 'klondike_game.dart';
 14
 15class KlondikeWorld extends World with HasGameReference<KlondikeGame> {
 16  final cardGap = KlondikeGame.cardGap;
 17  final topGap = KlondikeGame.topGap;
 18  final cardSpaceWidth = KlondikeGame.cardSpaceWidth;
 19  final cardSpaceHeight = KlondikeGame.cardSpaceHeight;
 20
 21  final stock = StockPile(position: Vector2(0.0, 0.0));
 22  final waste = WastePile(position: Vector2(0.0, 0.0));
 23  final List<FoundationPile> foundations = [];
 24  final List<TableauPile> tableauPiles = [];
 25  final List<Card> cards = [];
 26  late Vector2 playAreaSize;
 27
 28  @override
 29  Future<void> onLoad() async {
 30    await Flame.images.load('klondike-sprites.png');
 31
 32    stock.position = Vector2(cardGap, topGap);
 33    waste.position = Vector2(cardSpaceWidth + cardGap, topGap);
 34
 35    for (var i = 0; i < 4; i++) {
 36      foundations.add(
 37        FoundationPile(
 38          i,
 39          checkWin,
 40          position: Vector2((i + 3) * cardSpaceWidth + cardGap, topGap),
 41        ),
 42      );
 43    }
 44    for (var i = 0; i < 7; i++) {
 45      tableauPiles.add(
 46        TableauPile(
 47          position: Vector2(
 48            i * cardSpaceWidth + cardGap,
 49            cardSpaceHeight + topGap,
 50          ),
 51        ),
 52      );
 53    }
 54
 55    // Add a Base Card to the Stock Pile, above the pile and below other cards.
 56    final baseCard = Card(1, 0, isBaseCard: true);
 57    baseCard.position = stock.position;
 58    baseCard.priority = -1;
 59    baseCard.pile = stock;
 60    stock.priority = -2;
 61
 62    for (var rank = 1; rank <= 13; rank++) {
 63      for (var suit = 0; suit < 4; suit++) {
 64        final card = Card(rank, suit);
 65        card.position = stock.position;
 66        cards.add(card);
 67      }
 68    }
 69
 70    add(stock);
 71    add(waste);
 72    addAll(foundations);
 73    addAll(tableauPiles);
 74    addAll(cards);
 75    add(baseCard);
 76
 77    playAreaSize =
 78        Vector2(7 * cardSpaceWidth + cardGap, 4 * cardSpaceHeight + topGap);
 79    final gameMidX = playAreaSize.x / 2;
 80
 81    addButton('New deal', gameMidX, Action.newDeal);
 82    addButton('Same deal', gameMidX + cardSpaceWidth, Action.sameDeal);
 83    addButton('Draw 1 or 3', gameMidX + 2 * cardSpaceWidth, Action.changeDraw);
 84    addButton('Have fun', gameMidX + 3 * cardSpaceWidth, Action.haveFun);
 85
 86    final camera = game.camera;
 87    camera.viewfinder.visibleGameSize = playAreaSize;
 88    camera.viewfinder.position = Vector2(gameMidX, 0);
 89    camera.viewfinder.anchor = Anchor.topCenter;
 90
 91    deal();
 92  }
 93
 94  void addButton(String label, double buttonX, Action action) {
 95    final button = FlatButton(
 96      label,
 97      size: Vector2(KlondikeGame.cardWidth, 0.6 * topGap),
 98      position: Vector2(buttonX, topGap / 2),
 99      onReleased: () {
100        if (action == Action.haveFun) {
101          // Shortcut to the "win" sequence, for Tutorial purposes only.
102          letsCelebrate();
103        } else {
104          // Restart with a new deal or the same deal as before.
105          game.action = action;
106          game.world = KlondikeWorld();
107        }
108      },
109    );
110    add(button);
111  }
112
113  void deal() {
114    assert(cards.length == 52, 'There are ${cards.length} cards: should be 52');
115
116    if (game.action != Action.sameDeal) {
117      // New deal: change the Random Number Generator's seed.
118      game.seed = Random().nextInt(KlondikeGame.maxInt);
119      if (game.action == Action.changeDraw) {
120        game.klondikeDraw = (game.klondikeDraw == 3) ? 1 : 3;
121      }
122    }
123    // For the "Same deal" option, re-use the previous seed, else use a new one.
124    cards.shuffle(Random(game.seed));
125
126    // Each card dealt must be seen to come from the top of the deck!
127    var dealPriority = 1;
128    for (final card in cards) {
129      card.priority = dealPriority++;
130    }
131
132    // Change priority as cards take off: so later cards fly above earlier ones.
133    var cardToDeal = cards.length - 1;
134    var nMovingCards = 0;
135    for (var i = 0; i < 7; i++) {
136      for (var j = i; j < 7; j++) {
137        final card = cards[cardToDeal--];
138        card.doMove(
139          tableauPiles[j].position,
140          speed: 15.0,
141          start: nMovingCards * 0.15,
142          startPriority: 100 + nMovingCards,
143          onComplete: () {
144            tableauPiles[j].acquireCard(card);
145            nMovingCards--;
146            if (nMovingCards == 0) {
147              var delayFactor = 0;
148              for (final tableauPile in tableauPiles) {
149                delayFactor++;
150                tableauPile.flipTopCard(start: delayFactor * 0.15);
151              }
152            }
153          },
154        );
155        nMovingCards++;
156      }
157    }
158    for (var n = 0; n <= cardToDeal; n++) {
159      stock.acquireCard(cards[n]);
160    }
161  }
162
163  void checkWin() {
164    // Callback from a Foundation Pile when it is full (Ace to King).
165    var nComplete = 0;
166    for (final f in foundations) {
167      if (f.isFull) {
168        nComplete++;
169      }
170    }
171    if (nComplete == foundations.length) {
172      letsCelebrate();
173    }
174  }
175
176  void letsCelebrate({int phase = 1}) {
177    // Deal won: bring all cards to the middle of the screen (phase 1)
178    // then scatter them to points just outside the screen (phase 2).
179    //
180    // First get the device's screen-size in game co-ordinates, then get the
181    // top-left of the off-screen area that will accept the scattered cards.
182    // Note: The play area is anchored at TopCenter, so topLeft.y is fixed.
183
184    final cameraZoom = game.camera.viewfinder.zoom;
185    final zoomedScreen = game.size / cameraZoom;
186    final screenCenter = (playAreaSize - KlondikeGame.cardSize) / 2;
187    final topLeft = Vector2(
188      (playAreaSize.x - zoomedScreen.x) / 2 - KlondikeGame.cardWidth,
189      -KlondikeGame.cardHeight,
190    );
191    final nCards = cards.length;
192    final offscreenHeight = zoomedScreen.y + KlondikeGame.cardSize.y;
193    final offscreenWidth = zoomedScreen.x + KlondikeGame.cardSize.x;
194    final spacing = 2.0 * (offscreenHeight + offscreenWidth) / nCards;
195
196    // Starting points, directions and lengths of offscreen rect's sides.
197    final corner = [
198      Vector2(0.0, 0.0),
199      Vector2(0.0, offscreenHeight),
200      Vector2(offscreenWidth, offscreenHeight),
201      Vector2(offscreenWidth, 0.0),
202    ];
203    final direction = [
204      Vector2(0.0, 1.0),
205      Vector2(1.0, 0.0),
206      Vector2(0.0, -1.0),
207      Vector2(-1.0, 0.0),
208    ];
209    final length = [
210      offscreenHeight,
211      offscreenWidth,
212      offscreenHeight,
213      offscreenWidth,
214    ];
215
216    var side = 0;
217    var cardsToMove = nCards;
218    var offScreenPosition = corner[side] + topLeft;
219    var space = length[side];
220    var cardNum = 0;
221
222    while (cardNum < nCards) {
223      final cardIndex = phase == 1 ? cardNum : nCards - cardNum - 1;
224      final card = cards[cardIndex];
225      card.priority = cardIndex + 1;
226      if (card.isFaceDown) {
227        card.flip();
228      }
229      // Start cards a short time apart to give a riffle effect.
230      final delay = phase == 1 ? cardNum * 0.02 : 0.5 + cardNum * 0.04;
231      final destination = (phase == 1) ? screenCenter : offScreenPosition;
232      card.doMove(
233        destination,
234        speed: (phase == 1) ? 15.0 : 5.0,
235        start: delay,
236        onComplete: () {
237          cardsToMove--;
238          if (cardsToMove == 0) {
239            if (phase == 1) {
240              letsCelebrate(phase: 2);
241            } else {
242              // Restart with a new deal after winning or pressing "Have fun".
243              game.action = Action.newDeal;
244              game.world = KlondikeWorld();
245            }
246          }
247        },
248      );
249      cardNum++;
250      if (phase == 1) {
251        continue;
252      }
253
254      // Phase 2: next card goes to same side with full spacing, if possible.
255      offScreenPosition = offScreenPosition + direction[side] * spacing;
256      space = space - spacing;
257      if ((space < 0.0) && (side < 3)) {
258        // Out of space: change to the next side and use excess spacing there.
259        side++;
260        offScreenPosition = corner[side] + topLeft - direction[side] * space;
261        space = length[side] + space;
262      }
263    }
264  }
265}
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
 3enum MoveMethod { drag, tap }
 4
 5abstract class Pile {
 6  /// Returns true if the [card] can be taken away from this pile and moved
 7  /// somewhere else. A tapping move may need additional validation.
 8  bool canMoveCard(Card card, MoveMethod method);
 9
10  /// Returns true if the [card] can be placed on top of this pile. The [card]
11  /// may have other cards "attached" to it.
12  bool canAcceptCard(Card card);
13
14  /// Removes [card] from this pile; this method will only be called for a card
15  /// that both belong to this pile, and for which [canMoveCard] returns true.
16  void removeCard(Card card, MoveMethod method);
17
18  /// Places a single [card] on top of this pile. This method will only be
19  /// called for a card for which [canAcceptCard] returns true.
20  void acquireCard(Card card);
21
22  /// Returns a [card], which already belongs to this pile, to its proper place.
23  void returnCard(Card card);
24}
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}