diff --git a/doc/_sphinx/extensions/flutter_app.css b/doc/_sphinx/extensions/flutter_app.css index 4b61afbfa..e70690a8e 100644 --- a/doc/_sphinx/extensions/flutter_app.css +++ b/doc/_sphinx/extensions/flutter_app.css @@ -10,7 +10,7 @@ button.flutter-app-button { font-size: 1.1em; font-weight: bold; line-height: 1em; - margin-right: 1em; + margin: 0 1em 1em 0; min-height: 26pt; min-width: 120pt; } diff --git a/doc/_sphinx/extensions/flutter_app.py b/doc/_sphinx/extensions/flutter_app.py index 0ecb09b34..7fdf084d2 100644 --- a/doc/_sphinx/extensions/flutter_app.py +++ b/doc/_sphinx/extensions/flutter_app.py @@ -138,7 +138,7 @@ class FlutterAppDirective(SphinxDirective): ) if not need_compiling: return - self.logger.info('Compiling Flutter app ' + self.app_name) + self.logger.info('Compiling Flutter app [%s]' % self.app_name) self._compile_source() self._copy_compiled() self._create_index_html() @@ -150,7 +150,7 @@ class FlutterAppDirective(SphinxDirective): def _compile_source(self): try: subprocess.run( - ['flutter', 'build', 'web', '--web-renderer', 'html'], + ['flutter', 'build', 'web'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, cwd=self.source_dir, diff --git a/doc/tutorials/klondike/app/assets/images/klondike-sprites.png b/doc/tutorials/klondike/app/assets/images/klondike-sprites.png index 0c7e7b73f..d5462f3e5 100644 Binary files a/doc/tutorials/klondike/app/assets/images/klondike-sprites.png and b/doc/tutorials/klondike/app/assets/images/klondike-sprites.png differ diff --git a/doc/tutorials/klondike/app/lib/main.dart b/doc/tutorials/klondike/app/lib/main.dart index e04c5bfa8..dc497ace3 100644 --- a/doc/tutorials/klondike/app/lib/main.dart +++ b/doc/tutorials/klondike/app/lib/main.dart @@ -1,6 +1,7 @@ import 'dart:html'; // ignore: avoid_web_libraries_in_flutter import 'package:flutter/widgets.dart'; import 'step2/main.dart' as step2; +import 'step3/main.dart' as step3; void main() { var page = window.location.search ?? ''; @@ -12,6 +13,10 @@ void main() { step2.main(); break; + case 'step3': + step3.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 498d084dc..91b9d1584 100644 --- a/doc/tutorials/klondike/app/lib/step2/klondike_game.dart +++ b/doc/tutorials/klondike/app/lib/step2/klondike_game.dart @@ -1,5 +1,6 @@ import 'package:flame/components.dart'; import 'package:flame/experimental.dart'; +import 'package:flame/flame.dart'; import 'package:flame/game.dart'; import 'components/foundation.dart'; @@ -8,31 +9,33 @@ import 'components/stock.dart'; import 'components/waste.dart'; class KlondikeGame extends FlameGame { - final double cardGap = 175.0; - final double cardWidth = 1000.0; - final double cardHeight = 1400.0; + 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); @override Future onLoad() async { - await images.load('klondike-sprites.png'); + await Flame.images.load('klondike-sprites.png'); final stock = Stock() - ..size = Vector2(cardWidth, cardHeight) + ..size = cardSize ..position = Vector2(cardGap, cardGap); final waste = Waste() - ..size = Vector2(cardWidth * 1.5, cardHeight) + ..size = cardSize ..position = Vector2(cardWidth + 2 * cardGap, cardGap); final foundations = List.generate( 4, (i) => Foundation() - ..size = Vector2(cardWidth, cardHeight) + ..size = cardSize ..position = Vector2((i + 3) * (cardWidth + cardGap) + cardGap, cardGap), ); final piles = List.generate( 7, (i) => Pile() - ..size = Vector2(cardWidth, cardHeight) + ..size = cardSize ..position = Vector2( cardGap + i * (cardWidth + cardGap), cardHeight + 2 * cardGap, @@ -44,13 +47,21 @@ class KlondikeGame extends FlameGame { ..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(world); add(camera); } } + +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/step3/components/card.dart b/doc/tutorials/klondike/app/lib/step3/components/card.dart new file mode 100644 index 000000000..bddddf385 --- /dev/null +++ b/doc/tutorials/klondike/app/lib/step3/components/card.dart @@ -0,0 +1,209 @@ +import 'dart:math'; +import 'dart:ui'; + +import 'package:flame/components.dart'; +import '../klondike_game.dart'; +import '../rank.dart'; +import '../suit.dart'; + +class Card extends PositionComponent { + Card(int intRank, int intSuit) + : rank = Rank.fromInt(intRank), + suit = Suit.fromInt(intSuit), + _faceUp = false, + super(size: KlondikeGame.cardSize); + + final Rank rank; + final Suit suit; + bool _faceUp; + + bool get isFaceUp => _faceUp; + void flip() => _faceUp = !_faceUp; + + @override + String toString() => rank.label + suit.label; // e.g. "Q♠" or "10♦" + + @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(); + } + } +} diff --git a/doc/tutorials/klondike/app/lib/step3/components/foundation.dart b/doc/tutorials/klondike/app/lib/step3/components/foundation.dart new file mode 100644 index 000000000..9da612ce1 --- /dev/null +++ b/doc/tutorials/klondike/app/lib/step3/components/foundation.dart @@ -0,0 +1,6 @@ +import 'package:flame/components.dart'; + +class Foundation extends PositionComponent { + @override + bool get debugMode => true; +} diff --git a/doc/tutorials/klondike/app/lib/step3/components/pile.dart b/doc/tutorials/klondike/app/lib/step3/components/pile.dart new file mode 100644 index 000000000..7dcff07e1 --- /dev/null +++ b/doc/tutorials/klondike/app/lib/step3/components/pile.dart @@ -0,0 +1,6 @@ +import 'package:flame/components.dart'; + +class Pile extends PositionComponent { + @override + bool get debugMode => true; +} diff --git a/doc/tutorials/klondike/app/lib/step3/components/stock.dart b/doc/tutorials/klondike/app/lib/step3/components/stock.dart new file mode 100644 index 000000000..90656bed1 --- /dev/null +++ b/doc/tutorials/klondike/app/lib/step3/components/stock.dart @@ -0,0 +1,6 @@ +import 'package:flame/components.dart'; + +class Stock extends PositionComponent { + @override + bool get debugMode => true; +} diff --git a/doc/tutorials/klondike/app/lib/step3/components/waste.dart b/doc/tutorials/klondike/app/lib/step3/components/waste.dart new file mode 100644 index 000000000..dff70d4ce --- /dev/null +++ b/doc/tutorials/klondike/app/lib/step3/components/waste.dart @@ -0,0 +1,6 @@ +import 'package:flame/components.dart'; + +class Waste extends PositionComponent { + @override + bool get debugMode => true; +} diff --git a/doc/tutorials/klondike/app/lib/step3/klondike_game.dart b/doc/tutorials/klondike/app/lib/step3/klondike_game.dart new file mode 100644 index 000000000..1c45d9366 --- /dev/null +++ b/doc/tutorials/klondike/app/lib/step3/klondike_game.dart @@ -0,0 +1,83 @@ +import 'dart:math'; + +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.dart'; +import 'components/pile.dart'; +import 'components/stock.dart'; +import 'components/waste.dart'; + +class KlondikeGame extends FlameGame { + 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); + + @override + Future onLoad() async { + await Flame.images.load('klondike-sprites.png'); + + final stock = Stock() + ..size = cardSize + ..position = Vector2(cardGap, cardGap); + final waste = Waste() + ..size = cardSize + ..position = Vector2(cardWidth + 2 * cardGap, cardGap); + final foundations = List.generate( + 4, + (i) => Foundation() + ..size = cardSize + ..position = + Vector2((i + 3) * (cardWidth + cardGap) + cardGap, cardGap), + ); + final piles = List.generate( + 7, + (i) => Pile() + ..size = cardSize + ..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 random = Random(); + for (var i = 0; i < 7; i++) { + for (var j = 0; j < 4; j++) { + final card = Card(random.nextInt(13) + 1, random.nextInt(4)) + ..position = Vector2(100 + i * 1150, 100 + j * 1500) + ..addToParent(world); + // flip the card face-up with 90% probability + if (random.nextDouble() < 0.9) { + card.flip(); + } + } + } + } +} + +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/step3/main.dart b/doc/tutorials/klondike/app/lib/step3/main.dart new file mode 100644 index 000000000..2648523b9 --- /dev/null +++ b/doc/tutorials/klondike/app/lib/step3/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/step3/rank.dart b/doc/tutorials/klondike/app/lib/step3/rank.dart new file mode 100644 index 000000000..8ff15a38b --- /dev/null +++ b/doc/tutorials/klondike/app/lib/step3/rank.dart @@ -0,0 +1,44 @@ +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); + 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/step3/suit.dart b/doc/tutorials/klondike/app/lib/step3/suit.dart new file mode 100644 index 000000000..4a4e129aa --- /dev/null +++ b/doc/tutorials/klondike/app/lib/step3/suit.dart @@ -0,0 +1,29 @@ +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); + 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 50e1701c5..d784b3730 100644 --- a/doc/tutorials/klondike/klondike.md +++ b/doc/tutorials/klondike/klondike.md @@ -15,4 +15,6 @@ with the [Dart] programming language. 1. Preparation 2. Scaffolding +3. Cards +[To be continued]... ``` diff --git a/doc/tutorials/klondike/step1.md b/doc/tutorials/klondike/step1.md index a5a3a8c58..f7d33ac68 100644 --- a/doc/tutorials/klondike/step1.md +++ b/doc/tutorials/klondike/step1.md @@ -30,7 +30,7 @@ shown below: Here you can see both the general layout of the game, as well as names of various objects. These names are the [standard terminology] for solitaire games. Which is really lucky, because normally figuring out good names for various -classes is a quite challenging task. +classes is quite a challenging task. Looking at this sketch, we can already imagine the high-level structure of the game. Obviously, there will be a `Card` class, but also the `Stock` class, the @@ -47,34 +47,34 @@ In such a simple game as Klondike we won't need lots of fancy graphics, but still some sprites will be needed in order to draw the cards. In order to prepare the graphic assets, I first took a physical playing card and -measured it to be 63mm × 88mm, which is the ratio of approximately `1.4`. Thus, -I decided that my in-game cards should be rendered at 1000×1400 pixels, and I -should draw all my images with this scale in mind. +measured it to be 63mm × 88mm, which is the ratio of approximately `10:14`. +Thus, I decided that my in-game cards should be rendered at 1000×1400 pixels, +and I should draw all my images with this scale in mind. Note that the exact pixel dimensions are somewhat irrelevant here, since the images will in the end be scaled up or down, according to the device's actual resolution. Here I'm using probably a bigger resolution than necessary for phones, but it would also work nicely for larger devices like an iPad. -And now, without further ado, the graphic assets for the Klondike game (don't -judge too harshly, I'm not an artist): +And now, without further ado, here's my graphic asset for the Klondike game +(I'm not an artist, so don't judge too harshly): ![](app/assets/images/klondike-sprites.png) Right-click the image, choose "Save as...", and store it in the `assets/images` -folder of the project. At this point our project structure looks like this +folder of the project. At this point our project's structure looks like this (there are other files too, of course, but these are the important ones): ```text klondike/ -├─assets/ -│ └─images/ -│ └─klondike-sprites.png -├─lib/ -│ └─main.dart -└─pubspec.yaml + ├─assets/ + │ └─images/ + │ └─klondike-sprites.png + ├─lib/ + │ └─main.dart + └─pubspec.yaml ``` -By the way, this kind of file is called the _spritesheet_: it's just a +By the way, this kind of file is called the **spritesheet**: it's just a collection of multiple independent images in a single file. We are using a spritesheet here for the simple reason that loading a single large image is faster than many small images. In addition, rendering sprites that were @@ -85,8 +85,11 @@ Here are the contents of my spritesheet: - Numerals 2, 3, 4, ..., K, A. In theory, we could have rendered these in the game as text strings, but then we would need to also include a font as an asset -- seems simpler to just have them as images instead. - - Suit marks: ♠, ♥, ♦, ♣. Again, we could have used Unicode characters for + - Suit marks: ♥, ♦, ♣, ♠. Again, we could have used Unicode characters for these, but images are much easier to position precisely. + * In case you're wondering why these are yellow/blue instead of red/black + -- turns out, black symbols don't look very nice on a dark background, + so I had to adjust the color scheme. - Flame logo, for use on the backs of the cards. - Pictures of a Jack, a Queen, and a King. Normally there would be four times more of these, with a different character for each suite, but I got too diff --git a/doc/tutorials/klondike/step2.md b/doc/tutorials/klondike/step2.md index 95c56bff6..974c1b807 100644 --- a/doc/tutorials/klondike/step2.md +++ b/doc/tutorials/klondike/step2.md @@ -1,13 +1,13 @@ # 2. Scaffolding -In this section we will use broad strokes in order to outline the main elements -of the game. This includes the main game class, and the general layout. +In this section we will use broad strokes to outline the main elements of the +game. This includes the main game class, and the general layout. ## KlondikeGame -In Flame universe, the `FlameGame` class is the cornerstone of any game. This -class runs the game loop, dispatches events, owns all the components that +In Flame universe, the **FlameGame** class is the cornerstone of most games. +This class runs the game loop, dispatches events, owns all the components that comprise the game (the component tree), and usually also serves as the central repository for the game's state. @@ -20,7 +20,7 @@ import 'package:flame/game.dart'; class KlondikeGame extends FlameGame { @override Future onLoad() async { - await Images.load('klondike-sprites.png'); + await Flame.images.load('klondike-sprites.png'); } } ``` @@ -33,6 +33,27 @@ into the game; but we will be adding more soon. Any image or other resource that you want to use in the game needs to be loaded first, which is a relatively slow I/O operation, hence the need for `await` keyword. +I am loading the image into the global `Flame.images` cache here. An alternative +approach is to load it into the `Game.images` cache instead, but then it would +have been more difficult to access that image from other classes. + +Also note that I am `await`ing the image to finish loading before initializing +anything else in the game. This is for convenience: it means that by the time +all other components are initialized, they can assume the spritesheet is already +loaded. We can even add a helper function to extract a sprite from the common +spritesheet: +```dart +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), + ); +} +``` +This helper function won't be needed in this chapter, but will be used +extensively in the next. + Let's incorporate this class into the project so that it isn't orphaned. Open the `main.dart` find the line which says `final game = FlameGame();` and replace the `FlameGame` with `KlondikeGame`. You will need to import the class too. @@ -70,6 +91,7 @@ file write import 'package:flame/components.dart'; class Stock extends PositionComponent { + @override bool get debugMode => true; } ``` @@ -79,10 +101,28 @@ that has a position and size). We also turn on the debug mode for this class so that we can see it on the screen even though we don't have any rendering logic yet. -Likewise, create three more files `components/foundation.dart`, -`components/pile.dart`, and `components/waste.dart`. For now all four classes -will have exactly the same logic inside, we'll be adding more functionality into -those classes in subsequent chapters. +Likewise, create three more classes `Foundation`, `Pile` and `Waste`, each in +its corresponding file. For now all four classes will have exactly the same +logic inside, we'll be adding more functionality into those classes in +subsequent chapters. + +At this moment the directory structure of your game should look like this: +```text +klondike/ + ├─assets/ + │ └─images/ + │ └─klondike-sprites.png + ├─lib/ + │ ├─components/ + │ │ ├─foundation.dart + │ │ ├─pile.dart + │ │ ├─stock.dart + │ │ └─waste.dart + │ ├─klondike_game.dart + │ └─main.dart + ├─analysis_options.yaml + └─pubspec.yaml +``` ## Game structure @@ -94,7 +134,7 @@ There exist multiple approaches here, which differ in their complexity, extendability, and overall philosophy. The approach that we will be taking in this tutorial is based on using the [World] component, together with a [Camera]. -The idea behind this approach is the following: imagine that your game world +The idea behind this approach is the following: imagine that your game **world** exists independently from the device, that it exists already in our heads, and on the sketch, even though we haven't done any coding yet. This world will have a certain size, and each element in the world will have certain coordinates. It @@ -106,9 +146,9 @@ pixel resolution of the screen. All elements that are part of the world will be added to the `World` component, and the `World` component will be then added to the game. -The second part of the overall structure is a camera (`CameraComponent`). The -purpose of the camera is to be able to look at the world, to make sure that it -renders at the right size on the screen of the user's device. +The second part of the overall structure is a **camera** (`CameraComponent`). +The purpose of the camera is to be able to look at the world, to make sure that +it renders at the right size on the screen of the user's device. Thus, the overall structure of the component tree will look approximately like this: @@ -122,6 +162,12 @@ KlondikeGame └─ CameraComponent ``` +```{note} +The **Camera** system described in this tutorial is different from the +"official" camera available as a property of the `FlameGame` class. The latter +may become deprecated in the future. +``` + For this game I've been drawing my image assets having in mind the dimension of a single card at 1000×1400 pixels. So, this will serve as the reference size for determining the overall layout. Another important measurement that affects the @@ -132,134 +178,128 @@ the vertical and horizontal inter-card distance will be the same, and the minimum padding between the cards and the edges of the screen will also be equal to `cardGap`. -Alright, let's put all this together and implement our `KlondikeGame` class: +Alright, let's put all this together and implement our `KlondikeGame` class. +First, we declare several global constants which describe the dimensions of a +card and the distance between cards. We declare them as constants because we are +not planning to change these values during the game: ```dart -class KlondikeGame extends FlameGame { - final double cardGap = 175.0; - final double cardWidth = 1000.0; - final double cardHeight = 1400.0; - - @override - Future onLoad() async { - await images.load('klondike-sprites.png'); + static const double cardWidth = 1000.0; + static const double cardHeight = 1400.0; + static const double cardGap = 175.0; + static const double cardRadius = 100.0; + static final Vector2 cardSize = Vector2(cardWidth, cardHeight); +``` +Next, we will create a `Stock` component, the `Waste`, four `Foundation`s and +seven `Pile`s, setting their sizes and positions in the world. The positions +are calculated using simple arithmetics. This should all happen inside the +`onLoad` method, after loading the spritesheet: +```dart final stock = Stock() - ..size = Vector2(cardWidth, cardHeight) + ..size = cardSize ..position = Vector2(cardGap, cardGap); final waste = Waste() - ..size = Vector2(cardWidth * 1.5, cardHeight) + ..size = cardSize ..position = Vector2(cardWidth + 2 * cardGap, cardGap); final foundations = List.generate( 4, (i) => Foundation() - ..size = Vector2(cardWidth, cardHeight) + ..size = cardSize ..position = Vector2((i + 3) * (cardWidth + cardGap) + cardGap, cardGap), ); final piles = List.generate( 7, (i) => Pile() - ..size = Vector2(cardWidth, cardHeight) + ..size = cardSize ..position = Vector2( cardGap + i * (cardWidth + cardGap), cardHeight + 2 * cardGap, ), ); +``` +Then we create the main `World` component, add to it all the components that +we just created, and finally add the `world` to the game. +```dart final world = World() ..add(stock) ..add(waste) ..addAll(foundations) ..addAll(piles); add(world); +``` +```{note} +You may be wondering when you need to `await` the result of `add()`, and when +you don't. The short answer is: usually you don't need to wait, but if you want +to, then it won't hurt either. + +If you check the documentation for `.add()` method, you'll see that the returned +future only waits until the component is finished loading, not until it is +actually mounted to the game. As such, you only have to wait for the future from +`.add()` if your logic requires that the component is fully loaded before it can +proceed. This is not very common. + +If you don't `await` the future from `.add()`, then the component will be added +to the game anyways, and in the same amount of time. +``` + +Lastly, we create a camera object to look at the `world`. Internally, the camera +consists of two parts: a **viewport** and a **viewfinder**. The default viewport +is `MaxViewport`, which takes up the entire available screen size -- this is +exactly what we need for our game, so no need to change anything. The +viewfinder, on the other hand, needs to be set up to properly take the +dimensions of the underlying world into account. + +We want the entire card layout to be visible on the screen without the need to +scroll. In order to accomplish this, we specify that we want the entire world +size (which is `7*cardWidth + 8*cardGap` by `4*cardHeight + 3*cardGap`) to be +able to fit into the screen. The `.visibleGameSize` setting ensures that no +matter the size of the device, the zoom level will be adjusted such that the +specified chunk of the game world will be visible. + +The game size calculation is obtained like this: there are 7 cards in the +tableau and 6 gaps between them, add 2 more "gaps" to account for padding, and +you get the width of `7*cardWidth + 8*cardGap`. Vertically, there are two rows +of cards, but in the bottom row we need some extra space to be able to display +a tall pile -- by my rough estimate, thrice the height of a card is sufficient +for this -- which gives the total height of the game world as +`4*cardHeight + 3*cardGap`. + +Next, we specify which part of the world will be in the "center" of the +viewport. In this case I specify that the "center" of the viewport should +be at the top center of the screen, and the corresponding point within +the game world is at coordinates `[(7*cardWidth + 8*cardGap)/2, 0]`. + +The reason for such choice for the viewfinder's position and anchor is +because of how we want it to respond if the game size becomes too wide or +too tall: in case of too wide we want it to be centered on the screen, +but if the screen is too tall, we want the content to be aligned at the +top. +```dart 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); - } -} ``` -Let's review what's happening here: - * First, we declare constants `cardWidth`, `cardHeight`, and `cardGap` which - describe the size of a card and the distance between cards. - - * Then, there is the `onLoad` method that we have had before. It starts with - loading the main image asset, as before (though we are not using it yet). - - * After that, we create components `stock`, `waste`, etc., setting their size - and position in the world. The positions are calculated using simple - arithmetics. - - * Then we create the main `World` component, add to it all the components - that we just created, and finally add the `world` to the game. - - * Lastly, we create a camera object to look at the `world`. Internally, the - camera consists of two parts: a viewport and a viewfinder. The default - viewport is `MaxViewport`, which takes up the entire available screen size -- - this is exactly what we need for our game, so no need to change anything. The - viewfinder, on the other hand, needs to be set up to properly take into - account the dimensions of the underlying world. - - We want the entire card layout to be visible on the screen without the - need to scroll. In order to accomplish this, we specify that we want the - entire world size (which is `7*cardWidth + 8*cardGap` by - `4*cardHeight + 3*cardGap`) to be able to fit into the screen. The - `.visibleGameSize` setting ensures that no matter the size of the device, - the zoom level will be adjusted such that the specified chunk of the game - world will be visible. - + The game size calculation is obtained like this: there are 7 cards in - the tableau and 6 gaps between them, add 2 more "gaps" to account for - padding, and you get the width of `7*cardWidth + 8*cardGap`. - Vertically, there are two rows of cards, but in the bottom row we - need some extra space to be able to display a tall pile -- by my - rough estimate, thrice the height of a card is sufficient for this -- - which gives the total height of the game world as - `4*cardHeight + 3*cardGap`. - - - Next, we specify which part of the world will be in the "center" of the - viewport. In this case I specify that the "center" of the viewport should - be at the top center of the screen, and the corresponding point within - the game world is at coordinates `[(7*cardWidth + 8*cardGap)/2, 0]`. - - The reason for such choice for the viewfinder's position and anchor is - because of how we want it to respond if the game size becomes too wide or - too tall: in case of too wide we want it to be centered on the screen, - but if the screen is too tall, we want the content to be aligned at the - top. - - * As a side note, you may be wondering when you need to `await` the result - of `add()`, and when you don't. - - The short answer is: usually you don't need to wait, but if you want to, then - it won't hurt either. - - If you check the documentation for `.add()` method, you'll see that the - returned future only waits until the component is finished loading, not until - it is actually mounted to the game. As such, you only have to wait for the - future from `.add()` if your logic requires that the component is fully - loaded before it can proceed. This is not very common. - - If you don't `await` the future from `.add()`, then the component will be - added to the game anyways, and in the same amount of time. - If you run the game now, you should see the placeholders for where the various components will be. If you are running the game in the browser, try resizing the window and see how the game responds to this. -And this is it with this step -- we've created the basic game structure upon -which everything else will be built. In the next step, we'll learn how to render -the card objects, which are the most important visual objects in this game. - - ```{flutter-app} :sources: ../tutorials/klondike/app :page: step2 :show: popup code ``` +And this is it with this step -- we've created the basic game structure upon +which everything else will be built. In the next step, we'll learn how to render +the card objects, which are the most important visual objects in this game. + [World]: ../../flame/camera_component.md#world [Camera]: ../../flame/camera_component.md#cameracomponent diff --git a/doc/tutorials/klondike/step3.md b/doc/tutorials/klondike/step3.md new file mode 100644 index 000000000..2479225af --- /dev/null +++ b/doc/tutorials/klondike/step3.md @@ -0,0 +1,479 @@ +# Cards + +In this chapter we will begin implementing the most visible component in the +game -- the **Card** component, which corresponds to a single real-life card. +There will be 52 `Card` objects in the game. + +Each card has a **rank** (from 1 to 13, where 1 is an Ace, and 13 is a King) +and a **suit** (from 0 to 3: hearts ♥, diamonds ♦, clubs ♣, and spades ♠). +Also, each card will have a boolean flag **faceUp**, which controls whether +the card is currently facing up or down. This property is important both for +rendering, and for certain aspects of the gameplay logic. + +The rank and the suit are simple properties of a card, they aren't components, +so we need to make a decision on how to represent them. There are several +possibilities: either as a simple `int`, or as an `enum`, or as objects. The +choice will depend on what operations we need to perform with them. For the +rank, we will need to be able to tell whether one rank is one higher/lower than +another rank. Also, we need to produce the text label and a sprite corresponding +to the given rank. For suits, we need to know whether two suits are of different +colors, and also produce a text label and a sprite. Given these requirements, +I decided to represent both `Rank` and `Suit` as classes. + + +## Suit + +Create file `suit.dart` and declare an `@immutable class Suit` there, with no +parent. The `@immutable` annotation here is just a hint for us that the objects +of this class should not be modified after creation. + +Next, we define the factory constructor for the class: `Suit.fromInt(i)`. We +use a factory constructor here in order to enforce the singleton pattern for +the class: instead of creating a new object every time, we are returning one +of the pre-built objects that we store in the `_singletons` list: +```dart + factory Suit.fromInt(int index) { + assert(index >= 0 && index <= 3); + return _singletons[index]; + } +``` + +After that, there is a private constructor `Suit._()`. This constructor +initializes the main properties of each `Suit` object: the numeric value, the +string label, and the sprite object which we will later use to draw the suit +symbol on the canvas. The sprite object is initialized using the +`klondikeSprite()` function that we created in the previous chapter: +```dart + 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; +``` + +Then comes the static list of all `Suit` objects in the game. Note that we +define it as `late`, meaning that it will be only initialized the first time +it is needed. This is important: as we seen above, the constructor tries to +retrieve an image from the global cache, so it can only be invoked after the +image is loaded into the cache. +```dart + 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), + ]; +``` +The last four numbers in the constructor are the coordinates of the sprite +image within the spritesheet `klondike-sprites.png`. If you're wondering how I +obtained these numbers, the answer is that I used a free online service +[spritecow.com] -- it's a handy tool for locating sprites within a spritesheet. + +Lastly, I have simple getters to determine the "color" of a suit. This will be +needed later when we need to enforce the rule that cards can only be placed +into columns by alternating colors. +```dart + /// Hearts and Diamonds are red, while Clubs and Spades are black. + bool get isRed => value <= 1; + bool get isBlack => value >= 2; +``` + + +## Rank + +The `Rank` class is very similar to `Suit`. The main difference is that `Rank` +contains two sprites instead of one, separately for ranks of "red" and "black" +colors. The full code for the `Rank` class is as follows: + +```dart +import 'package:flame/components.dart'; +import 'package:flame/flame.dart'; +import 'package:flutter/foundation.dart'; + +@immutable +class Rank { + factory Rank.of(int value) { + assert(value >= 1 && value <= 13); + 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), + ]; +} +``` + + +## Card component + +Now that we have the `Rank` and the `Suit` classes, we can finally start +implementing the **Card** component. Create file `components/card.dart` and +declare the `Card` class extending from the `PositionComponent`: +```dart +class Card extends PositionComponent {} +``` + +The constructor of the class will take integer rank and suit, and make the +card initially facing down. Also, we initialize the size of the component to +be equal to the `cardSize` constant defined in the `KlondikeGame` class: +```dart + Card(int intRank, int intSuit) + : rank = Rank.fromInt(intRank), + suit = Suit.fromInt(intSuit), + _faceUp = false, + super(size: KlondikeGame.cardSize); + + final Rank rank; + final Suit suit; + bool _faceUp; +``` + +The `_faceUp` property is private (indicated by the underscore) and non-final, +meaning that it can change during the lifetime of a card. We should create some +public accessors and mutators for this variable: +```dart + bool get isFaceUp => _faceUp; + void flip() => _faceUp = !_faceUp; +``` + +Lastly, let's add a simple `toString()` implementation, which may turn out to +be useful when we need to debug the game: +```dart + @override + String toString() => rank.label + suit.label; // e.g. "Q♠" or "10♦" +``` + +Before we proceed with implementing the rendering, we need to add some cards +into the game. Head over to the `KlondikeGame` class and add the following at +the bottom of the `onLoad` method: +```dart + final random = Random(); + for (var i = 0; i < 7; i++) { + for (var j = 0; j < 4; j++) { + final card = Card(random.nextInt(13) + 1, random.nextInt(4)) + ..position = Vector2(100 + i * 1150, 100 + j * 1500) + ..addToParent(world); + if (random.nextDouble() < 0.9) { // flip face up with 90% probability + card.flip(); + } + } + } +``` +This snippet is a temporary code -- we will remove it in the next chapter -- +but for now it lays down 28 random cards on the table, most of them facing up. + + +### Rendering + +In order to be able to see a card, we need to implement its `render()` method. +Since the card has two distinct states -- face up or down -- we will +implement rendering for these two states separately. Add the following methods +into the `Card` class: +```dart + @override + void render(Canvas canvas) { + if (_faceUp) { + _renderFront(canvas); + } else { + _renderBack(canvas); + } + } + + void _renderFront(Canvas canvas) {} + void _renderBack(Canvas canvas) {} +``` + + +### renderBack() + +Since rendering the back of a card is simpler, we will do it first. + +The `render()` method of a `PositionComponent` operates in a local coordinate +system, which means we don't need to worry about where the card is located on +the screen. This local coordinate system has the origin at the top-left corner +of the component, and extends to the right by `width` and down by `height` +pixels. + +There is a lot of artistic freedom in how to draw the back of a card, but my +implementation contains a solid background, a border, a flame logo in the +middle, and another decorative border: +```dart + 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); + } +``` +The most interesting part here is the rendering of a sprite: we want to +render it in the middle (`size/2`), and we use `Anchor.center` to tell the +engine that we want the _center_ of the sprite to be at that point. + +Various properties used in the `_renderBack()` method are defined as follows: +```dart + 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); +``` +I declared these properties as static because they will all be the same across +all 52 card objects, so we might as well save some resources by having them +initialized only once. + + +### renderFront() + +When rendering the face of a card, we will follow the standard card design: the +rank and the suit in two opposite corners, plus the number of pips equal to the +rank value. The court cards (jack, queen, king) will have special images in the +center. + +As before, we begin by declaring some constants that will be used for rendering. +The background of a card will be black, whereas the border will be different +depending on whether the card is of a "red" suit or "black": +```dart + 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; +``` + +Next, we also need the images for the court cards: +```dart + 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); +``` + +Note that I'm calling these sprites `redJack`, `redQueen`, and `redKing`. This +is because, after some trial, I found that the images that I have don't look +very well on black-suit cards. So what I decided to do is to take these images +and _tint_ them with a blueish hue. Tinting of a sprite can be achieved by +using a paint with `colorFilter` set to the specified color and the `srcATop` +blending mode: +```dart + static final blueFilter = Paint() + ..colorFilter = const ColorFilter.mode( + Color(0x880d8bff), + BlendMode.srcATop, + ); + 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; +``` + +Now we can start coding the render method itself. First, draw the background +and the card border: +```dart + void _renderFront(Canvas canvas) { + canvas.drawRRect(cardRRect, frontBackgroundPaint); + canvas.drawRRect( + cardRRect, + suit.isRed ? redBorderPaint : blackBorderPaint, + ); + } +``` + +In order to draw the rest of the card, I need one more helper method. This +method will draw the provided sprite on the canvas at the specified place (the +location is relative to the dimensions of the card). The sprite can be +optionally scaled. In addition, if flag `rotate=true` is passed, the sprite +will be drawn as if it was rotated 180º around the center of the card: +```dart + 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(); + } + } +``` + +Let's draw the rank and the suit symbols in the corners of the card. Add the +following to the `_renderFront()` method: +```dart + final rankSprite = suit.isBlack ? rank.blackSprite : rank.redSprite; + final suitSprite = suit.sprite; + _drawSprite(canvas, rankSprite, 0.1, 0.08); + _drawSprite(canvas, rankSprite, 0.1, 0.08, rotate: true); + _drawSprite(canvas, suitSprite, 0.1, 0.18, scale: 0.5); + _drawSprite(canvas, suitSprite, 0.1, 0.18, scale: 0.5, rotate: true); +``` + +The middle of the card is rendered in the same manner: we will create a big +switch statement on the card's rank, and draw pips accordingly. The code +below may seem long, but it is actually quite repetitive and consists only +of drawing various sprites in different places on the card's face: +```dart + 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; + } +``` + +And this is it with the rendering of the `Card` component. If you run the code +now, you would see four rows of cards neatly spread on the table. Refreshing +the page will lay down a new set of cards. Remember that we have laid these +cards in this way only temporarily, in order to be able to check that rendering +works properly. + +In the next chapter we will discuss how to implement interactions with the +cards, that is, how to make them draggable and tappable. + +```{flutter-app} +:sources: ../tutorials/klondike/app +:page: step3 +:show: popup code +``` + +[spritecow.com]: http://www.spritecow.com/ diff --git a/doc/tutorials/klondike/tbc.md b/doc/tutorials/klondike/tbc.md new file mode 100644 index 000000000..6761ece22 --- /dev/null +++ b/doc/tutorials/klondike/tbc.md @@ -0,0 +1,3 @@ +# To be continued + +This tutorial is not finished yet, stay tuned for the updates.