diff --git a/doc/tutorials/klondike/app/analysis_options.yaml b/doc/tutorials/klondike/app/analysis_options.yaml index 85732fa02..6a72fc362 100644 --- a/doc/tutorials/klondike/app/analysis_options.yaml +++ b/doc/tutorials/klondike/app/analysis_options.yaml @@ -1 +1,6 @@ include: package:flame_lint/analysis_options.yaml + +linter: + rules: + always_use_package_imports: false + prefer_relative_imports: true diff --git a/doc/tutorials/klondike/app/lib/main.dart b/doc/tutorials/klondike/app/lib/main.dart index 6fb1a52ef..f85cf4be4 100644 --- a/doc/tutorials/klondike/app/lib/main.dart +++ b/doc/tutorials/klondike/app/lib/main.dart @@ -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( diff --git a/doc/tutorials/klondike/app/lib/step2/klondike_game.dart b/doc/tutorials/klondike/app/lib/step2/klondike_game.dart index 077e18855..91b9d1584 100644 --- a/doc/tutorials/klondike/app/lib/step2/klondike_game.dart +++ b/doc/tutorials/klondike/app/lib/step2/klondike_game.dart @@ -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; diff --git a/doc/tutorials/klondike/app/lib/step2/main.dart b/doc/tutorials/klondike/app/lib/step2/main.dart index 5c6527125..2648523b9 100644 --- a/doc/tutorials/klondike/app/lib/step2/main.dart +++ b/doc/tutorials/klondike/app/lib/step2/main.dart @@ -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(); diff --git a/doc/tutorials/klondike/app/lib/step3/components/card.dart b/doc/tutorials/klondike/app/lib/step3/components/card.dart index b15c51354..bddddf385 100644 --- a/doc/tutorials/klondike/app/lib/step3/components/card.dart +++ b/doc/tutorials/klondike/app/lib/step3/components/card.dart @@ -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) diff --git a/doc/tutorials/klondike/app/lib/step3/klondike_game.dart b/doc/tutorials/klondike/app/lib/step3/klondike_game.dart index b30ff92cd..1c45d9366 100644 --- a/doc/tutorials/klondike/app/lib/step3/klondike_game.dart +++ b/doc/tutorials/klondike/app/lib/step3/klondike_game.dart @@ -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; diff --git a/doc/tutorials/klondike/app/lib/step3/main.dart b/doc/tutorials/klondike/app/lib/step3/main.dart index 033c12b39..2648523b9 100644 --- a/doc/tutorials/klondike/app/lib/step3/main.dart +++ b/doc/tutorials/klondike/app/lib/step3/main.dart @@ -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(); diff --git a/doc/tutorials/klondike/app/lib/step3/rank.dart b/doc/tutorials/klondike/app/lib/step3/rank.dart index 663ad67fd..2af952405 100644 --- a/doc/tutorials/klondike/app/lib/step3/rank.dart +++ b/doc/tutorials/klondike/app/lib/step3/rank.dart @@ -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 { diff --git a/doc/tutorials/klondike/app/lib/step3/suit.dart b/doc/tutorials/klondike/app/lib/step3/suit.dart index a6229addf..e7aa0898e 100644 --- a/doc/tutorials/klondike/app/lib/step3/suit.dart +++ b/doc/tutorials/klondike/app/lib/step3/suit.dart @@ -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 { diff --git a/doc/tutorials/klondike/app/lib/step4/components/card.dart b/doc/tutorials/klondike/app/lib/step4/components/card.dart new file mode 100644 index 000000000..a77ebf2d6 --- /dev/null +++ b/doc/tutorials/klondike/app/lib/step4/components/card.dart @@ -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 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()! + .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() + .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 +} diff --git a/doc/tutorials/klondike/app/lib/step4/components/foundation_pile.dart b/doc/tutorials/klondike/app/lib/step4/components/foundation_pile.dart new file mode 100644 index 000000000..959d10d2a --- /dev/null +++ b/doc/tutorials/klondike/app/lib/step4/components/foundation_pile.dart @@ -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 _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 +} diff --git a/doc/tutorials/klondike/app/lib/step4/components/stock_pile.dart b/doc/tutorials/klondike/app/lib/step4/components/stock_pile.dart new file mode 100644 index 000000000..a89436254 --- /dev/null +++ b/doc/tutorials/klondike/app/lib/step4/components/stock_pile.dart @@ -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 _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()!; + 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 +} diff --git a/doc/tutorials/klondike/app/lib/step4/components/tableau_pile.dart b/doc/tutorials/klondike/app/lib/step4/components/tableau_pile.dart new file mode 100644 index 000000000..38e79a6e5 --- /dev/null +++ b/doc/tutorials/klondike/app/lib/step4/components/tableau_pile.dart @@ -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 _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 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 +} diff --git a/doc/tutorials/klondike/app/lib/step4/components/waste_pile.dart b/doc/tutorials/klondike/app/lib/step4/components/waste_pile.dart new file mode 100644 index 000000000..bb8bc0e56 --- /dev/null +++ b/doc/tutorials/klondike/app/lib/step4/components/waste_pile.dart @@ -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 _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 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); + } + } +} diff --git a/doc/tutorials/klondike/app/lib/step4/klondike_game.dart b/doc/tutorials/klondike/app/lib/step4/klondike_game.dart new file mode 100644 index 000000000..35fd72db4 --- /dev/null +++ b/doc/tutorials/klondike/app/lib/step4/klondike_game.dart @@ -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 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), + ); +} diff --git a/doc/tutorials/klondike/app/lib/step4/main.dart b/doc/tutorials/klondike/app/lib/step4/main.dart new file mode 100644 index 000000000..2648523b9 --- /dev/null +++ b/doc/tutorials/klondike/app/lib/step4/main.dart @@ -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)); +} diff --git a/doc/tutorials/klondike/app/lib/step4/pile.dart b/doc/tutorials/klondike/app/lib/step4/pile.dart new file mode 100644 index 000000000..de001f92d --- /dev/null +++ b/doc/tutorials/klondike/app/lib/step4/pile.dart @@ -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); +} diff --git a/doc/tutorials/klondike/app/lib/step4/rank.dart b/doc/tutorials/klondike/app/lib/step4/rank.dart new file mode 100644 index 000000000..2af952405 --- /dev/null +++ b/doc/tutorials/klondike/app/lib/step4/rank.dart @@ -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 _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), + ]; +} diff --git a/doc/tutorials/klondike/app/lib/step4/suit.dart b/doc/tutorials/klondike/app/lib/step4/suit.dart new file mode 100644 index 000000000..e7aa0898e --- /dev/null +++ b/doc/tutorials/klondike/app/lib/step4/suit.dart @@ -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 _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; +} diff --git a/doc/tutorials/klondike/klondike.md b/doc/tutorials/klondike/klondike.md index d784b3730..d720606c2 100644 --- a/doc/tutorials/klondike/klondike.md +++ b/doc/tutorials/klondike/klondike.md @@ -16,5 +16,6 @@ with the [Dart] programming language. 1. Preparation 2. Scaffolding 3. Cards +4. Gameplay [To be continued]... ``` diff --git a/doc/tutorials/klondike/step4.md b/doc/tutorials/klondike/step4.md new file mode 100644 index 000000000..83636a690 --- /dev/null +++ b/doc/tutorials/klondike/step4.md @@ -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 _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 _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()!; + 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()!; + 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 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 _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 _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 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()! + .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()! + .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() + .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() + .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 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 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()! + .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() + .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 +``` diff --git a/packages/flame/lib/src/events/messages/position_event.dart b/packages/flame/lib/src/events/messages/position_event.dart index 5e981a12c..1d819f824 100644 --- a/packages/flame/lib/src/events/messages/position_event.dart +++ b/packages/flame/lib/src/events/messages/position_event.dart @@ -37,6 +37,14 @@ abstract class PositionEvent extends Event { /// rendering order. final List renderingTrace = []; + Vector2? get parentPosition { + if (renderingTrace.length >= 2) { + return renderingTrace[renderingTrace.length - 2]; + } else { + return null; + } + } + /// Sends the event to components of type that are currently rendered at /// the [canvasPosition]. @internal