mirror of
https://github.com/flame-engine/flame.git
synced 2025-11-01 19:12:31 +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