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        break;
147      case 2:
148        _drawSprite(canvas, suitSprite, 0.5, 0.25);
149        _drawSprite(canvas, suitSprite, 0.5, 0.25, rotate: true);
150        break;
151      case 3:
152        _drawSprite(canvas, suitSprite, 0.5, 0.2);
153        _drawSprite(canvas, suitSprite, 0.5, 0.5);
154        _drawSprite(canvas, suitSprite, 0.5, 0.2, rotate: true);
155        break;
156      case 4:
157        _drawSprite(canvas, suitSprite, 0.3, 0.25);
158        _drawSprite(canvas, suitSprite, 0.7, 0.25);
159        _drawSprite(canvas, suitSprite, 0.3, 0.25, rotate: true);
160        _drawSprite(canvas, suitSprite, 0.7, 0.25, rotate: true);
161        break;
162      case 5:
163        _drawSprite(canvas, suitSprite, 0.3, 0.25);
164        _drawSprite(canvas, suitSprite, 0.7, 0.25);
165        _drawSprite(canvas, suitSprite, 0.3, 0.25, rotate: true);
166        _drawSprite(canvas, suitSprite, 0.7, 0.25, rotate: true);
167        _drawSprite(canvas, suitSprite, 0.5, 0.5);
168        break;
169      case 6:
170        _drawSprite(canvas, suitSprite, 0.3, 0.25);
171        _drawSprite(canvas, suitSprite, 0.7, 0.25);
172        _drawSprite(canvas, suitSprite, 0.3, 0.5);
173        _drawSprite(canvas, suitSprite, 0.7, 0.5);
174        _drawSprite(canvas, suitSprite, 0.3, 0.25, rotate: true);
175        _drawSprite(canvas, suitSprite, 0.7, 0.25, rotate: true);
176        break;
177      case 7:
178        _drawSprite(canvas, suitSprite, 0.3, 0.2);
179        _drawSprite(canvas, suitSprite, 0.7, 0.2);
180        _drawSprite(canvas, suitSprite, 0.5, 0.35);
181        _drawSprite(canvas, suitSprite, 0.3, 0.5);
182        _drawSprite(canvas, suitSprite, 0.7, 0.5);
183        _drawSprite(canvas, suitSprite, 0.3, 0.2, rotate: true);
184        _drawSprite(canvas, suitSprite, 0.7, 0.2, rotate: true);
185        break;
186      case 8:
187        _drawSprite(canvas, suitSprite, 0.3, 0.2);
188        _drawSprite(canvas, suitSprite, 0.7, 0.2);
189        _drawSprite(canvas, suitSprite, 0.5, 0.35);
190        _drawSprite(canvas, suitSprite, 0.3, 0.5);
191        _drawSprite(canvas, suitSprite, 0.7, 0.5);
192        _drawSprite(canvas, suitSprite, 0.3, 0.2, rotate: true);
193        _drawSprite(canvas, suitSprite, 0.7, 0.2, rotate: true);
194        _drawSprite(canvas, suitSprite, 0.5, 0.35, rotate: true);
195        break;
196      case 9:
197        _drawSprite(canvas, suitSprite, 0.3, 0.2);
198        _drawSprite(canvas, suitSprite, 0.7, 0.2);
199        _drawSprite(canvas, suitSprite, 0.5, 0.3);
200        _drawSprite(canvas, suitSprite, 0.3, 0.4);
201        _drawSprite(canvas, suitSprite, 0.7, 0.4);
202        _drawSprite(canvas, suitSprite, 0.3, 0.2, rotate: true);
203        _drawSprite(canvas, suitSprite, 0.7, 0.2, rotate: true);
204        _drawSprite(canvas, suitSprite, 0.3, 0.4, rotate: true);
205        _drawSprite(canvas, suitSprite, 0.7, 0.4, rotate: true);
206        break;
207      case 10:
208        _drawSprite(canvas, suitSprite, 0.3, 0.2);
209        _drawSprite(canvas, suitSprite, 0.7, 0.2);
210        _drawSprite(canvas, suitSprite, 0.5, 0.3);
211        _drawSprite(canvas, suitSprite, 0.3, 0.4);
212        _drawSprite(canvas, suitSprite, 0.7, 0.4);
213        _drawSprite(canvas, suitSprite, 0.3, 0.2, rotate: true);
214        _drawSprite(canvas, suitSprite, 0.7, 0.2, rotate: true);
215        _drawSprite(canvas, suitSprite, 0.5, 0.3, rotate: true);
216        _drawSprite(canvas, suitSprite, 0.3, 0.4, rotate: true);
217        _drawSprite(canvas, suitSprite, 0.7, 0.4, rotate: true);
218        break;
219      case 11:
220        _drawSprite(canvas, suit.isRed ? redJack : blackJack, 0.5, 0.5);
221        break;
222      case 12:
223        _drawSprite(canvas, suit.isRed ? redQueen : blackQueen, 0.5, 0.5);
224        break;
225      case 13:
226        _drawSprite(canvas, suit.isRed ? redKing : blackKing, 0.5, 0.5);
227        break;
228    }
229  }
230
231  void _drawSprite(
232    Canvas canvas,
233    Sprite sprite,
234    double relativeX,
235    double relativeY, {
236    double scale = 1,
237    bool rotate = false,
238  }) {
239    if (rotate) {
240      canvas.save();
241      canvas.translate(size.x / 2, size.y / 2);
242      canvas.rotate(pi);
243      canvas.translate(-size.x / 2, -size.y / 2);
244    }
245    sprite.render(
246      canvas,
247      position: Vector2(relativeX * size.x, relativeY * size.y),
248      anchor: Anchor.center,
249      size: sprite.srcSize.scaled(scale),
250    );
251    if (rotate) {
252      canvas.restore();
253    }
254  }
255
256  //#endregion
257
258  //#region Card-Dragging
259
260  @override
261  void onTapCancel(TapCancelEvent event) {
262    if (pile is StockPile) {
263      _isDragging = false;
264      handleTapUp();
265    }
266  }
267
268  @override
269  void onDragStart(DragStartEvent event) {
270    super.onDragStart(event);
271    if (pile is StockPile) {
272      _isDragging = false;
273      return;
274    }
275    // Clone the position, else _whereCardStarted changes as the position does.
276    _whereCardStarted = position.clone();
277    attachedCards.clear();
278    if (pile?.canMoveCard(this, MoveMethod.drag) ?? false) {
279      _isDragging = true;
280      priority = 100;
281      if (pile is TableauPile) {
282        final extraCards = (pile! as TableauPile).cardsOnTop(this);
283        for (final card in extraCards) {
284          card.priority = attachedCards.length + 101;
285          attachedCards.add(card);
286        }
287      }
288    }
289  }
290
291  @override
292  void onDragUpdate(DragUpdateEvent event) {
293    if (!_isDragging) {
294      return;
295    }
296    final delta = event.localDelta;
297    position.add(delta);
298    attachedCards.forEach((card) => card.position.add(delta));
299  }
300
301  @override
302  void onDragEnd(DragEndEvent event) {
303    super.onDragEnd(event);
304    if (!_isDragging) {
305      return;
306    }
307    _isDragging = false;
308
309    // If short drag, return card to Pile and treat it as having been tapped.
310    final shortDrag =
311        (position - _whereCardStarted).length < KlondikeGame.dragTolerance;
312    if (shortDrag && attachedCards.isEmpty) {
313      doMove(
314        _whereCardStarted,
315        onComplete: () {
316          pile!.returnCard(this);
317          // Card moves to its Foundation Pile next, if valid, or it stays put.
318          handleTapUp();
319        },
320      );
321      return;
322    }
323
324    // Find out what is under the center-point of this card when it is dropped.
325    final dropPiles = parent!
326        .componentsAtPoint(position + size / 2)
327        .whereType<Pile>()
328        .toList();
329    if (dropPiles.isNotEmpty) {
330      if (dropPiles.first.canAcceptCard(this)) {
331        // Found a Pile: move card(s) the rest of the way onto it.
332        pile!.removeCard(this, MoveMethod.drag);
333        if (dropPiles.first is TableauPile) {
334          // Get TableauPile to handle positions, priorities and moves of cards.
335          (dropPiles.first as TableauPile).dropCards(this, attachedCards);
336          attachedCards.clear();
337        } else {
338          // Drop a single card onto a FoundationPile.
339          final dropPosition = (dropPiles.first as FoundationPile).position;
340          doMove(
341            dropPosition,
342            onComplete: () {
343              dropPiles.first.acquireCard(this);
344            },
345          );
346        }
347        return;
348      }
349    }
350
351    // Invalid drop (middle of nowhere, invalid pile or invalid card for pile).
352    doMove(
353      _whereCardStarted,
354      onComplete: () {
355        pile!.returnCard(this);
356      },
357    );
358    if (attachedCards.isNotEmpty) {
359      attachedCards.forEach((card) {
360        final offset = card.position - position;
361        card.doMove(
362          _whereCardStarted + offset,
363          onComplete: () {
364            pile!.returnCard(card);
365          },
366        );
367      });
368      attachedCards.clear();
369    }
370  }
371
372  //#endregion
373
374  //#region Card-Tapping
375
376  // Tap a face-up card to make it auto-move and go out (if acceptable), but
377  // if it is face-down and on the Stock Pile, pass the event to that pile.
378
379  @override
380  void onTapUp(TapUpEvent event) {
381    handleTapUp();
382  }
383
384  void handleTapUp() {
385    // Can be called by onTapUp or after a very short (failed) drag-and-drop.
386    // We need to be more user-friendly towards taps that include a short drag.
387    if (pile?.canMoveCard(this, MoveMethod.tap) ?? false) {
388      final suitIndex = suit.value;
389      if (world.foundations[suitIndex].canAcceptCard(this)) {
390        pile!.removeCard(this, MoveMethod.tap);
391        doMove(
392          world.foundations[suitIndex].position,
393          onComplete: () {
394            world.foundations[suitIndex].acquireCard(this);
395          },
396        );
397      }
398    } else if (pile is StockPile) {
399      world.stock.handleTapUp(this);
400    }
401  }
402
403  //#endRegion
404
405  //#region Effects
406
407  void doMove(
408    Vector2 to, {
409    double speed = 10.0,
410    double start = 0.0,
411    int startPriority = 100,
412    Curve curve = Curves.easeOutQuad,
413    VoidCallback? onComplete,
414  }) {
415    assert(speed > 0.0, 'Speed must be > 0 widths per second');
416    final dt = (to - position).length / (speed * size.x);
417    assert(dt > 0, 'Distance to move must be > 0');
418    add(
419      CardMoveEffect(
420        to,
421        EffectController(duration: dt, startDelay: start, curve: curve),
422        transitPriority: startPriority,
423        onComplete: () {
424          onComplete?.call();
425        },
426      ),
427    );
428  }
429
430  void doMoveAndFlip(
431    Vector2 to, {
432    double speed = 10.0,
433    double start = 0.0,
434    Curve curve = Curves.easeOutQuad,
435    VoidCallback? whenDone,
436  }) {
437    assert(speed > 0.0, 'Speed must be > 0 widths per second');
438    final dt = (to - position).length / (speed * size.x);
439    assert(dt > 0, 'Distance to move must be > 0');
440    priority = 100;
441    add(
442      MoveToEffect(
443        to,
444        EffectController(duration: dt, startDelay: start, curve: curve),
445        onComplete: () {
446          turnFaceUp(
447            onComplete: whenDone,
448          );
449        },
450      ),
451    );
452  }
453
454  void turnFaceUp({
455    double time = 0.3,
456    double start = 0.0,
457    VoidCallback? onComplete,
458  }) {
459    assert(!_isFaceUpView, 'Card must be face-down before turning face-up.');
460    assert(time > 0.0, 'Time to turn card over must be > 0');
461    assert(start >= 0.0, 'Start tim must be >= 0');
462    _isAnimatedFlip = true;
463    anchor = Anchor.topCenter;
464    position += Vector2(width / 2, 0);
465    priority = 100;
466    add(
467      ScaleEffect.to(
468        Vector2(scale.x / 100, scale.y),
469        EffectController(
470          startDelay: start,
471          curve: Curves.easeOutSine,
472          duration: time / 2,
473          onMax: () {
474            _isFaceUpView = true;
475          },
476          reverseDuration: time / 2,
477          onMin: () {
478            _isAnimatedFlip = false;
479            _faceUp = true;
480            anchor = Anchor.topLeft;
481            position -= Vector2(width / 2, 0);
482          },
483        ),
484        onComplete: () {
485          onComplete?.call();
486        },
487      ),
488    );
489  }
490
491  //#endregion
492}
493
494class CardMoveEffect extends MoveToEffect {
495  CardMoveEffect(
496    super.destination,
497    super.controller, {
498    super.onComplete,
499    this.transitPriority = 100,
500  });
501
502  final int transitPriority;
503
504  @override
505  void onStart() {
506    super.onStart(); // Flame connects MoveToEffect to EffectController.
507    parent?.priority = transitPriority;
508  }
509}
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}