docs: Klondike tutorial, part 4 (#1740)

This PR adds step 4 for the Klondike tutorial: "Gameplay".
This commit is contained in:
Pasha Stetsenko
2022-06-27 13:31:23 -07:00
committed by GitHub
parent c44272be45
commit 02d0b71b23
22 changed files with 1774 additions and 18 deletions

View File

@ -1 +1,6 @@
include: package:flame_lint/analysis_options.yaml
linter:
rules:
always_use_package_imports: false
prefer_relative_imports: true

View File

@ -1,7 +1,8 @@
import 'dart:html'; // ignore: avoid_web_libraries_in_flutter
import 'package:flutter/widgets.dart';
import 'package:klondike/step2/main.dart' as step2;
import 'package:klondike/step3/main.dart' as step3;
import 'step2/main.dart' as step2;
import 'step3/main.dart' as step3;
import 'step4/main.dart' as step4;
void main() {
var page = window.location.search ?? '';
@ -17,6 +18,10 @@ void main() {
step3.main();
break;
case 'step4':
step4.main();
break;
default:
runApp(
Directionality(

View File

@ -3,10 +3,10 @@ import 'package:flame/experimental.dart';
import 'package:flame/flame.dart';
import 'package:flame/game.dart';
import 'package:klondike/step2/components/foundation.dart';
import 'package:klondike/step2/components/pile.dart';
import 'package:klondike/step2/components/stock.dart';
import 'package:klondike/step2/components/waste.dart';
import 'components/foundation.dart';
import 'components/pile.dart';
import 'components/stock.dart';
import 'components/waste.dart';
class KlondikeGame extends FlameGame {
static const double cardGap = 175.0;

View File

@ -1,7 +1,7 @@
import 'package:flame/game.dart';
import 'package:flutter/widgets.dart';
import 'package:klondike/step2/klondike_game.dart';
import 'klondike_game.dart';
void main() {
final game = KlondikeGame();

View File

@ -2,9 +2,9 @@ import 'dart:math';
import 'dart:ui';
import 'package:flame/components.dart';
import 'package:klondike/step3/klondike_game.dart';
import 'package:klondike/step3/rank.dart';
import 'package:klondike/step3/suit.dart';
import '../klondike_game.dart';
import '../rank.dart';
import '../suit.dart';
class Card extends PositionComponent {
Card(int intRank, int intSuit)

View File

@ -5,11 +5,11 @@ import 'package:flame/experimental.dart';
import 'package:flame/flame.dart';
import 'package:flame/game.dart';
import 'package:klondike/step3/components/card.dart';
import 'package:klondike/step3/components/foundation.dart';
import 'package:klondike/step3/components/pile.dart';
import 'package:klondike/step3/components/stock.dart';
import 'package:klondike/step3/components/waste.dart';
import 'components/card.dart';
import 'components/foundation.dart';
import 'components/pile.dart';
import 'components/stock.dart';
import 'components/waste.dart';
class KlondikeGame extends FlameGame {
static const double cardGap = 175.0;

View File

@ -1,7 +1,7 @@
import 'package:flame/game.dart';
import 'package:flutter/widgets.dart';
import 'package:klondike/step3/klondike_game.dart';
import 'klondike_game.dart';
void main() {
final game = KlondikeGame();

View File

@ -1,6 +1,6 @@
import 'package:flame/components.dart';
import 'package:flutter/foundation.dart';
import 'package:klondike/step3/klondike_game.dart';
import 'klondike_game.dart';
@immutable
class Rank {

View File

@ -1,6 +1,6 @@
import 'package:flame/sprite.dart';
import 'package:flutter/foundation.dart';
import 'package:klondike/step3/klondike_game.dart';
import 'klondike_game.dart';
@immutable
class Suit {

View File

@ -0,0 +1,282 @@
import 'dart:math';
import 'dart:ui';
import 'package:flame/components.dart';
import 'package:flame/experimental.dart';
import 'package:flame/game.dart';
import '../klondike_game.dart';
import '../pile.dart';
import '../rank.dart';
import '../suit.dart';
import 'tableau_pile.dart';
class Card extends PositionComponent with DragCallbacks {
Card(int intRank, int intSuit)
: rank = Rank.fromInt(intRank),
suit = Suit.fromInt(intSuit),
super(size: KlondikeGame.cardSize);
final Rank rank;
final Suit suit;
Pile? pile;
bool _faceUp = false;
bool _isDragging = false;
final List<Card> attachedCards = [];
bool get isFaceUp => _faceUp;
bool get isFaceDown => !_faceUp;
void flip() => _faceUp = !_faceUp;
@override
String toString() => rank.label + suit.label; // e.g. "Q♠" or "10♦"
//#region Rendering
@override
void render(Canvas canvas) {
if (_faceUp) {
_renderFront(canvas);
} else {
_renderBack(canvas);
}
}
static final Paint backBackgroundPaint = Paint()
..color = const Color(0xff380c02);
static final Paint backBorderPaint1 = Paint()
..color = const Color(0xffdbaf58)
..style = PaintingStyle.stroke
..strokeWidth = 10;
static final Paint backBorderPaint2 = Paint()
..color = const Color(0x5CEF971B)
..style = PaintingStyle.stroke
..strokeWidth = 35;
static final RRect cardRRect = RRect.fromRectAndRadius(
KlondikeGame.cardSize.toRect(),
const Radius.circular(KlondikeGame.cardRadius),
);
static final RRect backRRectInner = cardRRect.deflate(40);
static late final Sprite flameSprite = klondikeSprite(1367, 6, 357, 501);
void _renderBack(Canvas canvas) {
canvas.drawRRect(cardRRect, backBackgroundPaint);
canvas.drawRRect(cardRRect, backBorderPaint1);
canvas.drawRRect(backRRectInner, backBorderPaint2);
flameSprite.render(canvas, position: size / 2, anchor: Anchor.center);
}
static final Paint frontBackgroundPaint = Paint()
..color = const Color(0xff000000);
static final Paint redBorderPaint = Paint()
..color = const Color(0xffece8a3)
..style = PaintingStyle.stroke
..strokeWidth = 10;
static final Paint blackBorderPaint = Paint()
..color = const Color(0xff7ab2e8)
..style = PaintingStyle.stroke
..strokeWidth = 10;
static final blueFilter = Paint()
..colorFilter = const ColorFilter.mode(
Color(0x880d8bff),
BlendMode.srcATop,
);
static late final Sprite redJack = klondikeSprite(81, 565, 562, 488);
static late final Sprite redQueen = klondikeSprite(717, 541, 486, 515);
static late final Sprite redKing = klondikeSprite(1305, 532, 407, 549);
static late final Sprite blackJack = klondikeSprite(81, 565, 562, 488)
..paint = blueFilter;
static late final Sprite blackQueen = klondikeSprite(717, 541, 486, 515)
..paint = blueFilter;
static late final Sprite blackKing = klondikeSprite(1305, 532, 407, 549)
..paint = blueFilter;
void _renderFront(Canvas canvas) {
canvas.drawRRect(cardRRect, frontBackgroundPaint);
canvas.drawRRect(
cardRRect,
suit.isRed ? redBorderPaint : blackBorderPaint,
);
final rankSprite = suit.isBlack ? rank.blackSprite : rank.redSprite;
final suitSprite = suit.sprite;
_drawSprite(canvas, rankSprite, 0.1, 0.08);
_drawSprite(canvas, suitSprite, 0.1, 0.18, scale: 0.5);
_drawSprite(canvas, rankSprite, 0.1, 0.08, rotate: true);
_drawSprite(canvas, suitSprite, 0.1, 0.18, scale: 0.5, rotate: true);
switch (rank.value) {
case 1:
_drawSprite(canvas, suitSprite, 0.5, 0.5, scale: 2.5);
break;
case 2:
_drawSprite(canvas, suitSprite, 0.5, 0.25);
_drawSprite(canvas, suitSprite, 0.5, 0.25, rotate: true);
break;
case 3:
_drawSprite(canvas, suitSprite, 0.5, 0.2);
_drawSprite(canvas, suitSprite, 0.5, 0.5);
_drawSprite(canvas, suitSprite, 0.5, 0.2, rotate: true);
break;
case 4:
_drawSprite(canvas, suitSprite, 0.3, 0.25);
_drawSprite(canvas, suitSprite, 0.7, 0.25);
_drawSprite(canvas, suitSprite, 0.3, 0.25, rotate: true);
_drawSprite(canvas, suitSprite, 0.7, 0.25, rotate: true);
break;
case 5:
_drawSprite(canvas, suitSprite, 0.3, 0.25);
_drawSprite(canvas, suitSprite, 0.7, 0.25);
_drawSprite(canvas, suitSprite, 0.3, 0.25, rotate: true);
_drawSprite(canvas, suitSprite, 0.7, 0.25, rotate: true);
_drawSprite(canvas, suitSprite, 0.5, 0.5);
break;
case 6:
_drawSprite(canvas, suitSprite, 0.3, 0.25);
_drawSprite(canvas, suitSprite, 0.7, 0.25);
_drawSprite(canvas, suitSprite, 0.3, 0.5);
_drawSprite(canvas, suitSprite, 0.7, 0.5);
_drawSprite(canvas, suitSprite, 0.3, 0.25, rotate: true);
_drawSprite(canvas, suitSprite, 0.7, 0.25, rotate: true);
break;
case 7:
_drawSprite(canvas, suitSprite, 0.3, 0.2);
_drawSprite(canvas, suitSprite, 0.7, 0.2);
_drawSprite(canvas, suitSprite, 0.5, 0.35);
_drawSprite(canvas, suitSprite, 0.3, 0.5);
_drawSprite(canvas, suitSprite, 0.7, 0.5);
_drawSprite(canvas, suitSprite, 0.3, 0.2, rotate: true);
_drawSprite(canvas, suitSprite, 0.7, 0.2, rotate: true);
break;
case 8:
_drawSprite(canvas, suitSprite, 0.3, 0.2);
_drawSprite(canvas, suitSprite, 0.7, 0.2);
_drawSprite(canvas, suitSprite, 0.5, 0.35);
_drawSprite(canvas, suitSprite, 0.3, 0.5);
_drawSprite(canvas, suitSprite, 0.7, 0.5);
_drawSprite(canvas, suitSprite, 0.3, 0.2, rotate: true);
_drawSprite(canvas, suitSprite, 0.7, 0.2, rotate: true);
_drawSprite(canvas, suitSprite, 0.5, 0.35, rotate: true);
break;
case 9:
_drawSprite(canvas, suitSprite, 0.3, 0.2);
_drawSprite(canvas, suitSprite, 0.7, 0.2);
_drawSprite(canvas, suitSprite, 0.5, 0.3);
_drawSprite(canvas, suitSprite, 0.3, 0.4);
_drawSprite(canvas, suitSprite, 0.7, 0.4);
_drawSprite(canvas, suitSprite, 0.3, 0.2, rotate: true);
_drawSprite(canvas, suitSprite, 0.7, 0.2, rotate: true);
_drawSprite(canvas, suitSprite, 0.3, 0.4, rotate: true);
_drawSprite(canvas, suitSprite, 0.7, 0.4, rotate: true);
break;
case 10:
_drawSprite(canvas, suitSprite, 0.3, 0.2);
_drawSprite(canvas, suitSprite, 0.7, 0.2);
_drawSprite(canvas, suitSprite, 0.5, 0.3);
_drawSprite(canvas, suitSprite, 0.3, 0.4);
_drawSprite(canvas, suitSprite, 0.7, 0.4);
_drawSprite(canvas, suitSprite, 0.3, 0.2, rotate: true);
_drawSprite(canvas, suitSprite, 0.7, 0.2, rotate: true);
_drawSprite(canvas, suitSprite, 0.5, 0.3, rotate: true);
_drawSprite(canvas, suitSprite, 0.3, 0.4, rotate: true);
_drawSprite(canvas, suitSprite, 0.7, 0.4, rotate: true);
break;
case 11:
_drawSprite(canvas, suit.isRed ? redJack : blackJack, 0.5, 0.5);
break;
case 12:
_drawSprite(canvas, suit.isRed ? redQueen : blackQueen, 0.5, 0.5);
break;
case 13:
_drawSprite(canvas, suit.isRed ? redKing : blackKing, 0.5, 0.5);
break;
}
}
void _drawSprite(
Canvas canvas,
Sprite sprite,
double relativeX,
double relativeY, {
double scale = 1,
bool rotate = false,
}) {
if (rotate) {
canvas.save();
canvas.translate(size.x / 2, size.y / 2);
canvas.rotate(pi);
canvas.translate(-size.x / 2, -size.y / 2);
}
sprite.render(
canvas,
position: Vector2(relativeX * size.x, relativeY * size.y),
anchor: Anchor.center,
size: sprite.srcSize.scaled(scale),
);
if (rotate) {
canvas.restore();
}
}
//#endregion
//#region Dragging
@override
void onDragStart(DragStartEvent event) {
if (pile?.canMoveCard(this) ?? false) {
_isDragging = true;
priority = 100;
if (pile is TableauPile) {
attachedCards.clear();
final extraCards = (pile! as TableauPile).cardsOnTop(this);
for (final card in extraCards) {
card.priority = attachedCards.length + 101;
attachedCards.add(card);
}
}
}
}
@override
void onDragUpdate(DragUpdateEvent event) {
if (!_isDragging) {
return;
}
final cameraZoom = (findGame()! as FlameGame)
.firstChild<CameraComponent>()!
.viewfinder
.zoom;
final delta = event.delta / cameraZoom;
position.add(delta);
attachedCards.forEach((card) => card.position.add(delta));
}
@override
void onDragEnd(DragEndEvent event) {
if (!_isDragging) {
return;
}
_isDragging = false;
final dropPiles = parent!
.componentsAtPoint(position + size / 2)
.whereType<Pile>()
.toList();
if (dropPiles.isNotEmpty) {
if (dropPiles.first.canAcceptCard(this)) {
pile!.removeCard(this);
dropPiles.first.acquireCard(this);
if (attachedCards.isNotEmpty) {
attachedCards.forEach((card) => dropPiles.first.acquireCard(card));
attachedCards.clear();
}
return;
}
}
pile!.returnCard(this);
if (attachedCards.isNotEmpty) {
attachedCards.forEach((card) => pile!.returnCard(card));
attachedCards.clear();
}
}
//#endregion
}

View File

@ -0,0 +1,79 @@
import 'dart:ui';
import 'package:flame/components.dart';
import '../klondike_game.dart';
import '../pile.dart';
import '../suit.dart';
import 'card.dart';
class FoundationPile extends PositionComponent implements Pile {
FoundationPile(int intSuit, {super.position})
: suit = Suit.fromInt(intSuit),
super(size: KlondikeGame.cardSize);
final Suit suit;
final List<Card> _cards = [];
//#region Pile API
@override
bool canMoveCard(Card card) {
return _cards.isNotEmpty && card == _cards.last;
}
@override
bool canAcceptCard(Card card) {
final topCardRank = _cards.isEmpty ? 0 : _cards.last.rank.value;
return card.suit == suit &&
card.rank.value == topCardRank + 1 &&
card.attachedCards.isEmpty;
}
@override
void removeCard(Card card) {
assert(canMoveCard(card));
_cards.removeLast();
}
@override
void returnCard(Card card) {
card.position = position;
card.priority = _cards.indexOf(card);
}
@override
void acquireCard(Card card) {
assert(card.isFaceUp);
card.position = position;
card.priority = _cards.length;
card.pile = this;
_cards.add(card);
}
//#endregion
//#region Rendering
final _borderPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 10
..color = const Color(0x50ffffff);
late final _suitPaint = Paint()
..color = suit.isRed ? const Color(0x3a000000) : const Color(0x64000000)
..blendMode = BlendMode.luminosity;
@override
void render(Canvas canvas) {
canvas.drawRRect(KlondikeGame.cardRRect, _borderPaint);
suit.sprite.render(
canvas,
position: size / 2,
anchor: Anchor.center,
size: Vector2.all(KlondikeGame.cardWidth * 0.6),
overridePaint: _suitPaint,
);
}
//#endregion
}

View File

@ -0,0 +1,84 @@
import 'dart:ui';
import 'package:flame/components.dart';
import 'package:flame/experimental.dart';
import '../klondike_game.dart';
import '../pile.dart';
import 'card.dart';
import 'waste_pile.dart';
class StockPile extends PositionComponent with TapCallbacks implements Pile {
StockPile({super.position}) : super(size: KlondikeGame.cardSize);
/// Which cards are currently placed onto this pile. The first card in the
/// list is at the bottom, the last card is on top.
final List<Card> _cards = [];
//#region Pile API
@override
bool canMoveCard(Card card) => false;
@override
bool canAcceptCard(Card card) => false;
@override
void removeCard(Card card) => throw StateError('cannot remove cards');
@override
void returnCard(Card card) => throw StateError('cannot remove cards');
@override
void acquireCard(Card card) {
assert(card.isFaceDown);
card.pile = this;
card.position = position;
card.priority = _cards.length;
_cards.add(card);
}
//#endregion
@override
void onTapUp(TapUpEvent event) {
final wastePile = parent!.firstChild<WastePile>()!;
if (_cards.isEmpty) {
wastePile.removeAllCards().reversed.forEach((card) {
card.flip();
acquireCard(card);
});
} else {
for (var i = 0; i < 3; i++) {
if (_cards.isNotEmpty) {
final card = _cards.removeLast();
card.flip();
wastePile.acquireCard(card);
}
}
}
}
//#region Rendering
final _borderPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 10
..color = const Color(0xFF3F5B5D);
final _circlePaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 100
..color = const Color(0x883F5B5D);
@override
void render(Canvas canvas) {
canvas.drawRRect(KlondikeGame.cardRRect, _borderPaint);
canvas.drawCircle(
Offset(width / 2, height / 2),
KlondikeGame.cardWidth * 0.3,
_circlePaint,
);
}
//#endregion
}

View File

@ -0,0 +1,97 @@
import 'dart:ui';
import 'package:flame/components.dart';
import '../klondike_game.dart';
import '../pile.dart';
import 'card.dart';
class TableauPile extends PositionComponent implements Pile {
TableauPile({super.position}) : super(size: KlondikeGame.cardSize);
/// Which cards are currently placed onto this pile.
final List<Card> _cards = [];
final Vector2 _fanOffset1 = Vector2(0, KlondikeGame.cardHeight * 0.05);
final Vector2 _fanOffset2 = Vector2(0, KlondikeGame.cardHeight * 0.20);
//#region Pile API
@override
bool canMoveCard(Card card) => card.isFaceUp;
@override
bool canAcceptCard(Card card) {
if (_cards.isEmpty) {
return card.rank.value == 13;
} else {
final topCard = _cards.last;
return card.suit.isRed == !topCard.suit.isRed &&
card.rank.value == topCard.rank.value - 1;
}
}
@override
void removeCard(Card card) {
assert(_cards.contains(card) && card.isFaceUp);
final index = _cards.indexOf(card);
_cards.removeRange(index, _cards.length);
if (_cards.isNotEmpty && _cards.last.isFaceDown) {
flipTopCard();
}
layOutCards();
}
@override
void returnCard(Card card) {
card.priority = _cards.indexOf(card);
layOutCards();
}
@override
void acquireCard(Card card) {
card.pile = this;
card.priority = _cards.length;
_cards.add(card);
layOutCards();
}
//#endregion
void flipTopCard() {
assert(_cards.last.isFaceDown);
_cards.last.flip();
}
void layOutCards() {
if (_cards.isEmpty) {
return;
}
_cards[0].position.setFrom(position);
for (var i = 1; i < _cards.length; i++) {
_cards[i].position
..setFrom(_cards[i - 1].position)
..add(_cards[i - 1].isFaceDown ? _fanOffset1 : _fanOffset2);
}
height = KlondikeGame.cardHeight * 1.5 + _cards.last.y - _cards.first.y;
}
List<Card> cardsOnTop(Card card) {
assert(card.isFaceUp && _cards.contains(card));
final index = _cards.indexOf(card);
return _cards.getRange(index + 1, _cards.length).toList();
}
//#region Rendering
final _borderPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 10
..color = const Color(0x50ffffff);
@override
void render(Canvas canvas) {
canvas.drawRRect(KlondikeGame.cardRRect, _borderPaint);
}
//#endregion
}

View File

@ -0,0 +1,64 @@
import 'package:flame/components.dart';
import '../klondike_game.dart';
import '../pile.dart';
import 'card.dart';
class WastePile extends PositionComponent implements Pile {
WastePile({super.position}) : super(size: KlondikeGame.cardSize);
final List<Card> _cards = [];
final Vector2 _fanOffset = Vector2(KlondikeGame.cardWidth * 0.2, 0);
//#region Pile API
@override
bool canMoveCard(Card card) => _cards.isNotEmpty && card == _cards.last;
@override
bool canAcceptCard(Card card) => false;
@override
void removeCard(Card card) {
assert(canMoveCard(card));
_cards.removeLast();
_fanOutTopCards();
}
@override
void returnCard(Card card) {
card.priority = _cards.indexOf(card);
_fanOutTopCards();
}
@override
void acquireCard(Card card) {
assert(card.isFaceUp);
card.pile = this;
card.position = position;
card.priority = _cards.length;
_cards.add(card);
_fanOutTopCards();
}
//#endregion
List<Card> removeAllCards() {
final cards = _cards.toList();
_cards.clear();
return cards;
}
void _fanOutTopCards() {
final n = _cards.length;
for (var i = 0; i < n; i++) {
_cards[i].position = position;
}
if (n == 2) {
_cards[1].position.add(_fanOffset);
} else if (n >= 3) {
_cards[n - 2].position.add(_fanOffset);
_cards[n - 1].position.addScaled(_fanOffset, 2);
}
}
}

View File

@ -0,0 +1,87 @@
import 'dart:ui';
import 'package:flame/components.dart';
import 'package:flame/experimental.dart';
import 'package:flame/flame.dart';
import 'package:flame/game.dart';
import 'components/card.dart';
import 'components/foundation_pile.dart';
import 'components/stock_pile.dart';
import 'components/tableau_pile.dart';
import 'components/waste_pile.dart';
class KlondikeGame extends FlameGame
with HasTappableComponents, HasDraggableComponents {
static const double cardGap = 175.0;
static const double cardWidth = 1000.0;
static const double cardHeight = 1400.0;
static const double cardRadius = 100.0;
static final Vector2 cardSize = Vector2(cardWidth, cardHeight);
static final cardRRect = RRect.fromRectAndRadius(
const Rect.fromLTWH(0, 0, cardWidth, cardHeight),
const Radius.circular(cardRadius),
);
@override
Future<void> onLoad() async {
await Flame.images.load('klondike-sprites.png');
final stock = StockPile(position: Vector2(cardGap, cardGap));
final waste =
WastePile(position: Vector2(cardWidth + 2 * cardGap, cardGap));
final foundations = List.generate(
4,
(i) => FoundationPile(
i,
position: Vector2((i + 3) * (cardWidth + cardGap) + cardGap, cardGap),
),
);
final piles = List.generate(
7,
(i) => TableauPile(
position: Vector2(
cardGap + i * (cardWidth + cardGap),
cardHeight + 2 * cardGap,
),
),
);
final world = World()
..add(stock)
..add(waste)
..addAll(foundations)
..addAll(piles);
add(world);
final camera = CameraComponent(world: world)
..viewfinder.visibleGameSize =
Vector2(cardWidth * 7 + cardGap * 8, 4 * cardHeight + 3 * cardGap)
..viewfinder.position = Vector2(cardWidth * 3.5 + cardGap * 4, 0)
..viewfinder.anchor = Anchor.topCenter;
add(camera);
final cards = [
for (var rank = 1; rank <= 13; rank++)
for (var suit = 0; suit < 4; suit++) Card(rank, suit)
];
cards.shuffle();
world.addAll(cards);
for (var i = 0; i < 7; i++) {
for (var j = i; j < 7; j++) {
piles[j].acquireCard(cards.removeLast());
}
piles[i].flipTopCard();
}
cards.forEach(stock.acquireCard);
}
}
Sprite klondikeSprite(double x, double y, double width, double height) {
return Sprite(
Flame.images.fromCache('klondike-sprites.png'),
srcPosition: Vector2(x, y),
srcSize: Vector2(width, height),
);
}

View File

@ -0,0 +1,9 @@
import 'package:flame/game.dart';
import 'package:flutter/widgets.dart';
import 'klondike_game.dart';
void main() {
final game = KlondikeGame();
runApp(GameWidget(game: game));
}

View File

@ -0,0 +1,23 @@
import 'components/card.dart';
abstract class Pile {
/// Returns true if the [card] can be taken away from this pile and moved
/// somewhere else.
bool canMoveCard(Card card);
/// Returns true if the [card] can be placed on top of this pile. The [card]
/// may have other cards "attached" to it.
bool canAcceptCard(Card card);
/// Removes [card] from this pile; this method will only be called for a card
/// that both belong to this pile, and for which [canMoveCard] returns true.
void removeCard(Card card);
/// Places a single [card] on top of this pile. This method will only be
/// called for a card for which [canAcceptCard] returns true.
void acquireCard(Card card);
/// Returns the [card] (which already belongs to this pile) in its proper
/// place.
void returnCard(Card card);
}

View File

@ -0,0 +1,47 @@
import 'package:flame/components.dart';
import 'package:flutter/foundation.dart';
import 'klondike_game.dart';
@immutable
class Rank {
factory Rank.fromInt(int value) {
assert(
value >= 1 && value <= 13,
'value is outside of the bounds of what a rank can be',
);
return _singletons[value - 1];
}
Rank._(
this.value,
this.label,
double x1,
double y1,
double x2,
double y2,
double w,
double h,
) : redSprite = klondikeSprite(x1, y1, w, h),
blackSprite = klondikeSprite(x2, y2, w, h);
final int value;
final String label;
final Sprite redSprite;
final Sprite blackSprite;
static late final List<Rank> _singletons = [
Rank._(1, 'A', 335, 164, 789, 161, 120, 129),
Rank._(2, '2', 20, 19, 15, 322, 83, 125),
Rank._(3, '3', 122, 19, 117, 322, 80, 127),
Rank._(4, '4', 213, 12, 208, 315, 93, 132),
Rank._(5, '5', 314, 21, 309, 324, 85, 125),
Rank._(6, '6', 419, 17, 414, 320, 84, 129),
Rank._(7, '7', 509, 21, 505, 324, 92, 128),
Rank._(8, '8', 612, 19, 607, 322, 78, 127),
Rank._(9, '9', 709, 19, 704, 322, 84, 130),
Rank._(10, '10', 810, 20, 805, 322, 137, 127),
Rank._(11, 'J', 15, 170, 469, 167, 56, 126),
Rank._(12, 'Q', 92, 168, 547, 165, 132, 128),
Rank._(13, 'K', 243, 170, 696, 167, 92, 123),
];
}

View File

@ -0,0 +1,32 @@
import 'package:flame/sprite.dart';
import 'package:flutter/foundation.dart';
import 'klondike_game.dart';
@immutable
class Suit {
factory Suit.fromInt(int index) {
assert(
index >= 0 && index <= 3,
'index is outside of the bounds of what a suit can be',
);
return _singletons[index];
}
Suit._(this.value, this.label, double x, double y, double w, double h)
: sprite = klondikeSprite(x, y, w, h);
final int value;
final String label;
final Sprite sprite;
static late final List<Suit> _singletons = [
Suit._(0, '', 1176, 17, 172, 183),
Suit._(1, '', 973, 14, 177, 182),
Suit._(2, '', 974, 226, 184, 172),
Suit._(3, '', 1178, 220, 176, 182),
];
/// Hearts and Diamonds are red, while Clubs and Spades are black.
bool get isRed => value <= 1;
bool get isBlack => value >= 2;
}

View File

@ -16,5 +16,6 @@ with the [Dart] programming language.
1. Preparation <step1.md>
2. Scaffolding <step2.md>
3. Cards <step3.md>
4. Gameplay <step4.md>
[To be continued]... <tbc.md>
```

View File

@ -0,0 +1,933 @@
# Gameplay
In this chapter we will be implementing the core of Klondike's gameplay: how the cards move between
the stock and the waste, the piles and the foundations.
Before we begin though, let's clean up all those cards that we left scattered across the table in
the previous chapter. Open the `KlondikeGame` class and erase the loop at the bottom of `onLoad()`
that was adding 28 cards onto the table.
## The piles
Another small refactoring that we need to do is to rename our components: `Stock``StockPile`,
`Waste``WastePile`, `Foundation``FoundationPile`, and `Pile``TableauPile`. This is
because these components have some common features in how they handle interactions with the cards,
and it would be convenient to have all of them implement a common API. We will call the interface
that they will all be implementing the `Pile` class.
```{note}
Refactors and changes in architecture happen during development all the time: it's almost impossible
to get the structure right on the first try. Do not be anxious about changing code that you have
written in the past: it is a good habit to have.
```
After such a rename, we can begin implementing each of these components.
### Stock pile
The **stock** is a place in the top-left corner of the playing field which holds the cards that are
not currently in play. We will need to build the following functionality for this component:
1. Ability to hold cards that are not currently in play, face down;
2. Tapping the stock should reveal top 3 cards and move them to the **waste** pile;
3. When the cards run out, there should be a visual indicating that this is the stock pile;
4. When the cards run out, tapping the empty stock should move all the cards from the waste pile
into the stock, turning them face down.
The first question that needs to be decided here is this: who is going to own the `Card` components?
Previously we have been adding them directly to the game field, but now wouldn't it be better to
say that the cards belong to the `Stock` component, or to the waste, or piles, or foundations? While
this approach is tempting, I believe it would make our life more complicated as we need to move a
card from one place to another.
So, I decided to stick with my first approach: the `Card` components are owned directly by the
`KlondikeGame` itself, whereas the `StockPile` and other piles are merely aware of which cards are
currently placed there.
Having this in mind, let's start implementing the `StockPile` component:
```dart
class StockPile extends PositionComponent {
StockPile({super.position}) : super(size: KlondikeGame.cardSize);
/// Which cards are currently placed onto this pile. The first card in the
/// list is at the bottom, the last card is on top.
final List<Card> _cards = [];
void acquireCard(Card card) {
assert(!card.isFaceUp);
card.position = position;
card.priority = _cards.length;
_cards.add(card);
}
}
```
Here the `acquireCard()` method stores the provided card into the internal list `_cards`; it also
moves that card to the `StockPile`'s position and adjusts the cards priority so that they are
displayed in the right order. However, this method does not mount the card as a child of the
`StockPile` component -- it remains belonging to the top-level game.
Speaking of the game class, let's open the `KlondikeGame` and add the following lines to create a
full deck of 52 cards and put them onto the stock pile (this should be added at the end of the
`onLoad` method):
```dart
final cards = [
for (var rank = 1; rank <= 13; rank++)
for (var suit = 0; suit < 4; suit++)
Card(rank, suit)
];
world.addAll(cards);
cards.forEach(stock.acquireCard);
```
This concludes the first step of our short plan at the beginning of this section. For the second
step, though, we need to have a waste pile -- so let's make a quick detour and implement the
`WastePile` class.
### Waste pile
The **waste** is a pile next to the stock. During the course of the game we will be taking the cards
from the top of the stock pile and putting them into the waste. The functionality of this class is
quite simple: it holds a certain number of cards face up, fanning out the top 3.
Let's start implementing the `WastePile` class same way as we did with the `StockPile` class, only
now the cards are expected to be face up:
```dart
class WastePile extends PositionComponent {
WastePile({super.position}) : super(size: KlondikeGame.cardSize);
final List<Card> _cards = [];
void acquireCard(Card card) {
assert(card.isFaceUp);
card.position = position;
card.priority = _cards.length;
_cards.add(card);
}
}
```
So far, this puts all cards into a single neat pile, whereas we wanted a fan-out of top three. So,
let's add a dedicated method `_fanOutTopCards()` for this, which we will call at the end of each
`acquireCard()`:
```dart
void _fanOutTopCards() {
final n = _cards.length;
for (var i = 0; i < n; i++) {
_cards[i].position = position;
}
if (n == 2) {
_cards[1].position.add(_fanOffset);
} else if (n >= 3) {
_cards[n - 2].position.add(_fanOffset);
_cards[n - 1].position.addScaled(_fanOffset, 2);
}
}
```
The `_fanOffset` variable here helps determine the shift between cards in the fan, which I decided
to be about 20% of the card's width:
```dart
final Vector2 _fanOffset = Vector2(KlondikeGame.cardWidth * 0.2, 0);
```
Now that the waste pile is ready, let's get back to the `StockPile`.
### Stock pile -- tap to deal cards
The second item on our todo list is the first interactive functionality in the game: tap the stock
pile to deal 3 cards onto the waste.
Adding tap functionality to the components in Flame is quite simple: first, we add the mixin
`HasTappableComponents` to our top-level game class:
```dart
class KlondikeGame extends FlameGame with HasTappableComponents { ... }
```
And second, we add the mixin `TapCallbacks` to the component that we want to be tappable:
```dart
class StockPile extends PositionComponent with TapCallbacks { ... }
```
Oh, and we also need to say what we want to happen when the tap occurs. Here we want the top 3 cards
to be turned face up and moved to the waste pile. So, add the following method to the `StockPile`
class:
```dart
@override
void onTapUp(TapUpEvent event) {
final wastePile = parent!.firstChild<WastePile>()!;
for (var i = 0; i < 3; i++) {
if (_cards.isNotEmpty) {
final card = _cards.removeLast();
card.flip();
wastePile.acquireCard(card);
}
}
}
```
You have probably noticed that the cards move from one pile to another immediately, which looks very
unnatural. However, this is how it is going to be for now -- we will defer making the game more
smooth till the next chapter of the tutorial.
Also, the cards are organized in a well-defined order right now, starting from Kings and ending with
Aces. This doesn't make a very exciting gameplay though, so add line
```dart
cards.shuffle();
```
in the `KlondikeGame` class right after the list of cards is created.
```{seealso}
For more information about tap functionality, see [](../../flame/inputs/tap-events.md).
```
### Stock pile -- visual representation
Currently, when the stock pile has no cards, it simply shows an empty space -- there is no visual
cue that this is where the stock is. Such cue is needed, though, because we want the user to be
able to click the stock pile when it is empty in order to move all the cards from the waste back to
the stock so that they can be dealt again.
In our case, the empty stock pile will have a card-like border, and a circle in the middle:
```dart
@override
void render(Canvas canvas) {
canvas.drawRRect(KlondikeGame.cardRRect, _borderPaint);
canvas.drawCircle(
Offset(width / 2, height / 2),
KlondikeGame.cardWidth * 0.3,
_circlePaint,
);
}
```
where the paints are defined as
```dart
final _borderPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 10
..color = const Color(0xFF3F5B5D);
final _circlePaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 100
..color = const Color(0x883F5B5D);
```
and the `cardRRect` in the `KlondikeGame` class as
```dart
static final cardRRect = RRect.fromRectAndRadius(
const Rect.fromLTWH(0, 0, cardWidth, cardHeight),
const Radius.circular(cardRadius),
);
```
Now when you click through the stock pile till the end, you should be able to see the placeholder
for the stock cards.
### Stock pile -- refill from the waste
The last piece of functionality to add, is to move the cards back from the waste pile into the stock
pile when the user taps on an empty stock. To implement this, we will modify the `onTapUp()` method
like so:
```dart
@override
void onTapUp(TapUpEvent event) {
final wastePile = parent!.firstChild<WastePile>()!;
if (_cards.isEmpty) {
wastePile.removeAllCards().reversed.forEach((card) {
card.flip();
acquireCard(card);
});
} else {
for (var i = 0; i < 3; i++) {
if (_cards.isNotEmpty) {
final card = _cards.removeLast();
card.flip();
wastePile.acquireCard(card);
}
}
}
}
```
If you're curious why we needed to reverse the list of cards removed from the waste pile, then it is
because we want to simulate the entire waste pile being turned over at once, and not each card being
flipped one by one in their places. You can check that this is working as intended by verifying that
on each subsequent run through the stock pile, the cards are dealt in the same order as they were
dealt in the first run.
The method `WastePile.removeAllCards()` still needs to be implemented though:
```dart
List<Card> removeAllCards() {
final cards = _cards.toList();
_cards.clear();
return cards;
}
```
This pretty much concludes the `StockPile` functionality, and we already implemented the `WastePile`
-- so the only two components remaining are the `FoundationPile` and the `TableauPile`. We'll start
with the first one because it looks simpler.
### Foundation piles
The **foundation** piles are the four piles in the top right corner of the game. This is where we
will be building the ordered runs of cards from Ace to King. The functionality of this class is
similar to the `StockPile` and the `WastePile`: it has to be able to hold cards face up, and there
has to be some visual to show where the foundation is when there are no cards there.
First, let's implement the card-holding logic:
```dart
class FoundationPile extends PositionComponent {
FoundationPile({super.position}) : super(size: KlondikeGame.cardSize);
final List<Card> _cards = [];
void acquireCard(Card card) {
assert(card.isFaceUp);
card.position = position;
card.priority = _cards.length;
_cards.add(card);
}
}
```
For visual representation of a foundation, I've decided to make a large icon of that foundation's
suit, in grey color. Which means we'd need to update the definition of the class to include the
suit information:
```dart
class FoundationPile extends PositionComponent {
FoundationPile(int intSuit, {super.position})
: suit = Suit.fromInt(intSuit),
super(size: KlondikeGame.cardSize);
final Suit suit;
...
}
```
The code in the `KlondikeGame` class that generates the foundations will have to be adjusted
accordingly in order to pass the suit index to each foundation.
Now, the rendering code for the foundation pile will look like this:
```dart
@override
void render(Canvas canvas) {
canvas.drawRRect(KlondikeGame.cardRRect, _borderPaint);
suit.sprite.render(
canvas,
position: size / 2,
anchor: Anchor.center,
size: Vector2.all(KlondikeGame.cardWidth * 0.6),
overridePaint: _suitPaint,
);
}
```
Here we need to have two paint objects, one for the border and one for the suits:
```dart
final _borderPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 10
..color = const Color(0x50ffffff);
late final _suitPaint = Paint()
..color = suit.isRed? const Color(0x3a000000) : const Color(0x64000000)
..blendMode = BlendMode.luminosity;
```
The suit paint uses `BlendMode.luminosity` in order to convert the regular yellow/blue colors of
the suit sprites into greyscale. The "color" of the paint is different depending whether the suit
is red or black because the original luminosity of those sprites is different. Therefore, I had to
pick two different colors in order to make them look the same in greyscale.
### Tableau Piles
The last piece of the game to be implemented is the `TableauPile` component. There are seven of
these piles in total, and they are where the majority of the game play is happening.
The `TableauPile` also needs a visual representation, in order to indicate that it's a place where
a King can be placed when it is empty. I believe it could be just an empty frame, and that should
be sufficient:
```dart
class TableauPile extends PositionComponent {
TableauPile({super.position}) : super(size: KlondikeGame.cardSize);
final _borderPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 10
..color = const Color(0x50ffffff);
@override
void render(Canvas canvas) {
canvas.drawRRect(KlondikeGame.cardRRect, _borderPaint);
}
}
```
Oh, and the class will need to be able hold the cards too, obviously. Here, some of the cards will
be face down, while others will be face up. Also we will need a small amount of vertical fanning,
similar to how we did it for the `WastePile` component:
```dart
/// Which cards are currently placed onto this pile.
final List<Card> _cards = [];
final Vector2 _fanOffset = Vector2(0, KlondikeGame.cardHeight * 0.05);
void acquireCard(Card card) {
if (_cards.isEmpty) {
card.position = position;
} else {
card.position = _cards.last.position + _fanOffset;
}
card.priority = _cards.length;
_cards.add(card);
}
```
All that remains now is to head over to the `KlondikeGame` and make sure that the cards are dealt
into the `TableauPile`s at the beginning of the game. Modify the code at the end of the `onLoad()`
method so that it looks like this:
```dart
Future<void> onLoad() async {
...
final cards = [
for (var rank = 1; rank <= 13; rank++)
for (var suit = 0; suit < 4; suit++)
Card(rank, suit)
];
cards.shuffle();
world.addAll(cards);
for (var i = 0; i < 7; i++) {
for (var j = i; j < 7; j++) {
piles[j].acquireCard(cards.removeLast());
}
piles[i].flipTopCard();
}
cards.forEach(stock.acquireCard);
}
```
Note how we remove the cards from the deck and place them into `TableauPile`s one by one, and only
after that we put the remaining cards into the stock. Also, the `flipTopCard` method in the
`TableauPile` class is as trivial as it sounds:
```dart
void flipTopCard() {
assert(_cards.last.isFaceDown);
_cards.last.flip();
}
```
If you run the game at this point, it would be nicely set up and look as if it was ready to play.
Except that we can't move the cards yet, which is kinda a deal-breaker here. So without further ado,
presenting you the next section:
## Moving the cards
Moving the cards is a somewhat more complicated topic than what we have had so far. We will split
it into several smaller steps:
1. Simple movement: grab a card and move it around.
2. Ensure that the user can only move the cards that they are allowed to.
3. Check that the cards are dropped at proper destinations.
4. Drag a run of cards.
### 1. Simple movement
So, we want to be able to drag the cards on the screen. This is almost as simple as making the
`StockPile` tappable: first, we add the `HasDraggableComponents` mixin to our game class:
```dart
class KlondikeGame extends FlameGame
with HasTappableComponents, HasDraggableComponents {
...
}
```
Now, head over into the `Card` class and add the `DragCallbacks` mixin:
```dart
class Card extends PositionComponent with DragCallbacks {
}
```
The next step is to implement the actual drag event callbacks: `onDragStart`, `onDragUpdate`, and
`onDragEnd`.
When the drag gesture is initiated, the first thing that we need to do is to raise the priority of
the card, so that it is rendered above all others. Without this, the card would be occasionally
"sliding beneath" other cards, which would look most unnatural:
```dart
@override
void onDragStart(DragStartEvent event) {
priority = 100;
}
```
During the drag, the `onDragUpdate` event will be called continuously. Using this callback we will
be updating the position of the card so that it follows the movement of the finger (or the mouse).
The `event` object passed to this callback contains the most recent coordinate of the point of
touch, and also the `delta` property -- which is the displacement vector since the previous call of
`onDragUpdate`. The only problem is that this delta is measured in screen pixels, whereas we want
it to be in game world units. The conversion between the two is given by the camera zoom level, so
we will add an extra method to determine the zoom level:
```dart
@override
void onDragUpdate(DragUpdateEvent event) {
final cameraZoom = (findGame()! as FlameGame)
.firstChild<CameraComponent>()!
.viewfinder
.zoom;
position += event.delta / cameraZoom;
}
```
So far this allows you to grab any card and drag it anywhere around the table. What we want,
however, is to be able to restrict where the card is allowed or not allowed to go. This is where
the core of the logic of the game begins.
### 2. Move only allowed cards
The first restriction that we impose is that the user should only be able to drag the cards that we
allow, which include: (1) the top card of a waste pile, (2) the top card of a foundation pile, and
(3) any face-up card in a tableau pile.
Thus, in order to determine whether a card can be moved or not, we need to know which pile it
currently belongs to. There could be several ways that we go about it, but seemingly the most
straightforward is to let every card keep a reference to the pile in which it currently resides.
So, let's start by defining the abstract interface `Pile` that all our existing piles will be
implementing:
```dart
abstract class Pile {
bool canMoveCard(Card card);
}
```
We will expand this class further later, but for now let's make sure that each of the classes
`StockPile`, `WastePile`, `FoundationPile`, and `TableauPile` are marked as implementing this
interface:
```dart
class StockPile extends PositionComponent with TapCallbacks implements Pile {
...
@override
bool canMoveCard(Card card) => false;
}
class WastePile extends PositionComponent implements Pile {
...
@override
bool canMoveCard(Card card) => _cards.isNotEmpty && card == _cards.last;
}
class FoundationPile extends PositionComponent implements Pile {
...
@override
bool canMoveCard(Card card) => _cards.isNotEmpty && card == _cards.last;
}
class TableauPile extends PositionComponent implements Pile {
...
@override
bool canMoveCard(Card card) => _cards.isNotEmpty && card == _cards.last;
}
```
We also wanted to let every `Card` know which pile it is currently in. For this, add the field
`Pile? pile` into the `Card` class, and make sure to set it in each pile's `acquireCard()` method,
like so:
```dart
void acquireCard(Card card) {
...
card.pile = this;
}
```
Now we can put this new functionality to use: go into the `Card.onDragStart()` method and modify
it so that it would check whether the card is allowed to be moved before starting the drag:
```dart
void onDragStart(DragStartEvent event) {
if (pile?.canMoveCard(this) ?? false) {
_isDragging = true;
priority = 100;
}
}
```
We have also added the boolean `_isDragging` variable here: make sure to define it, and then to
check this flag in the `onDragUpdate()` method, and to set it back to false in the `onDragEnd()`:
```dart
@override
void onDragUpdate(DragUpdateEvent event) {
if (!_isDragging) {
return;
}
final cameraZoom = (findGame()! as FlameGame)
.firstChild<CameraComponent>()!
.viewfinder
.zoom;
position += event.delta / cameraZoom;
}
@override
void onDragEnd(DragEndEvent event) {
_isDragging = false;
}
```
Now the only the proper cards can be dragged, but they still drop at random positions on the table,
so let's work on that.
### 3. Dropping the cards at proper locations
At this point what we want to do is to figure out where the dragged card is being dropped. More
specifically, we want to know into which _pile_ it is being dropped. This can be achieved by using
the `componentsAtPoint()` API, which allows you to query which components are located at a given
position on the screen.
Thus, my first attempt at revising the `onDragEnd` callback looks like this:
```dart
@override
void onDragEnd(DragEndEvent event) {
if (!_isDragging) {
return;
}
_isDragging = false;
final dropPiles = parent!
.componentsAtPoint(position + size / 2)
.whereType<Pile>()
.toList();
if (dropPiles.isNotEmpty) {
// if (card is allowed to be dropped into this pile) {
// remove the card from the current pile
// add the card into the new pile
// }
}
// return the card to where it was originally
}
```
This still contains several placeholders for the functionality that still needs to be implemented,
so let's get to it.
First piece of the puzzle is the "is card allowed to be dropped here?" check. To implement this,
first head over into the `Pile` class and add the `canAcceptCard()` abstract method:
```dart
abstract class Pile {
...
bool canAcceptCard(Card card);
}
```
Obviously this now needs to be implemented for every `Pile` subclass, so let's get to it:
```dart
class FoundationPile ... implements Pile {
...
@override
bool canAcceptCard(Card card) {
final topCardRank = _cards.isEmpty? 0 : _cards.last.rank.value;
return card.suit == suit && card.rank.value == topCardRank + 1;
}
}
class TableauPile ... implements Pile {
...
@override
bool canAcceptCard(Card card) {
if (_cards.isEmpty) {
return card.rank.value == 13;
} else {
final topCard = _cards.last;
return card.suit.isRed == !topCard.suit.isRed &&
card.rank.value == topCard.rank.value - 1;
}
}
}
```
(for the `StockPile` and the `WastePile` the method should just return false, since no cards should
be dropped there).
Alright, next part is the "remove the card from its current pile". Once again, let's head over to
the `Pile` class and add the `removeCard()` abstract method:
```dart
abstract class Pile {
...
void removeCard(Card card);
}
```
Then we need to re-visit all four pile subclasses and implement this method:
```dart
class StockPile ... implements Pile {
...
@override
void removeCard(Card card) => throw StateError('cannot remove cards from here');
}
class WastePile ... implements Pile {
...
@override
void removeCard(Card card) {
assert(canMoveCard(card));
_cards.removeLast();
_fanOutTopCards();
}
}
class FoundationPile ... implements Pile {
...
@override
void removeCard(Card card) {
assert(canMoveCard(card));
_cards.removeLast();
}
}
class TableauPile ... implements Pile {
...
@override
void removeCard(Card card) {
assert(_cards.contains(card) && card.isFaceUp);
final index = _cards.indexOf(card);
_cards.removeRange(index, _cards.length);
if (_cards.isNotEmpty && _cards.last.isFaceDown) {
flipTopCard();
}
}
}
```
The next action in our pseudo-code is to "add the card to the new pile". But this one we have
already implemented: it's the `acquireCard()` method. So all we need is to declare it in the `Pile`
interface:
```dart
abstract class Pile {
...
void acquireCard(Card card);
}
```
The last piece that's missing is "return the card to where it was". You can probably guess how we
are going to go about this one: add the `returnCard()` method into the `Pile` interface, and then
implement this method in all four pile subclasses:
```dart
class StockPile ... implements Pile {
...
@override
void returnCard(Card card) => throw StateError('cannot remove cards from here');
}
class WastePile ... implements Pile {
...
@override
void returnCard(Card card) {
card.priority = _cards.indexOf(card);
_fanOutTopCards();
}
}
class FoundationPile ... implements Pile {
...
@override
void returnCard(Card card) {
card.position = position;
card.priority = _cards.indexOf(card);
}
}
class TableauPile ... implements Pile {
...
@override
void returnCard(Card card) {
final index = _cards.indexOf(card);
card.position =
index == 0 ? position : _cards[index - 1].position + _fanOffset;
card.priority = index;
}
}
```
Now, putting this all together, the `Card`'s `onDragEnd` method will look like this:
```dart
@override
void onDragEnd(DragEndEvent event) {
if (!_isDragging) {
return;
}
_isDragging = false;
final dropPiles = parent!
.componentsAtPoint(position + size / 2)
.whereType<Pile>()
.toList();
if (dropPiles.isNotEmpty) {
if (dropPiles.first.canAcceptCard(this)) {
pile!.removeCard(this);
dropPiles.first.acquireCard(this);
return;
}
}
pile!.returnCard(this);
}
```
Ok, that was quite a lot of work -- but if you run the game now, you'd be able to move the cards
properly from one pile to another, and they will never go where they are not supposed to go. The
only thing that remains is to be able to move multiple cards at once between tableau piles. So take
a short break, and then on to the next section!
### 4. Moving a run of cards
In this section we will be implementing the necessary changes to allow us to move small stacks of
cards between the tableau piles. Before we begin, though, we need to make a small fix first.
You have probably noticed when running the game in the previous section that the cards in the
tableau piles clamp too closely together. That is, they are at the correct distance when they face
down, but they should be at a larger distance when they face up, which is not currently the case.
This makes it really difficult to see which cards are available for dragging.
So, let's head over into the `TableauPile` class and create a new method `layOutCards()`, whose job
would be to ensure that all cards currently in the pile have the right positions:
```dart
final Vector2 _fanOffset1 = Vector2(0, KlondikeGame.cardHeight * 0.05);
final Vector2 _fanOffset2 = Vector2(0, KlondikeGame.cardHeight * 0.20);
void layOutCards() {
if (_cards.isEmpty) {
return;
}
_cards[0].position.setFrom(position);
for (var i = 1; i < _cards.length; i++) {
_cards[i].position
..setFrom(_cards[i - 1].position)
..add(_cards[i - 1].isFaceDown ? _fanOffset1 : _fanOffset2);
}
}
```
Make sure to call this method at the end of `removeCard()`, `returnCard()`, and `acquireCard()` --
replacing any current logic that handles card positioning.
Another problem that you may have noticed is that for taller card stacks it becomes hard to place a
card there. This is because our logic for determining in which pile the card is being dropped checks
whether the center of the card is inside any of the `TableauPile` components -- but those components
have only the size of a single card! To fix this inconsistency, all we need is to declare that the
height of the tableau pile is at least as tall as all the cards in it, or even higher. Add this line
at the end of the `layOutCards()` method:
```dart
height = KlondikeGame.cardHeight * 1.5 + _cards.last.y - _cards.first.y;
```
The factor `1.5` here adds a little bit extra space at the bottom of each pile. You can temporarily
turn the debug mode on to see the hitboxes.
Ok, let's get to our main topic: how to move a stack of cards at once.
First thing that we're going to add is the list of `attachedCards` for every card. This list will
be non-empty only when the card is being dragged while having other cards on top. Add the following
declaration to the `Card` class:
```dart
final List<Card> attachedCards = [];
```
Now, in order to create this list in `onDragStart`, we need to query the `TableauPile` for the list
of cards that are on top of the given card. Let's add such a method into the `TableauPile` class:
```dart
List<Card> cardsOnTop(Card card) {
assert(card.isFaceUp && _cards.contains(card));
final index = _cards.indexOf(card);
return _cards.getRange(index + 1, _cards.length).toList();
}
```
While we are in the `TableauPile` class, let's also update the `canMoveCard()` method to allow
dragging cards that are not necessarily on top:
```dart
@override
bool canMoveCard(Card card) => card.isFaceUp;
```
Heading back into the `Card` class, we can use this method in order to populate the list of
`attachedCards` when the card starts to move:
```dart
@override
void onDragStart(DragStartEvent event) {
if (pile?.canMoveCard(this) ?? false) {
_isDragging = true;
priority = 100;
if (pile is TableauPile) {
attachedCards.clear();
final extraCards = (pile! as TableauPile).cardsOnTop(this);
for (final card in extraCards) {
card.priority = attachedCards.length + 101;
attachedCards.add(card);
}
}
}
}
```
Now all we need to do is to make sure that the attached cards are also moved with the main card in
the `onDragUpdate` method:
```dart
@override
void onDragUpdate(DragUpdateEvent event) {
if (!_isDragging) {
return;
}
final cameraZoom = (findGame()! as FlameGame)
.firstChild<CameraComponent>()!
.viewfinder
.zoom;
final delta = event.delta / cameraZoom;
position.add(delta);
attachedCards.forEach((card) => card.position.add(delta));
}
```
This does the trick, almost. All that remains is to fix any loose ends. For example, we don't want
to let the user drop a stack of cards onto a foundation pile, so let's head over into the
`FoundationPile` class and modify the `canAcceptCard()` method accordingly:
```dart
@override
bool canAcceptCard(Card card) {
final topCardRank = _cards.isEmpty ? 0 : _cards.last.rank.value;
return card.suit == suit &&
card.rank.value == topCardRank + 1 &&
card.attachedCards.isEmpty;
}
```
Secondly, we need to properly take care of the stack of card as it is being dropped into a tableau
pile. So, go back into the `Card` class and update its `onDragEnd()` method to also move the
attached cards into the pile, and the same when it comes to returning the cards into the old pile:
```dart
@override
void onDragEnd(DragEndEvent event) {
if (!_isDragging) {
return;
}
_isDragging = false;
final dropPiles = parent!
.componentsAtPoint(position + size / 2)
.whereType<Pile>()
.toList();
if (dropPiles.isNotEmpty) {
if (dropPiles.first.canAcceptCard(this)) {
pile!.removeCard(this);
dropPiles.first.acquireCard(this);
if (attachedCards.isNotEmpty) {
attachedCards.forEach((card) => dropPiles.first.acquireCard(card));
attachedCards.clear();
}
return;
}
}
pile!.returnCard(this);
if (attachedCards.isNotEmpty) {
attachedCards.forEach((card) => pile!.returnCard(card));
attachedCards.clear();
}
}
```
Well, this is it! The game is now fully playable. Press the button below to see what the resulting
code looks like, or to play it live. In the next section we will discuss how to make it more
animated with the help of effects.
```{flutter-app}
:sources: ../tutorials/klondike/app
:page: step4
:show: popup code
```

View File

@ -37,6 +37,14 @@ abstract class PositionEvent extends Event {
/// rendering order.
final List<Vector2> renderingTrace = [];
Vector2? get parentPosition {
if (renderingTrace.length >= 2) {
return renderingTrace[renderingTrace.length - 2];
} else {
return null;
}
}
/// Sends the event to components of type <T> that are currently rendered at
/// the [canvasPosition].
@internal