mirror of
				https://github.com/flame-engine/flame.git
				synced 2025-11-01 01:18:38 +08:00 
			
		
		
		
	docs: Chapter 3 of the Klondike game tutorial (#1515)
This commit is contained in:
		| @ -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; | ||||
| } | ||||
|  | ||||
| @ -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, | ||||
|  | ||||
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 468 KiB After Width: | Height: | Size: 538 KiB | 
| @ -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( | ||||
|  | ||||
| @ -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<void> 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), | ||||
|   ); | ||||
| } | ||||
|  | ||||
							
								
								
									
										209
									
								
								doc/tutorials/klondike/app/lib/step3/components/card.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										209
									
								
								doc/tutorials/klondike/app/lib/step3/components/card.dart
									
									
									
									
									
										Normal file
									
								
							| @ -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(); | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @ -0,0 +1,6 @@ | ||||
| import 'package:flame/components.dart'; | ||||
|  | ||||
| class Foundation extends PositionComponent { | ||||
|   @override | ||||
|   bool get debugMode => true; | ||||
| } | ||||
| @ -0,0 +1,6 @@ | ||||
| import 'package:flame/components.dart'; | ||||
|  | ||||
| class Pile extends PositionComponent { | ||||
|   @override | ||||
|   bool get debugMode => true; | ||||
| } | ||||
| @ -0,0 +1,6 @@ | ||||
| import 'package:flame/components.dart'; | ||||
|  | ||||
| class Stock extends PositionComponent { | ||||
|   @override | ||||
|   bool get debugMode => true; | ||||
| } | ||||
| @ -0,0 +1,6 @@ | ||||
| import 'package:flame/components.dart'; | ||||
|  | ||||
| class Waste extends PositionComponent { | ||||
|   @override | ||||
|   bool get debugMode => true; | ||||
| } | ||||
							
								
								
									
										83
									
								
								doc/tutorials/klondike/app/lib/step3/klondike_game.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										83
									
								
								doc/tutorials/klondike/app/lib/step3/klondike_game.dart
									
									
									
									
									
										Normal file
									
								
							| @ -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<void> 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), | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										9
									
								
								doc/tutorials/klondike/app/lib/step3/main.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								doc/tutorials/klondike/app/lib/step3/main.dart
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,9 @@ | ||||
| import 'package:flame/game.dart'; | ||||
| import 'package:flutter/widgets.dart'; | ||||
|  | ||||
| import 'klondike_game.dart'; | ||||
|  | ||||
| void main() { | ||||
|   final game = KlondikeGame(); | ||||
|   runApp(GameWidget(game: game)); | ||||
| } | ||||
							
								
								
									
										44
									
								
								doc/tutorials/klondike/app/lib/step3/rank.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								doc/tutorials/klondike/app/lib/step3/rank.dart
									
									
									
									
									
										Normal file
									
								
							| @ -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<Rank> _singletons = [ | ||||
|     Rank._(1, 'A', 335, 164, 789, 161, 120, 129), | ||||
|     Rank._(2, '2', 20, 19, 15, 322, 83, 125), | ||||
|     Rank._(3, '3', 122, 19, 117, 322, 80, 127), | ||||
|     Rank._(4, '4', 213, 12, 208, 315, 93, 132), | ||||
|     Rank._(5, '5', 314, 21, 309, 324, 85, 125), | ||||
|     Rank._(6, '6', 419, 17, 414, 320, 84, 129), | ||||
|     Rank._(7, '7', 509, 21, 505, 324, 92, 128), | ||||
|     Rank._(8, '8', 612, 19, 607, 322, 78, 127), | ||||
|     Rank._(9, '9', 709, 19, 704, 322, 84, 130), | ||||
|     Rank._(10, '10', 810, 20, 805, 322, 137, 127), | ||||
|     Rank._(11, 'J', 15, 170, 469, 167, 56, 126), | ||||
|     Rank._(12, 'Q', 92, 168, 547, 165, 132, 128), | ||||
|     Rank._(13, 'K', 243, 170, 696, 167, 92, 123), | ||||
|   ]; | ||||
| } | ||||
							
								
								
									
										29
									
								
								doc/tutorials/klondike/app/lib/step3/suit.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								doc/tutorials/klondike/app/lib/step3/suit.dart
									
									
									
									
									
										Normal file
									
								
							| @ -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<Suit> _singletons = [ | ||||
|     Suit._(0, '♥', 1176, 17, 172, 183), | ||||
|     Suit._(1, '♦', 973, 14, 177, 182), | ||||
|     Suit._(2, '♣', 974, 226, 184, 172), | ||||
|     Suit._(3, '♠', 1178, 220, 176, 182), | ||||
|   ]; | ||||
|  | ||||
|   /// Hearts and Diamonds are red, while Clubs and Spades are black. | ||||
|   bool get isRed => value <= 1; | ||||
|   bool get isBlack => value >= 2; | ||||
| } | ||||
| @ -15,4 +15,6 @@ with the [Dart] programming language. | ||||
|  | ||||
| 1. Preparation  <step1.md> | ||||
| 2. Scaffolding  <step2.md> | ||||
| 3. Cards        <step3.md> | ||||
| [To be continued]... <tbc.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): | ||||
|  | ||||
|  | ||||
|  | ||||
| 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 | ||||
|  | ||||
| @ -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<void> 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<void> 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 | ||||
|  | ||||
							
								
								
									
										479
									
								
								doc/tutorials/klondike/step3.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										479
									
								
								doc/tutorials/klondike/step3.md
									
									
									
									
									
										Normal file
									
								
							| @ -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<Suit> _singletons = [ | ||||
|     Suit._(0, '♥', 1176, 17, 172, 183), | ||||
|     Suit._(1, '♦', 973, 14, 177, 182), | ||||
|     Suit._(2, '♣', 974, 226, 184, 172), | ||||
|     Suit._(3, '♠', 1178, 220, 176, 182), | ||||
|   ]; | ||||
| ``` | ||||
| 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<Rank> _singletons = [ | ||||
|     Rank._(1, 'A', 335, 164, 789, 161, 120, 129), | ||||
|     Rank._(2, '2', 20, 19, 15, 322, 83, 125), | ||||
|     Rank._(3, '3', 122, 19, 117, 322, 80, 127), | ||||
|     Rank._(4, '4', 213, 12, 208, 315, 93, 132), | ||||
|     Rank._(5, '5', 314, 21, 309, 324, 85, 125), | ||||
|     Rank._(6, '6', 419, 17, 414, 320, 84, 129), | ||||
|     Rank._(7, '7', 509, 21, 505, 324, 92, 128), | ||||
|     Rank._(8, '8', 612, 19, 607, 322, 78, 127), | ||||
|     Rank._(9, '9', 709, 19, 704, 322, 84, 130), | ||||
|     Rank._(10, '10', 810, 20, 805, 322, 137, 127), | ||||
|     Rank._(11, 'J', 15, 170, 469, 167, 56, 126), | ||||
|     Rank._(12, 'Q', 92, 168, 547, 165, 132, 128), | ||||
|     Rank._(13, 'K', 243, 170, 696, 167, 92, 123), | ||||
|   ]; | ||||
| } | ||||
| ``` | ||||
|  | ||||
|  | ||||
| ## 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/ | ||||
							
								
								
									
										3
									
								
								doc/tutorials/klondike/tbc.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								doc/tutorials/klondike/tbc.md
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,3 @@ | ||||
| # To be continued | ||||
|  | ||||
| This tutorial is not finished yet, stay tuned for the updates. | ||||
		Reference in New Issue
	
	Block a user
	 Pasha Stetsenko
					Pasha Stetsenko