mirror of
https://github.com/flame-engine/flame.git
synced 2025-10-29 16:05:47 +08:00
docs: Klondike tutorial, part 4 (#1740)
This PR adds step 4 for the Klondike tutorial: "Gameplay".
This commit is contained in:
@ -1 +1,6 @@
|
||||
include: package:flame_lint/analysis_options.yaml
|
||||
|
||||
linter:
|
||||
rules:
|
||||
always_use_package_imports: false
|
||||
prefer_relative_imports: true
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import 'dart:html'; // ignore: avoid_web_libraries_in_flutter
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:klondike/step2/main.dart' as step2;
|
||||
import 'package:klondike/step3/main.dart' as step3;
|
||||
import 'step2/main.dart' as step2;
|
||||
import 'step3/main.dart' as step3;
|
||||
import 'step4/main.dart' as step4;
|
||||
|
||||
void main() {
|
||||
var page = window.location.search ?? '';
|
||||
@ -17,6 +18,10 @@ void main() {
|
||||
step3.main();
|
||||
break;
|
||||
|
||||
case 'step4':
|
||||
step4.main();
|
||||
break;
|
||||
|
||||
default:
|
||||
runApp(
|
||||
Directionality(
|
||||
|
||||
@ -3,10 +3,10 @@ import 'package:flame/experimental.dart';
|
||||
import 'package:flame/flame.dart';
|
||||
import 'package:flame/game.dart';
|
||||
|
||||
import 'package:klondike/step2/components/foundation.dart';
|
||||
import 'package:klondike/step2/components/pile.dart';
|
||||
import 'package:klondike/step2/components/stock.dart';
|
||||
import 'package:klondike/step2/components/waste.dart';
|
||||
import 'components/foundation.dart';
|
||||
import 'components/pile.dart';
|
||||
import 'components/stock.dart';
|
||||
import 'components/waste.dart';
|
||||
|
||||
class KlondikeGame extends FlameGame {
|
||||
static const double cardGap = 175.0;
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import 'package:flame/game.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'package:klondike/step2/klondike_game.dart';
|
||||
import 'klondike_game.dart';
|
||||
|
||||
void main() {
|
||||
final game = KlondikeGame();
|
||||
|
||||
@ -2,9 +2,9 @@ import 'dart:math';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flame/components.dart';
|
||||
import 'package:klondike/step3/klondike_game.dart';
|
||||
import 'package:klondike/step3/rank.dart';
|
||||
import 'package:klondike/step3/suit.dart';
|
||||
import '../klondike_game.dart';
|
||||
import '../rank.dart';
|
||||
import '../suit.dart';
|
||||
|
||||
class Card extends PositionComponent {
|
||||
Card(int intRank, int intSuit)
|
||||
|
||||
@ -5,11 +5,11 @@ import 'package:flame/experimental.dart';
|
||||
import 'package:flame/flame.dart';
|
||||
import 'package:flame/game.dart';
|
||||
|
||||
import 'package:klondike/step3/components/card.dart';
|
||||
import 'package:klondike/step3/components/foundation.dart';
|
||||
import 'package:klondike/step3/components/pile.dart';
|
||||
import 'package:klondike/step3/components/stock.dart';
|
||||
import 'package:klondike/step3/components/waste.dart';
|
||||
import 'components/card.dart';
|
||||
import 'components/foundation.dart';
|
||||
import 'components/pile.dart';
|
||||
import 'components/stock.dart';
|
||||
import 'components/waste.dart';
|
||||
|
||||
class KlondikeGame extends FlameGame {
|
||||
static const double cardGap = 175.0;
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import 'package:flame/game.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'package:klondike/step3/klondike_game.dart';
|
||||
import 'klondike_game.dart';
|
||||
|
||||
void main() {
|
||||
final game = KlondikeGame();
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import 'package:flame/components.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:klondike/step3/klondike_game.dart';
|
||||
import 'klondike_game.dart';
|
||||
|
||||
@immutable
|
||||
class Rank {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import 'package:flame/sprite.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:klondike/step3/klondike_game.dart';
|
||||
import 'klondike_game.dart';
|
||||
|
||||
@immutable
|
||||
class Suit {
|
||||
|
||||
282
doc/tutorials/klondike/app/lib/step4/components/card.dart
Normal file
282
doc/tutorials/klondike/app/lib/step4/components/card.dart
Normal file
@ -0,0 +1,282 @@
|
||||
import 'dart:math';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flame/components.dart';
|
||||
import 'package:flame/experimental.dart';
|
||||
import 'package:flame/game.dart';
|
||||
import '../klondike_game.dart';
|
||||
import '../pile.dart';
|
||||
import '../rank.dart';
|
||||
import '../suit.dart';
|
||||
import 'tableau_pile.dart';
|
||||
|
||||
class Card extends PositionComponent with DragCallbacks {
|
||||
Card(int intRank, int intSuit)
|
||||
: rank = Rank.fromInt(intRank),
|
||||
suit = Suit.fromInt(intSuit),
|
||||
super(size: KlondikeGame.cardSize);
|
||||
|
||||
final Rank rank;
|
||||
final Suit suit;
|
||||
Pile? pile;
|
||||
bool _faceUp = false;
|
||||
bool _isDragging = false;
|
||||
final List<Card> attachedCards = [];
|
||||
|
||||
bool get isFaceUp => _faceUp;
|
||||
bool get isFaceDown => !_faceUp;
|
||||
void flip() => _faceUp = !_faceUp;
|
||||
|
||||
@override
|
||||
String toString() => rank.label + suit.label; // e.g. "Q♠" or "10♦"
|
||||
|
||||
//#region Rendering
|
||||
|
||||
@override
|
||||
void render(Canvas canvas) {
|
||||
if (_faceUp) {
|
||||
_renderFront(canvas);
|
||||
} else {
|
||||
_renderBack(canvas);
|
||||
}
|
||||
}
|
||||
|
||||
static final Paint backBackgroundPaint = Paint()
|
||||
..color = const Color(0xff380c02);
|
||||
static final Paint backBorderPaint1 = Paint()
|
||||
..color = const Color(0xffdbaf58)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 10;
|
||||
static final Paint backBorderPaint2 = Paint()
|
||||
..color = const Color(0x5CEF971B)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 35;
|
||||
static final RRect cardRRect = RRect.fromRectAndRadius(
|
||||
KlondikeGame.cardSize.toRect(),
|
||||
const Radius.circular(KlondikeGame.cardRadius),
|
||||
);
|
||||
static final RRect backRRectInner = cardRRect.deflate(40);
|
||||
static late final Sprite flameSprite = klondikeSprite(1367, 6, 357, 501);
|
||||
|
||||
void _renderBack(Canvas canvas) {
|
||||
canvas.drawRRect(cardRRect, backBackgroundPaint);
|
||||
canvas.drawRRect(cardRRect, backBorderPaint1);
|
||||
canvas.drawRRect(backRRectInner, backBorderPaint2);
|
||||
flameSprite.render(canvas, position: size / 2, anchor: Anchor.center);
|
||||
}
|
||||
|
||||
static final Paint frontBackgroundPaint = Paint()
|
||||
..color = const Color(0xff000000);
|
||||
static final Paint redBorderPaint = Paint()
|
||||
..color = const Color(0xffece8a3)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 10;
|
||||
static final Paint blackBorderPaint = Paint()
|
||||
..color = const Color(0xff7ab2e8)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 10;
|
||||
static final blueFilter = Paint()
|
||||
..colorFilter = const ColorFilter.mode(
|
||||
Color(0x880d8bff),
|
||||
BlendMode.srcATop,
|
||||
);
|
||||
static late final Sprite redJack = klondikeSprite(81, 565, 562, 488);
|
||||
static late final Sprite redQueen = klondikeSprite(717, 541, 486, 515);
|
||||
static late final Sprite redKing = klondikeSprite(1305, 532, 407, 549);
|
||||
static late final Sprite blackJack = klondikeSprite(81, 565, 562, 488)
|
||||
..paint = blueFilter;
|
||||
static late final Sprite blackQueen = klondikeSprite(717, 541, 486, 515)
|
||||
..paint = blueFilter;
|
||||
static late final Sprite blackKing = klondikeSprite(1305, 532, 407, 549)
|
||||
..paint = blueFilter;
|
||||
|
||||
void _renderFront(Canvas canvas) {
|
||||
canvas.drawRRect(cardRRect, frontBackgroundPaint);
|
||||
canvas.drawRRect(
|
||||
cardRRect,
|
||||
suit.isRed ? redBorderPaint : blackBorderPaint,
|
||||
);
|
||||
|
||||
final rankSprite = suit.isBlack ? rank.blackSprite : rank.redSprite;
|
||||
final suitSprite = suit.sprite;
|
||||
_drawSprite(canvas, rankSprite, 0.1, 0.08);
|
||||
_drawSprite(canvas, suitSprite, 0.1, 0.18, scale: 0.5);
|
||||
_drawSprite(canvas, rankSprite, 0.1, 0.08, rotate: true);
|
||||
_drawSprite(canvas, suitSprite, 0.1, 0.18, scale: 0.5, rotate: true);
|
||||
switch (rank.value) {
|
||||
case 1:
|
||||
_drawSprite(canvas, suitSprite, 0.5, 0.5, scale: 2.5);
|
||||
break;
|
||||
case 2:
|
||||
_drawSprite(canvas, suitSprite, 0.5, 0.25);
|
||||
_drawSprite(canvas, suitSprite, 0.5, 0.25, rotate: true);
|
||||
break;
|
||||
case 3:
|
||||
_drawSprite(canvas, suitSprite, 0.5, 0.2);
|
||||
_drawSprite(canvas, suitSprite, 0.5, 0.5);
|
||||
_drawSprite(canvas, suitSprite, 0.5, 0.2, rotate: true);
|
||||
break;
|
||||
case 4:
|
||||
_drawSprite(canvas, suitSprite, 0.3, 0.25);
|
||||
_drawSprite(canvas, suitSprite, 0.7, 0.25);
|
||||
_drawSprite(canvas, suitSprite, 0.3, 0.25, rotate: true);
|
||||
_drawSprite(canvas, suitSprite, 0.7, 0.25, rotate: true);
|
||||
break;
|
||||
case 5:
|
||||
_drawSprite(canvas, suitSprite, 0.3, 0.25);
|
||||
_drawSprite(canvas, suitSprite, 0.7, 0.25);
|
||||
_drawSprite(canvas, suitSprite, 0.3, 0.25, rotate: true);
|
||||
_drawSprite(canvas, suitSprite, 0.7, 0.25, rotate: true);
|
||||
_drawSprite(canvas, suitSprite, 0.5, 0.5);
|
||||
break;
|
||||
case 6:
|
||||
_drawSprite(canvas, suitSprite, 0.3, 0.25);
|
||||
_drawSprite(canvas, suitSprite, 0.7, 0.25);
|
||||
_drawSprite(canvas, suitSprite, 0.3, 0.5);
|
||||
_drawSprite(canvas, suitSprite, 0.7, 0.5);
|
||||
_drawSprite(canvas, suitSprite, 0.3, 0.25, rotate: true);
|
||||
_drawSprite(canvas, suitSprite, 0.7, 0.25, rotate: true);
|
||||
break;
|
||||
case 7:
|
||||
_drawSprite(canvas, suitSprite, 0.3, 0.2);
|
||||
_drawSprite(canvas, suitSprite, 0.7, 0.2);
|
||||
_drawSprite(canvas, suitSprite, 0.5, 0.35);
|
||||
_drawSprite(canvas, suitSprite, 0.3, 0.5);
|
||||
_drawSprite(canvas, suitSprite, 0.7, 0.5);
|
||||
_drawSprite(canvas, suitSprite, 0.3, 0.2, rotate: true);
|
||||
_drawSprite(canvas, suitSprite, 0.7, 0.2, rotate: true);
|
||||
break;
|
||||
case 8:
|
||||
_drawSprite(canvas, suitSprite, 0.3, 0.2);
|
||||
_drawSprite(canvas, suitSprite, 0.7, 0.2);
|
||||
_drawSprite(canvas, suitSprite, 0.5, 0.35);
|
||||
_drawSprite(canvas, suitSprite, 0.3, 0.5);
|
||||
_drawSprite(canvas, suitSprite, 0.7, 0.5);
|
||||
_drawSprite(canvas, suitSprite, 0.3, 0.2, rotate: true);
|
||||
_drawSprite(canvas, suitSprite, 0.7, 0.2, rotate: true);
|
||||
_drawSprite(canvas, suitSprite, 0.5, 0.35, rotate: true);
|
||||
break;
|
||||
case 9:
|
||||
_drawSprite(canvas, suitSprite, 0.3, 0.2);
|
||||
_drawSprite(canvas, suitSprite, 0.7, 0.2);
|
||||
_drawSprite(canvas, suitSprite, 0.5, 0.3);
|
||||
_drawSprite(canvas, suitSprite, 0.3, 0.4);
|
||||
_drawSprite(canvas, suitSprite, 0.7, 0.4);
|
||||
_drawSprite(canvas, suitSprite, 0.3, 0.2, rotate: true);
|
||||
_drawSprite(canvas, suitSprite, 0.7, 0.2, rotate: true);
|
||||
_drawSprite(canvas, suitSprite, 0.3, 0.4, rotate: true);
|
||||
_drawSprite(canvas, suitSprite, 0.7, 0.4, rotate: true);
|
||||
break;
|
||||
case 10:
|
||||
_drawSprite(canvas, suitSprite, 0.3, 0.2);
|
||||
_drawSprite(canvas, suitSprite, 0.7, 0.2);
|
||||
_drawSprite(canvas, suitSprite, 0.5, 0.3);
|
||||
_drawSprite(canvas, suitSprite, 0.3, 0.4);
|
||||
_drawSprite(canvas, suitSprite, 0.7, 0.4);
|
||||
_drawSprite(canvas, suitSprite, 0.3, 0.2, rotate: true);
|
||||
_drawSprite(canvas, suitSprite, 0.7, 0.2, rotate: true);
|
||||
_drawSprite(canvas, suitSprite, 0.5, 0.3, rotate: true);
|
||||
_drawSprite(canvas, suitSprite, 0.3, 0.4, rotate: true);
|
||||
_drawSprite(canvas, suitSprite, 0.7, 0.4, rotate: true);
|
||||
break;
|
||||
case 11:
|
||||
_drawSprite(canvas, suit.isRed ? redJack : blackJack, 0.5, 0.5);
|
||||
break;
|
||||
case 12:
|
||||
_drawSprite(canvas, suit.isRed ? redQueen : blackQueen, 0.5, 0.5);
|
||||
break;
|
||||
case 13:
|
||||
_drawSprite(canvas, suit.isRed ? redKing : blackKing, 0.5, 0.5);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _drawSprite(
|
||||
Canvas canvas,
|
||||
Sprite sprite,
|
||||
double relativeX,
|
||||
double relativeY, {
|
||||
double scale = 1,
|
||||
bool rotate = false,
|
||||
}) {
|
||||
if (rotate) {
|
||||
canvas.save();
|
||||
canvas.translate(size.x / 2, size.y / 2);
|
||||
canvas.rotate(pi);
|
||||
canvas.translate(-size.x / 2, -size.y / 2);
|
||||
}
|
||||
sprite.render(
|
||||
canvas,
|
||||
position: Vector2(relativeX * size.x, relativeY * size.y),
|
||||
anchor: Anchor.center,
|
||||
size: sprite.srcSize.scaled(scale),
|
||||
);
|
||||
if (rotate) {
|
||||
canvas.restore();
|
||||
}
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Dragging
|
||||
|
||||
@override
|
||||
void onDragStart(DragStartEvent event) {
|
||||
if (pile?.canMoveCard(this) ?? false) {
|
||||
_isDragging = true;
|
||||
priority = 100;
|
||||
if (pile is TableauPile) {
|
||||
attachedCards.clear();
|
||||
final extraCards = (pile! as TableauPile).cardsOnTop(this);
|
||||
for (final card in extraCards) {
|
||||
card.priority = attachedCards.length + 101;
|
||||
attachedCards.add(card);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onDragUpdate(DragUpdateEvent event) {
|
||||
if (!_isDragging) {
|
||||
return;
|
||||
}
|
||||
final cameraZoom = (findGame()! as FlameGame)
|
||||
.firstChild<CameraComponent>()!
|
||||
.viewfinder
|
||||
.zoom;
|
||||
final delta = event.delta / cameraZoom;
|
||||
position.add(delta);
|
||||
attachedCards.forEach((card) => card.position.add(delta));
|
||||
}
|
||||
|
||||
@override
|
||||
void onDragEnd(DragEndEvent event) {
|
||||
if (!_isDragging) {
|
||||
return;
|
||||
}
|
||||
_isDragging = false;
|
||||
final dropPiles = parent!
|
||||
.componentsAtPoint(position + size / 2)
|
||||
.whereType<Pile>()
|
||||
.toList();
|
||||
if (dropPiles.isNotEmpty) {
|
||||
if (dropPiles.first.canAcceptCard(this)) {
|
||||
pile!.removeCard(this);
|
||||
dropPiles.first.acquireCard(this);
|
||||
if (attachedCards.isNotEmpty) {
|
||||
attachedCards.forEach((card) => dropPiles.first.acquireCard(card));
|
||||
attachedCards.clear();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
pile!.returnCard(this);
|
||||
if (attachedCards.isNotEmpty) {
|
||||
attachedCards.forEach((card) => pile!.returnCard(card));
|
||||
attachedCards.clear();
|
||||
}
|
||||
}
|
||||
|
||||
//#endregion
|
||||
}
|
||||
@ -0,0 +1,79 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flame/components.dart';
|
||||
|
||||
import '../klondike_game.dart';
|
||||
import '../pile.dart';
|
||||
import '../suit.dart';
|
||||
import 'card.dart';
|
||||
|
||||
class FoundationPile extends PositionComponent implements Pile {
|
||||
FoundationPile(int intSuit, {super.position})
|
||||
: suit = Suit.fromInt(intSuit),
|
||||
super(size: KlondikeGame.cardSize);
|
||||
|
||||
final Suit suit;
|
||||
final List<Card> _cards = [];
|
||||
|
||||
//#region Pile API
|
||||
|
||||
@override
|
||||
bool canMoveCard(Card card) {
|
||||
return _cards.isNotEmpty && card == _cards.last;
|
||||
}
|
||||
|
||||
@override
|
||||
bool canAcceptCard(Card card) {
|
||||
final topCardRank = _cards.isEmpty ? 0 : _cards.last.rank.value;
|
||||
return card.suit == suit &&
|
||||
card.rank.value == topCardRank + 1 &&
|
||||
card.attachedCards.isEmpty;
|
||||
}
|
||||
|
||||
@override
|
||||
void removeCard(Card card) {
|
||||
assert(canMoveCard(card));
|
||||
_cards.removeLast();
|
||||
}
|
||||
|
||||
@override
|
||||
void returnCard(Card card) {
|
||||
card.position = position;
|
||||
card.priority = _cards.indexOf(card);
|
||||
}
|
||||
|
||||
@override
|
||||
void acquireCard(Card card) {
|
||||
assert(card.isFaceUp);
|
||||
card.position = position;
|
||||
card.priority = _cards.length;
|
||||
card.pile = this;
|
||||
_cards.add(card);
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Rendering
|
||||
|
||||
final _borderPaint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 10
|
||||
..color = const Color(0x50ffffff);
|
||||
late final _suitPaint = Paint()
|
||||
..color = suit.isRed ? const Color(0x3a000000) : const Color(0x64000000)
|
||||
..blendMode = BlendMode.luminosity;
|
||||
|
||||
@override
|
||||
void render(Canvas canvas) {
|
||||
canvas.drawRRect(KlondikeGame.cardRRect, _borderPaint);
|
||||
suit.sprite.render(
|
||||
canvas,
|
||||
position: size / 2,
|
||||
anchor: Anchor.center,
|
||||
size: Vector2.all(KlondikeGame.cardWidth * 0.6),
|
||||
overridePaint: _suitPaint,
|
||||
);
|
||||
}
|
||||
|
||||
//#endregion
|
||||
}
|
||||
@ -0,0 +1,84 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flame/components.dart';
|
||||
import 'package:flame/experimental.dart';
|
||||
|
||||
import '../klondike_game.dart';
|
||||
import '../pile.dart';
|
||||
import 'card.dart';
|
||||
import 'waste_pile.dart';
|
||||
|
||||
class StockPile extends PositionComponent with TapCallbacks implements Pile {
|
||||
StockPile({super.position}) : super(size: KlondikeGame.cardSize);
|
||||
|
||||
/// Which cards are currently placed onto this pile. The first card in the
|
||||
/// list is at the bottom, the last card is on top.
|
||||
final List<Card> _cards = [];
|
||||
|
||||
//#region Pile API
|
||||
|
||||
@override
|
||||
bool canMoveCard(Card card) => false;
|
||||
|
||||
@override
|
||||
bool canAcceptCard(Card card) => false;
|
||||
|
||||
@override
|
||||
void removeCard(Card card) => throw StateError('cannot remove cards');
|
||||
|
||||
@override
|
||||
void returnCard(Card card) => throw StateError('cannot remove cards');
|
||||
|
||||
@override
|
||||
void acquireCard(Card card) {
|
||||
assert(card.isFaceDown);
|
||||
card.pile = this;
|
||||
card.position = position;
|
||||
card.priority = _cards.length;
|
||||
_cards.add(card);
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
@override
|
||||
void onTapUp(TapUpEvent event) {
|
||||
final wastePile = parent!.firstChild<WastePile>()!;
|
||||
if (_cards.isEmpty) {
|
||||
wastePile.removeAllCards().reversed.forEach((card) {
|
||||
card.flip();
|
||||
acquireCard(card);
|
||||
});
|
||||
} else {
|
||||
for (var i = 0; i < 3; i++) {
|
||||
if (_cards.isNotEmpty) {
|
||||
final card = _cards.removeLast();
|
||||
card.flip();
|
||||
wastePile.acquireCard(card);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//#region Rendering
|
||||
|
||||
final _borderPaint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 10
|
||||
..color = const Color(0xFF3F5B5D);
|
||||
final _circlePaint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 100
|
||||
..color = const Color(0x883F5B5D);
|
||||
|
||||
@override
|
||||
void render(Canvas canvas) {
|
||||
canvas.drawRRect(KlondikeGame.cardRRect, _borderPaint);
|
||||
canvas.drawCircle(
|
||||
Offset(width / 2, height / 2),
|
||||
KlondikeGame.cardWidth * 0.3,
|
||||
_circlePaint,
|
||||
);
|
||||
}
|
||||
|
||||
//#endregion
|
||||
}
|
||||
@ -0,0 +1,97 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flame/components.dart';
|
||||
|
||||
import '../klondike_game.dart';
|
||||
import '../pile.dart';
|
||||
import 'card.dart';
|
||||
|
||||
class TableauPile extends PositionComponent implements Pile {
|
||||
TableauPile({super.position}) : super(size: KlondikeGame.cardSize);
|
||||
|
||||
/// Which cards are currently placed onto this pile.
|
||||
final List<Card> _cards = [];
|
||||
final Vector2 _fanOffset1 = Vector2(0, KlondikeGame.cardHeight * 0.05);
|
||||
final Vector2 _fanOffset2 = Vector2(0, KlondikeGame.cardHeight * 0.20);
|
||||
|
||||
//#region Pile API
|
||||
|
||||
@override
|
||||
bool canMoveCard(Card card) => card.isFaceUp;
|
||||
|
||||
@override
|
||||
bool canAcceptCard(Card card) {
|
||||
if (_cards.isEmpty) {
|
||||
return card.rank.value == 13;
|
||||
} else {
|
||||
final topCard = _cards.last;
|
||||
return card.suit.isRed == !topCard.suit.isRed &&
|
||||
card.rank.value == topCard.rank.value - 1;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void removeCard(Card card) {
|
||||
assert(_cards.contains(card) && card.isFaceUp);
|
||||
final index = _cards.indexOf(card);
|
||||
_cards.removeRange(index, _cards.length);
|
||||
if (_cards.isNotEmpty && _cards.last.isFaceDown) {
|
||||
flipTopCard();
|
||||
}
|
||||
layOutCards();
|
||||
}
|
||||
|
||||
@override
|
||||
void returnCard(Card card) {
|
||||
card.priority = _cards.indexOf(card);
|
||||
layOutCards();
|
||||
}
|
||||
|
||||
@override
|
||||
void acquireCard(Card card) {
|
||||
card.pile = this;
|
||||
card.priority = _cards.length;
|
||||
_cards.add(card);
|
||||
layOutCards();
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
void flipTopCard() {
|
||||
assert(_cards.last.isFaceDown);
|
||||
_cards.last.flip();
|
||||
}
|
||||
|
||||
void layOutCards() {
|
||||
if (_cards.isEmpty) {
|
||||
return;
|
||||
}
|
||||
_cards[0].position.setFrom(position);
|
||||
for (var i = 1; i < _cards.length; i++) {
|
||||
_cards[i].position
|
||||
..setFrom(_cards[i - 1].position)
|
||||
..add(_cards[i - 1].isFaceDown ? _fanOffset1 : _fanOffset2);
|
||||
}
|
||||
height = KlondikeGame.cardHeight * 1.5 + _cards.last.y - _cards.first.y;
|
||||
}
|
||||
|
||||
List<Card> cardsOnTop(Card card) {
|
||||
assert(card.isFaceUp && _cards.contains(card));
|
||||
final index = _cards.indexOf(card);
|
||||
return _cards.getRange(index + 1, _cards.length).toList();
|
||||
}
|
||||
|
||||
//#region Rendering
|
||||
|
||||
final _borderPaint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 10
|
||||
..color = const Color(0x50ffffff);
|
||||
|
||||
@override
|
||||
void render(Canvas canvas) {
|
||||
canvas.drawRRect(KlondikeGame.cardRRect, _borderPaint);
|
||||
}
|
||||
|
||||
//#endregion
|
||||
}
|
||||
@ -0,0 +1,64 @@
|
||||
import 'package:flame/components.dart';
|
||||
|
||||
import '../klondike_game.dart';
|
||||
import '../pile.dart';
|
||||
import 'card.dart';
|
||||
|
||||
class WastePile extends PositionComponent implements Pile {
|
||||
WastePile({super.position}) : super(size: KlondikeGame.cardSize);
|
||||
|
||||
final List<Card> _cards = [];
|
||||
final Vector2 _fanOffset = Vector2(KlondikeGame.cardWidth * 0.2, 0);
|
||||
|
||||
//#region Pile API
|
||||
|
||||
@override
|
||||
bool canMoveCard(Card card) => _cards.isNotEmpty && card == _cards.last;
|
||||
|
||||
@override
|
||||
bool canAcceptCard(Card card) => false;
|
||||
|
||||
@override
|
||||
void removeCard(Card card) {
|
||||
assert(canMoveCard(card));
|
||||
_cards.removeLast();
|
||||
_fanOutTopCards();
|
||||
}
|
||||
|
||||
@override
|
||||
void returnCard(Card card) {
|
||||
card.priority = _cards.indexOf(card);
|
||||
_fanOutTopCards();
|
||||
}
|
||||
|
||||
@override
|
||||
void acquireCard(Card card) {
|
||||
assert(card.isFaceUp);
|
||||
card.pile = this;
|
||||
card.position = position;
|
||||
card.priority = _cards.length;
|
||||
_cards.add(card);
|
||||
_fanOutTopCards();
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
List<Card> removeAllCards() {
|
||||
final cards = _cards.toList();
|
||||
_cards.clear();
|
||||
return cards;
|
||||
}
|
||||
|
||||
void _fanOutTopCards() {
|
||||
final n = _cards.length;
|
||||
for (var i = 0; i < n; i++) {
|
||||
_cards[i].position = position;
|
||||
}
|
||||
if (n == 2) {
|
||||
_cards[1].position.add(_fanOffset);
|
||||
} else if (n >= 3) {
|
||||
_cards[n - 2].position.add(_fanOffset);
|
||||
_cards[n - 1].position.addScaled(_fanOffset, 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
87
doc/tutorials/klondike/app/lib/step4/klondike_game.dart
Normal file
87
doc/tutorials/klondike/app/lib/step4/klondike_game.dart
Normal file
@ -0,0 +1,87 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flame/components.dart';
|
||||
import 'package:flame/experimental.dart';
|
||||
import 'package:flame/flame.dart';
|
||||
import 'package:flame/game.dart';
|
||||
|
||||
import 'components/card.dart';
|
||||
import 'components/foundation_pile.dart';
|
||||
import 'components/stock_pile.dart';
|
||||
import 'components/tableau_pile.dart';
|
||||
import 'components/waste_pile.dart';
|
||||
|
||||
class KlondikeGame extends FlameGame
|
||||
with HasTappableComponents, HasDraggableComponents {
|
||||
static const double cardGap = 175.0;
|
||||
static const double cardWidth = 1000.0;
|
||||
static const double cardHeight = 1400.0;
|
||||
static const double cardRadius = 100.0;
|
||||
static final Vector2 cardSize = Vector2(cardWidth, cardHeight);
|
||||
static final cardRRect = RRect.fromRectAndRadius(
|
||||
const Rect.fromLTWH(0, 0, cardWidth, cardHeight),
|
||||
const Radius.circular(cardRadius),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> onLoad() async {
|
||||
await Flame.images.load('klondike-sprites.png');
|
||||
|
||||
final stock = StockPile(position: Vector2(cardGap, cardGap));
|
||||
final waste =
|
||||
WastePile(position: Vector2(cardWidth + 2 * cardGap, cardGap));
|
||||
final foundations = List.generate(
|
||||
4,
|
||||
(i) => FoundationPile(
|
||||
i,
|
||||
position: Vector2((i + 3) * (cardWidth + cardGap) + cardGap, cardGap),
|
||||
),
|
||||
);
|
||||
final piles = List.generate(
|
||||
7,
|
||||
(i) => TableauPile(
|
||||
position: Vector2(
|
||||
cardGap + i * (cardWidth + cardGap),
|
||||
cardHeight + 2 * cardGap,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final world = World()
|
||||
..add(stock)
|
||||
..add(waste)
|
||||
..addAll(foundations)
|
||||
..addAll(piles);
|
||||
add(world);
|
||||
|
||||
final camera = CameraComponent(world: world)
|
||||
..viewfinder.visibleGameSize =
|
||||
Vector2(cardWidth * 7 + cardGap * 8, 4 * cardHeight + 3 * cardGap)
|
||||
..viewfinder.position = Vector2(cardWidth * 3.5 + cardGap * 4, 0)
|
||||
..viewfinder.anchor = Anchor.topCenter;
|
||||
add(camera);
|
||||
|
||||
final cards = [
|
||||
for (var rank = 1; rank <= 13; rank++)
|
||||
for (var suit = 0; suit < 4; suit++) Card(rank, suit)
|
||||
];
|
||||
cards.shuffle();
|
||||
world.addAll(cards);
|
||||
|
||||
for (var i = 0; i < 7; i++) {
|
||||
for (var j = i; j < 7; j++) {
|
||||
piles[j].acquireCard(cards.removeLast());
|
||||
}
|
||||
piles[i].flipTopCard();
|
||||
}
|
||||
cards.forEach(stock.acquireCard);
|
||||
}
|
||||
}
|
||||
|
||||
Sprite klondikeSprite(double x, double y, double width, double height) {
|
||||
return Sprite(
|
||||
Flame.images.fromCache('klondike-sprites.png'),
|
||||
srcPosition: Vector2(x, y),
|
||||
srcSize: Vector2(width, height),
|
||||
);
|
||||
}
|
||||
9
doc/tutorials/klondike/app/lib/step4/main.dart
Normal file
9
doc/tutorials/klondike/app/lib/step4/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));
|
||||
}
|
||||
23
doc/tutorials/klondike/app/lib/step4/pile.dart
Normal file
23
doc/tutorials/klondike/app/lib/step4/pile.dart
Normal file
@ -0,0 +1,23 @@
|
||||
import 'components/card.dart';
|
||||
|
||||
abstract class Pile {
|
||||
/// Returns true if the [card] can be taken away from this pile and moved
|
||||
/// somewhere else.
|
||||
bool canMoveCard(Card card);
|
||||
|
||||
/// Returns true if the [card] can be placed on top of this pile. The [card]
|
||||
/// may have other cards "attached" to it.
|
||||
bool canAcceptCard(Card card);
|
||||
|
||||
/// Removes [card] from this pile; this method will only be called for a card
|
||||
/// that both belong to this pile, and for which [canMoveCard] returns true.
|
||||
void removeCard(Card card);
|
||||
|
||||
/// Places a single [card] on top of this pile. This method will only be
|
||||
/// called for a card for which [canAcceptCard] returns true.
|
||||
void acquireCard(Card card);
|
||||
|
||||
/// Returns the [card] (which already belongs to this pile) in its proper
|
||||
/// place.
|
||||
void returnCard(Card card);
|
||||
}
|
||||
47
doc/tutorials/klondike/app/lib/step4/rank.dart
Normal file
47
doc/tutorials/klondike/app/lib/step4/rank.dart
Normal file
@ -0,0 +1,47 @@
|
||||
import 'package:flame/components.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'klondike_game.dart';
|
||||
|
||||
@immutable
|
||||
class Rank {
|
||||
factory Rank.fromInt(int value) {
|
||||
assert(
|
||||
value >= 1 && value <= 13,
|
||||
'value is outside of the bounds of what a rank can be',
|
||||
);
|
||||
return _singletons[value - 1];
|
||||
}
|
||||
|
||||
Rank._(
|
||||
this.value,
|
||||
this.label,
|
||||
double x1,
|
||||
double y1,
|
||||
double x2,
|
||||
double y2,
|
||||
double w,
|
||||
double h,
|
||||
) : redSprite = klondikeSprite(x1, y1, w, h),
|
||||
blackSprite = klondikeSprite(x2, y2, w, h);
|
||||
|
||||
final int value;
|
||||
final String label;
|
||||
final Sprite redSprite;
|
||||
final Sprite blackSprite;
|
||||
|
||||
static late final List<Rank> _singletons = [
|
||||
Rank._(1, 'A', 335, 164, 789, 161, 120, 129),
|
||||
Rank._(2, '2', 20, 19, 15, 322, 83, 125),
|
||||
Rank._(3, '3', 122, 19, 117, 322, 80, 127),
|
||||
Rank._(4, '4', 213, 12, 208, 315, 93, 132),
|
||||
Rank._(5, '5', 314, 21, 309, 324, 85, 125),
|
||||
Rank._(6, '6', 419, 17, 414, 320, 84, 129),
|
||||
Rank._(7, '7', 509, 21, 505, 324, 92, 128),
|
||||
Rank._(8, '8', 612, 19, 607, 322, 78, 127),
|
||||
Rank._(9, '9', 709, 19, 704, 322, 84, 130),
|
||||
Rank._(10, '10', 810, 20, 805, 322, 137, 127),
|
||||
Rank._(11, 'J', 15, 170, 469, 167, 56, 126),
|
||||
Rank._(12, 'Q', 92, 168, 547, 165, 132, 128),
|
||||
Rank._(13, 'K', 243, 170, 696, 167, 92, 123),
|
||||
];
|
||||
}
|
||||
32
doc/tutorials/klondike/app/lib/step4/suit.dart
Normal file
32
doc/tutorials/klondike/app/lib/step4/suit.dart
Normal file
@ -0,0 +1,32 @@
|
||||
import 'package:flame/sprite.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'klondike_game.dart';
|
||||
|
||||
@immutable
|
||||
class Suit {
|
||||
factory Suit.fromInt(int index) {
|
||||
assert(
|
||||
index >= 0 && index <= 3,
|
||||
'index is outside of the bounds of what a suit can be',
|
||||
);
|
||||
return _singletons[index];
|
||||
}
|
||||
|
||||
Suit._(this.value, this.label, double x, double y, double w, double h)
|
||||
: sprite = klondikeSprite(x, y, w, h);
|
||||
|
||||
final int value;
|
||||
final String label;
|
||||
final Sprite sprite;
|
||||
|
||||
static late final List<Suit> _singletons = [
|
||||
Suit._(0, '♥', 1176, 17, 172, 183),
|
||||
Suit._(1, '♦', 973, 14, 177, 182),
|
||||
Suit._(2, '♣', 974, 226, 184, 172),
|
||||
Suit._(3, '♠', 1178, 220, 176, 182),
|
||||
];
|
||||
|
||||
/// Hearts and Diamonds are red, while Clubs and Spades are black.
|
||||
bool get isRed => value <= 1;
|
||||
bool get isBlack => value >= 2;
|
||||
}
|
||||
@ -16,5 +16,6 @@ with the [Dart] programming language.
|
||||
1. Preparation <step1.md>
|
||||
2. Scaffolding <step2.md>
|
||||
3. Cards <step3.md>
|
||||
4. Gameplay <step4.md>
|
||||
[To be continued]... <tbc.md>
|
||||
```
|
||||
|
||||
933
doc/tutorials/klondike/step4.md
Normal file
933
doc/tutorials/klondike/step4.md
Normal file
@ -0,0 +1,933 @@
|
||||
# Gameplay
|
||||
|
||||
In this chapter we will be implementing the core of Klondike's gameplay: how the cards move between
|
||||
the stock and the waste, the piles and the foundations.
|
||||
|
||||
Before we begin though, let's clean up all those cards that we left scattered across the table in
|
||||
the previous chapter. Open the `KlondikeGame` class and erase the loop at the bottom of `onLoad()`
|
||||
that was adding 28 cards onto the table.
|
||||
|
||||
|
||||
## The piles
|
||||
|
||||
Another small refactoring that we need to do is to rename our components: `Stock` ⇒ `StockPile`,
|
||||
`Waste` ⇒ `WastePile`, `Foundation` ⇒ `FoundationPile`, and `Pile` ⇒ `TableauPile`. This is
|
||||
because these components have some common features in how they handle interactions with the cards,
|
||||
and it would be convenient to have all of them implement a common API. We will call the interface
|
||||
that they will all be implementing the `Pile` class.
|
||||
|
||||
```{note}
|
||||
Refactors and changes in architecture happen during development all the time: it's almost impossible
|
||||
to get the structure right on the first try. Do not be anxious about changing code that you have
|
||||
written in the past: it is a good habit to have.
|
||||
```
|
||||
|
||||
After such a rename, we can begin implementing each of these components.
|
||||
|
||||
|
||||
### Stock pile
|
||||
|
||||
The **stock** is a place in the top-left corner of the playing field which holds the cards that are
|
||||
not currently in play. We will need to build the following functionality for this component:
|
||||
|
||||
1. Ability to hold cards that are not currently in play, face down;
|
||||
2. Tapping the stock should reveal top 3 cards and move them to the **waste** pile;
|
||||
3. When the cards run out, there should be a visual indicating that this is the stock pile;
|
||||
4. When the cards run out, tapping the empty stock should move all the cards from the waste pile
|
||||
into the stock, turning them face down.
|
||||
|
||||
The first question that needs to be decided here is this: who is going to own the `Card` components?
|
||||
Previously we have been adding them directly to the game field, but now wouldn't it be better to
|
||||
say that the cards belong to the `Stock` component, or to the waste, or piles, or foundations? While
|
||||
this approach is tempting, I believe it would make our life more complicated as we need to move a
|
||||
card from one place to another.
|
||||
|
||||
So, I decided to stick with my first approach: the `Card` components are owned directly by the
|
||||
`KlondikeGame` itself, whereas the `StockPile` and other piles are merely aware of which cards are
|
||||
currently placed there.
|
||||
|
||||
Having this in mind, let's start implementing the `StockPile` component:
|
||||
```dart
|
||||
class StockPile extends PositionComponent {
|
||||
StockPile({super.position}) : super(size: KlondikeGame.cardSize);
|
||||
|
||||
/// Which cards are currently placed onto this pile. The first card in the
|
||||
/// list is at the bottom, the last card is on top.
|
||||
final List<Card> _cards = [];
|
||||
|
||||
void acquireCard(Card card) {
|
||||
assert(!card.isFaceUp);
|
||||
card.position = position;
|
||||
card.priority = _cards.length;
|
||||
_cards.add(card);
|
||||
}
|
||||
}
|
||||
```
|
||||
Here the `acquireCard()` method stores the provided card into the internal list `_cards`; it also
|
||||
moves that card to the `StockPile`'s position and adjusts the cards priority so that they are
|
||||
displayed in the right order. However, this method does not mount the card as a child of the
|
||||
`StockPile` component -- it remains belonging to the top-level game.
|
||||
|
||||
Speaking of the game class, let's open the `KlondikeGame` and add the following lines to create a
|
||||
full deck of 52 cards and put them onto the stock pile (this should be added at the end of the
|
||||
`onLoad` method):
|
||||
```dart
|
||||
final cards = [
|
||||
for (var rank = 1; rank <= 13; rank++)
|
||||
for (var suit = 0; suit < 4; suit++)
|
||||
Card(rank, suit)
|
||||
];
|
||||
world.addAll(cards);
|
||||
cards.forEach(stock.acquireCard);
|
||||
```
|
||||
|
||||
This concludes the first step of our short plan at the beginning of this section. For the second
|
||||
step, though, we need to have a waste pile -- so let's make a quick detour and implement the
|
||||
`WastePile` class.
|
||||
|
||||
|
||||
### Waste pile
|
||||
|
||||
The **waste** is a pile next to the stock. During the course of the game we will be taking the cards
|
||||
from the top of the stock pile and putting them into the waste. The functionality of this class is
|
||||
quite simple: it holds a certain number of cards face up, fanning out the top 3.
|
||||
|
||||
Let's start implementing the `WastePile` class same way as we did with the `StockPile` class, only
|
||||
now the cards are expected to be face up:
|
||||
```dart
|
||||
class WastePile extends PositionComponent {
|
||||
WastePile({super.position}) : super(size: KlondikeGame.cardSize);
|
||||
|
||||
final List<Card> _cards = [];
|
||||
|
||||
void acquireCard(Card card) {
|
||||
assert(card.isFaceUp);
|
||||
card.position = position;
|
||||
card.priority = _cards.length;
|
||||
_cards.add(card);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
So far, this puts all cards into a single neat pile, whereas we wanted a fan-out of top three. So,
|
||||
let's add a dedicated method `_fanOutTopCards()` for this, which we will call at the end of each
|
||||
`acquireCard()`:
|
||||
```dart
|
||||
void _fanOutTopCards() {
|
||||
final n = _cards.length;
|
||||
for (var i = 0; i < n; i++) {
|
||||
_cards[i].position = position;
|
||||
}
|
||||
if (n == 2) {
|
||||
_cards[1].position.add(_fanOffset);
|
||||
} else if (n >= 3) {
|
||||
_cards[n - 2].position.add(_fanOffset);
|
||||
_cards[n - 1].position.addScaled(_fanOffset, 2);
|
||||
}
|
||||
}
|
||||
```
|
||||
The `_fanOffset` variable here helps determine the shift between cards in the fan, which I decided
|
||||
to be about 20% of the card's width:
|
||||
```dart
|
||||
final Vector2 _fanOffset = Vector2(KlondikeGame.cardWidth * 0.2, 0);
|
||||
```
|
||||
|
||||
Now that the waste pile is ready, let's get back to the `StockPile`.
|
||||
|
||||
|
||||
### Stock pile -- tap to deal cards
|
||||
|
||||
The second item on our todo list is the first interactive functionality in the game: tap the stock
|
||||
pile to deal 3 cards onto the waste.
|
||||
|
||||
Adding tap functionality to the components in Flame is quite simple: first, we add the mixin
|
||||
`HasTappableComponents` to our top-level game class:
|
||||
```dart
|
||||
class KlondikeGame extends FlameGame with HasTappableComponents { ... }
|
||||
```
|
||||
And second, we add the mixin `TapCallbacks` to the component that we want to be tappable:
|
||||
```dart
|
||||
class StockPile extends PositionComponent with TapCallbacks { ... }
|
||||
```
|
||||
Oh, and we also need to say what we want to happen when the tap occurs. Here we want the top 3 cards
|
||||
to be turned face up and moved to the waste pile. So, add the following method to the `StockPile`
|
||||
class:
|
||||
```dart
|
||||
@override
|
||||
void onTapUp(TapUpEvent event) {
|
||||
final wastePile = parent!.firstChild<WastePile>()!;
|
||||
for (var i = 0; i < 3; i++) {
|
||||
if (_cards.isNotEmpty) {
|
||||
final card = _cards.removeLast();
|
||||
card.flip();
|
||||
wastePile.acquireCard(card);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You have probably noticed that the cards move from one pile to another immediately, which looks very
|
||||
unnatural. However, this is how it is going to be for now -- we will defer making the game more
|
||||
smooth till the next chapter of the tutorial.
|
||||
|
||||
Also, the cards are organized in a well-defined order right now, starting from Kings and ending with
|
||||
Aces. This doesn't make a very exciting gameplay though, so add line
|
||||
```dart
|
||||
cards.shuffle();
|
||||
```
|
||||
in the `KlondikeGame` class right after the list of cards is created.
|
||||
|
||||
|
||||
```{seealso}
|
||||
For more information about tap functionality, see [](../../flame/inputs/tap-events.md).
|
||||
```
|
||||
|
||||
|
||||
### Stock pile -- visual representation
|
||||
|
||||
Currently, when the stock pile has no cards, it simply shows an empty space -- there is no visual
|
||||
cue that this is where the stock is. Such cue is needed, though, because we want the user to be
|
||||
able to click the stock pile when it is empty in order to move all the cards from the waste back to
|
||||
the stock so that they can be dealt again.
|
||||
|
||||
In our case, the empty stock pile will have a card-like border, and a circle in the middle:
|
||||
```dart
|
||||
@override
|
||||
void render(Canvas canvas) {
|
||||
canvas.drawRRect(KlondikeGame.cardRRect, _borderPaint);
|
||||
canvas.drawCircle(
|
||||
Offset(width / 2, height / 2),
|
||||
KlondikeGame.cardWidth * 0.3,
|
||||
_circlePaint,
|
||||
);
|
||||
}
|
||||
```
|
||||
where the paints are defined as
|
||||
```dart
|
||||
final _borderPaint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 10
|
||||
..color = const Color(0xFF3F5B5D);
|
||||
final _circlePaint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 100
|
||||
..color = const Color(0x883F5B5D);
|
||||
```
|
||||
and the `cardRRect` in the `KlondikeGame` class as
|
||||
```dart
|
||||
static final cardRRect = RRect.fromRectAndRadius(
|
||||
const Rect.fromLTWH(0, 0, cardWidth, cardHeight),
|
||||
const Radius.circular(cardRadius),
|
||||
);
|
||||
```
|
||||
|
||||
Now when you click through the stock pile till the end, you should be able to see the placeholder
|
||||
for the stock cards.
|
||||
|
||||
|
||||
### Stock pile -- refill from the waste
|
||||
|
||||
The last piece of functionality to add, is to move the cards back from the waste pile into the stock
|
||||
pile when the user taps on an empty stock. To implement this, we will modify the `onTapUp()` method
|
||||
like so:
|
||||
```dart
|
||||
@override
|
||||
void onTapUp(TapUpEvent event) {
|
||||
final wastePile = parent!.firstChild<WastePile>()!;
|
||||
if (_cards.isEmpty) {
|
||||
wastePile.removeAllCards().reversed.forEach((card) {
|
||||
card.flip();
|
||||
acquireCard(card);
|
||||
});
|
||||
} else {
|
||||
for (var i = 0; i < 3; i++) {
|
||||
if (_cards.isNotEmpty) {
|
||||
final card = _cards.removeLast();
|
||||
card.flip();
|
||||
wastePile.acquireCard(card);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
If you're curious why we needed to reverse the list of cards removed from the waste pile, then it is
|
||||
because we want to simulate the entire waste pile being turned over at once, and not each card being
|
||||
flipped one by one in their places. You can check that this is working as intended by verifying that
|
||||
on each subsequent run through the stock pile, the cards are dealt in the same order as they were
|
||||
dealt in the first run.
|
||||
|
||||
The method `WastePile.removeAllCards()` still needs to be implemented though:
|
||||
```dart
|
||||
List<Card> removeAllCards() {
|
||||
final cards = _cards.toList();
|
||||
_cards.clear();
|
||||
return cards;
|
||||
}
|
||||
```
|
||||
|
||||
This pretty much concludes the `StockPile` functionality, and we already implemented the `WastePile`
|
||||
-- so the only two components remaining are the `FoundationPile` and the `TableauPile`. We'll start
|
||||
with the first one because it looks simpler.
|
||||
|
||||
|
||||
### Foundation piles
|
||||
|
||||
The **foundation** piles are the four piles in the top right corner of the game. This is where we
|
||||
will be building the ordered runs of cards from Ace to King. The functionality of this class is
|
||||
similar to the `StockPile` and the `WastePile`: it has to be able to hold cards face up, and there
|
||||
has to be some visual to show where the foundation is when there are no cards there.
|
||||
|
||||
First, let's implement the card-holding logic:
|
||||
```dart
|
||||
class FoundationPile extends PositionComponent {
|
||||
FoundationPile({super.position}) : super(size: KlondikeGame.cardSize);
|
||||
|
||||
final List<Card> _cards = [];
|
||||
|
||||
void acquireCard(Card card) {
|
||||
assert(card.isFaceUp);
|
||||
card.position = position;
|
||||
card.priority = _cards.length;
|
||||
_cards.add(card);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For visual representation of a foundation, I've decided to make a large icon of that foundation's
|
||||
suit, in grey color. Which means we'd need to update the definition of the class to include the
|
||||
suit information:
|
||||
```dart
|
||||
class FoundationPile extends PositionComponent {
|
||||
FoundationPile(int intSuit, {super.position})
|
||||
: suit = Suit.fromInt(intSuit),
|
||||
super(size: KlondikeGame.cardSize);
|
||||
|
||||
final Suit suit;
|
||||
...
|
||||
}
|
||||
```
|
||||
The code in the `KlondikeGame` class that generates the foundations will have to be adjusted
|
||||
accordingly in order to pass the suit index to each foundation.
|
||||
|
||||
Now, the rendering code for the foundation pile will look like this:
|
||||
```dart
|
||||
@override
|
||||
void render(Canvas canvas) {
|
||||
canvas.drawRRect(KlondikeGame.cardRRect, _borderPaint);
|
||||
suit.sprite.render(
|
||||
canvas,
|
||||
position: size / 2,
|
||||
anchor: Anchor.center,
|
||||
size: Vector2.all(KlondikeGame.cardWidth * 0.6),
|
||||
overridePaint: _suitPaint,
|
||||
);
|
||||
}
|
||||
```
|
||||
Here we need to have two paint objects, one for the border and one for the suits:
|
||||
```dart
|
||||
final _borderPaint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 10
|
||||
..color = const Color(0x50ffffff);
|
||||
late final _suitPaint = Paint()
|
||||
..color = suit.isRed? const Color(0x3a000000) : const Color(0x64000000)
|
||||
..blendMode = BlendMode.luminosity;
|
||||
```
|
||||
The suit paint uses `BlendMode.luminosity` in order to convert the regular yellow/blue colors of
|
||||
the suit sprites into greyscale. The "color" of the paint is different depending whether the suit
|
||||
is red or black because the original luminosity of those sprites is different. Therefore, I had to
|
||||
pick two different colors in order to make them look the same in greyscale.
|
||||
|
||||
|
||||
### Tableau Piles
|
||||
|
||||
The last piece of the game to be implemented is the `TableauPile` component. There are seven of
|
||||
these piles in total, and they are where the majority of the game play is happening.
|
||||
|
||||
The `TableauPile` also needs a visual representation, in order to indicate that it's a place where
|
||||
a King can be placed when it is empty. I believe it could be just an empty frame, and that should
|
||||
be sufficient:
|
||||
```dart
|
||||
class TableauPile extends PositionComponent {
|
||||
TableauPile({super.position}) : super(size: KlondikeGame.cardSize);
|
||||
|
||||
final _borderPaint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 10
|
||||
..color = const Color(0x50ffffff);
|
||||
|
||||
@override
|
||||
void render(Canvas canvas) {
|
||||
canvas.drawRRect(KlondikeGame.cardRRect, _borderPaint);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Oh, and the class will need to be able hold the cards too, obviously. Here, some of the cards will
|
||||
be face down, while others will be face up. Also we will need a small amount of vertical fanning,
|
||||
similar to how we did it for the `WastePile` component:
|
||||
```dart
|
||||
/// Which cards are currently placed onto this pile.
|
||||
final List<Card> _cards = [];
|
||||
final Vector2 _fanOffset = Vector2(0, KlondikeGame.cardHeight * 0.05);
|
||||
|
||||
void acquireCard(Card card) {
|
||||
if (_cards.isEmpty) {
|
||||
card.position = position;
|
||||
} else {
|
||||
card.position = _cards.last.position + _fanOffset;
|
||||
}
|
||||
card.priority = _cards.length;
|
||||
_cards.add(card);
|
||||
}
|
||||
```
|
||||
|
||||
All that remains now is to head over to the `KlondikeGame` and make sure that the cards are dealt
|
||||
into the `TableauPile`s at the beginning of the game. Modify the code at the end of the `onLoad()`
|
||||
method so that it looks like this:
|
||||
```dart
|
||||
Future<void> onLoad() async {
|
||||
...
|
||||
|
||||
final cards = [
|
||||
for (var rank = 1; rank <= 13; rank++)
|
||||
for (var suit = 0; suit < 4; suit++)
|
||||
Card(rank, suit)
|
||||
];
|
||||
cards.shuffle();
|
||||
world.addAll(cards);
|
||||
|
||||
for (var i = 0; i < 7; i++) {
|
||||
for (var j = i; j < 7; j++) {
|
||||
piles[j].acquireCard(cards.removeLast());
|
||||
}
|
||||
piles[i].flipTopCard();
|
||||
}
|
||||
cards.forEach(stock.acquireCard);
|
||||
}
|
||||
```
|
||||
Note how we remove the cards from the deck and place them into `TableauPile`s one by one, and only
|
||||
after that we put the remaining cards into the stock. Also, the `flipTopCard` method in the
|
||||
`TableauPile` class is as trivial as it sounds:
|
||||
```dart
|
||||
void flipTopCard() {
|
||||
assert(_cards.last.isFaceDown);
|
||||
_cards.last.flip();
|
||||
}
|
||||
```
|
||||
|
||||
If you run the game at this point, it would be nicely set up and look as if it was ready to play.
|
||||
Except that we can't move the cards yet, which is kinda a deal-breaker here. So without further ado,
|
||||
presenting you the next section:
|
||||
|
||||
|
||||
## Moving the cards
|
||||
|
||||
Moving the cards is a somewhat more complicated topic than what we have had so far. We will split
|
||||
it into several smaller steps:
|
||||
1. Simple movement: grab a card and move it around.
|
||||
2. Ensure that the user can only move the cards that they are allowed to.
|
||||
3. Check that the cards are dropped at proper destinations.
|
||||
4. Drag a run of cards.
|
||||
|
||||
|
||||
### 1. Simple movement
|
||||
|
||||
So, we want to be able to drag the cards on the screen. This is almost as simple as making the
|
||||
`StockPile` tappable: first, we add the `HasDraggableComponents` mixin to our game class:
|
||||
```dart
|
||||
class KlondikeGame extends FlameGame
|
||||
with HasTappableComponents, HasDraggableComponents {
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Now, head over into the `Card` class and add the `DragCallbacks` mixin:
|
||||
```dart
|
||||
class Card extends PositionComponent with DragCallbacks {
|
||||
}
|
||||
```
|
||||
|
||||
The next step is to implement the actual drag event callbacks: `onDragStart`, `onDragUpdate`, and
|
||||
`onDragEnd`.
|
||||
|
||||
When the drag gesture is initiated, the first thing that we need to do is to raise the priority of
|
||||
the card, so that it is rendered above all others. Without this, the card would be occasionally
|
||||
"sliding beneath" other cards, which would look most unnatural:
|
||||
```dart
|
||||
@override
|
||||
void onDragStart(DragStartEvent event) {
|
||||
priority = 100;
|
||||
}
|
||||
```
|
||||
|
||||
During the drag, the `onDragUpdate` event will be called continuously. Using this callback we will
|
||||
be updating the position of the card so that it follows the movement of the finger (or the mouse).
|
||||
The `event` object passed to this callback contains the most recent coordinate of the point of
|
||||
touch, and also the `delta` property -- which is the displacement vector since the previous call of
|
||||
`onDragUpdate`. The only problem is that this delta is measured in screen pixels, whereas we want
|
||||
it to be in game world units. The conversion between the two is given by the camera zoom level, so
|
||||
we will add an extra method to determine the zoom level:
|
||||
```dart
|
||||
@override
|
||||
void onDragUpdate(DragUpdateEvent event) {
|
||||
final cameraZoom = (findGame()! as FlameGame)
|
||||
.firstChild<CameraComponent>()!
|
||||
.viewfinder
|
||||
.zoom;
|
||||
position += event.delta / cameraZoom;
|
||||
}
|
||||
```
|
||||
|
||||
So far this allows you to grab any card and drag it anywhere around the table. What we want,
|
||||
however, is to be able to restrict where the card is allowed or not allowed to go. This is where
|
||||
the core of the logic of the game begins.
|
||||
|
||||
|
||||
### 2. Move only allowed cards
|
||||
|
||||
The first restriction that we impose is that the user should only be able to drag the cards that we
|
||||
allow, which include: (1) the top card of a waste pile, (2) the top card of a foundation pile, and
|
||||
(3) any face-up card in a tableau pile.
|
||||
|
||||
Thus, in order to determine whether a card can be moved or not, we need to know which pile it
|
||||
currently belongs to. There could be several ways that we go about it, but seemingly the most
|
||||
straightforward is to let every card keep a reference to the pile in which it currently resides.
|
||||
|
||||
So, let's start by defining the abstract interface `Pile` that all our existing piles will be
|
||||
implementing:
|
||||
```dart
|
||||
abstract class Pile {
|
||||
bool canMoveCard(Card card);
|
||||
}
|
||||
```
|
||||
We will expand this class further later, but for now let's make sure that each of the classes
|
||||
`StockPile`, `WastePile`, `FoundationPile`, and `TableauPile` are marked as implementing this
|
||||
interface:
|
||||
```dart
|
||||
class StockPile extends PositionComponent with TapCallbacks implements Pile {
|
||||
...
|
||||
@override
|
||||
bool canMoveCard(Card card) => false;
|
||||
}
|
||||
|
||||
class WastePile extends PositionComponent implements Pile {
|
||||
...
|
||||
@override
|
||||
bool canMoveCard(Card card) => _cards.isNotEmpty && card == _cards.last;
|
||||
}
|
||||
|
||||
class FoundationPile extends PositionComponent implements Pile {
|
||||
...
|
||||
@override
|
||||
bool canMoveCard(Card card) => _cards.isNotEmpty && card == _cards.last;
|
||||
}
|
||||
|
||||
class TableauPile extends PositionComponent implements Pile {
|
||||
...
|
||||
@override
|
||||
bool canMoveCard(Card card) => _cards.isNotEmpty && card == _cards.last;
|
||||
}
|
||||
```
|
||||
|
||||
We also wanted to let every `Card` know which pile it is currently in. For this, add the field
|
||||
`Pile? pile` into the `Card` class, and make sure to set it in each pile's `acquireCard()` method,
|
||||
like so:
|
||||
```dart
|
||||
void acquireCard(Card card) {
|
||||
...
|
||||
card.pile = this;
|
||||
}
|
||||
```
|
||||
|
||||
Now we can put this new functionality to use: go into the `Card.onDragStart()` method and modify
|
||||
it so that it would check whether the card is allowed to be moved before starting the drag:
|
||||
```dart
|
||||
void onDragStart(DragStartEvent event) {
|
||||
if (pile?.canMoveCard(this) ?? false) {
|
||||
_isDragging = true;
|
||||
priority = 100;
|
||||
}
|
||||
}
|
||||
```
|
||||
We have also added the boolean `_isDragging` variable here: make sure to define it, and then to
|
||||
check this flag in the `onDragUpdate()` method, and to set it back to false in the `onDragEnd()`:
|
||||
```dart
|
||||
@override
|
||||
void onDragUpdate(DragUpdateEvent event) {
|
||||
if (!_isDragging) {
|
||||
return;
|
||||
}
|
||||
final cameraZoom = (findGame()! as FlameGame)
|
||||
.firstChild<CameraComponent>()!
|
||||
.viewfinder
|
||||
.zoom;
|
||||
position += event.delta / cameraZoom;
|
||||
}
|
||||
|
||||
@override
|
||||
void onDragEnd(DragEndEvent event) {
|
||||
_isDragging = false;
|
||||
}
|
||||
```
|
||||
|
||||
Now the only the proper cards can be dragged, but they still drop at random positions on the table,
|
||||
so let's work on that.
|
||||
|
||||
|
||||
### 3. Dropping the cards at proper locations
|
||||
|
||||
At this point what we want to do is to figure out where the dragged card is being dropped. More
|
||||
specifically, we want to know into which _pile_ it is being dropped. This can be achieved by using
|
||||
the `componentsAtPoint()` API, which allows you to query which components are located at a given
|
||||
position on the screen.
|
||||
|
||||
Thus, my first attempt at revising the `onDragEnd` callback looks like this:
|
||||
```dart
|
||||
@override
|
||||
void onDragEnd(DragEndEvent event) {
|
||||
if (!_isDragging) {
|
||||
return;
|
||||
}
|
||||
_isDragging = false;
|
||||
final dropPiles = parent!
|
||||
.componentsAtPoint(position + size / 2)
|
||||
.whereType<Pile>()
|
||||
.toList();
|
||||
if (dropPiles.isNotEmpty) {
|
||||
// if (card is allowed to be dropped into this pile) {
|
||||
// remove the card from the current pile
|
||||
// add the card into the new pile
|
||||
// }
|
||||
}
|
||||
// return the card to where it was originally
|
||||
}
|
||||
```
|
||||
This still contains several placeholders for the functionality that still needs to be implemented,
|
||||
so let's get to it.
|
||||
|
||||
First piece of the puzzle is the "is card allowed to be dropped here?" check. To implement this,
|
||||
first head over into the `Pile` class and add the `canAcceptCard()` abstract method:
|
||||
```dart
|
||||
abstract class Pile {
|
||||
...
|
||||
bool canAcceptCard(Card card);
|
||||
}
|
||||
```
|
||||
Obviously this now needs to be implemented for every `Pile` subclass, so let's get to it:
|
||||
```dart
|
||||
class FoundationPile ... implements Pile {
|
||||
...
|
||||
@override
|
||||
bool canAcceptCard(Card card) {
|
||||
final topCardRank = _cards.isEmpty? 0 : _cards.last.rank.value;
|
||||
return card.suit == suit && card.rank.value == topCardRank + 1;
|
||||
}
|
||||
}
|
||||
|
||||
class TableauPile ... implements Pile {
|
||||
...
|
||||
@override
|
||||
bool canAcceptCard(Card card) {
|
||||
if (_cards.isEmpty) {
|
||||
return card.rank.value == 13;
|
||||
} else {
|
||||
final topCard = _cards.last;
|
||||
return card.suit.isRed == !topCard.suit.isRed &&
|
||||
card.rank.value == topCard.rank.value - 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
(for the `StockPile` and the `WastePile` the method should just return false, since no cards should
|
||||
be dropped there).
|
||||
|
||||
Alright, next part is the "remove the card from its current pile". Once again, let's head over to
|
||||
the `Pile` class and add the `removeCard()` abstract method:
|
||||
```dart
|
||||
abstract class Pile {
|
||||
...
|
||||
void removeCard(Card card);
|
||||
}
|
||||
```
|
||||
Then we need to re-visit all four pile subclasses and implement this method:
|
||||
```dart
|
||||
class StockPile ... implements Pile {
|
||||
...
|
||||
@override
|
||||
void removeCard(Card card) => throw StateError('cannot remove cards from here');
|
||||
}
|
||||
|
||||
class WastePile ... implements Pile {
|
||||
...
|
||||
@override
|
||||
void removeCard(Card card) {
|
||||
assert(canMoveCard(card));
|
||||
_cards.removeLast();
|
||||
_fanOutTopCards();
|
||||
}
|
||||
}
|
||||
|
||||
class FoundationPile ... implements Pile {
|
||||
...
|
||||
@override
|
||||
void removeCard(Card card) {
|
||||
assert(canMoveCard(card));
|
||||
_cards.removeLast();
|
||||
}
|
||||
}
|
||||
|
||||
class TableauPile ... implements Pile {
|
||||
...
|
||||
@override
|
||||
void removeCard(Card card) {
|
||||
assert(_cards.contains(card) && card.isFaceUp);
|
||||
final index = _cards.indexOf(card);
|
||||
_cards.removeRange(index, _cards.length);
|
||||
if (_cards.isNotEmpty && _cards.last.isFaceDown) {
|
||||
flipTopCard();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The next action in our pseudo-code is to "add the card to the new pile". But this one we have
|
||||
already implemented: it's the `acquireCard()` method. So all we need is to declare it in the `Pile`
|
||||
interface:
|
||||
```dart
|
||||
abstract class Pile {
|
||||
...
|
||||
void acquireCard(Card card);
|
||||
}
|
||||
```
|
||||
|
||||
The last piece that's missing is "return the card to where it was". You can probably guess how we
|
||||
are going to go about this one: add the `returnCard()` method into the `Pile` interface, and then
|
||||
implement this method in all four pile subclasses:
|
||||
```dart
|
||||
class StockPile ... implements Pile {
|
||||
...
|
||||
@override
|
||||
void returnCard(Card card) => throw StateError('cannot remove cards from here');
|
||||
}
|
||||
|
||||
class WastePile ... implements Pile {
|
||||
...
|
||||
@override
|
||||
void returnCard(Card card) {
|
||||
card.priority = _cards.indexOf(card);
|
||||
_fanOutTopCards();
|
||||
}
|
||||
}
|
||||
|
||||
class FoundationPile ... implements Pile {
|
||||
...
|
||||
@override
|
||||
void returnCard(Card card) {
|
||||
card.position = position;
|
||||
card.priority = _cards.indexOf(card);
|
||||
}
|
||||
}
|
||||
|
||||
class TableauPile ... implements Pile {
|
||||
...
|
||||
@override
|
||||
void returnCard(Card card) {
|
||||
final index = _cards.indexOf(card);
|
||||
card.position =
|
||||
index == 0 ? position : _cards[index - 1].position + _fanOffset;
|
||||
card.priority = index;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Now, putting this all together, the `Card`'s `onDragEnd` method will look like this:
|
||||
```dart
|
||||
@override
|
||||
void onDragEnd(DragEndEvent event) {
|
||||
if (!_isDragging) {
|
||||
return;
|
||||
}
|
||||
_isDragging = false;
|
||||
final dropPiles = parent!
|
||||
.componentsAtPoint(position + size / 2)
|
||||
.whereType<Pile>()
|
||||
.toList();
|
||||
if (dropPiles.isNotEmpty) {
|
||||
if (dropPiles.first.canAcceptCard(this)) {
|
||||
pile!.removeCard(this);
|
||||
dropPiles.first.acquireCard(this);
|
||||
return;
|
||||
}
|
||||
}
|
||||
pile!.returnCard(this);
|
||||
}
|
||||
```
|
||||
|
||||
Ok, that was quite a lot of work -- but if you run the game now, you'd be able to move the cards
|
||||
properly from one pile to another, and they will never go where they are not supposed to go. The
|
||||
only thing that remains is to be able to move multiple cards at once between tableau piles. So take
|
||||
a short break, and then on to the next section!
|
||||
|
||||
|
||||
### 4. Moving a run of cards
|
||||
|
||||
In this section we will be implementing the necessary changes to allow us to move small stacks of
|
||||
cards between the tableau piles. Before we begin, though, we need to make a small fix first.
|
||||
|
||||
You have probably noticed when running the game in the previous section that the cards in the
|
||||
tableau piles clamp too closely together. That is, they are at the correct distance when they face
|
||||
down, but they should be at a larger distance when they face up, which is not currently the case.
|
||||
This makes it really difficult to see which cards are available for dragging.
|
||||
|
||||
So, let's head over into the `TableauPile` class and create a new method `layOutCards()`, whose job
|
||||
would be to ensure that all cards currently in the pile have the right positions:
|
||||
```dart
|
||||
final Vector2 _fanOffset1 = Vector2(0, KlondikeGame.cardHeight * 0.05);
|
||||
final Vector2 _fanOffset2 = Vector2(0, KlondikeGame.cardHeight * 0.20);
|
||||
|
||||
void layOutCards() {
|
||||
if (_cards.isEmpty) {
|
||||
return;
|
||||
}
|
||||
_cards[0].position.setFrom(position);
|
||||
for (var i = 1; i < _cards.length; i++) {
|
||||
_cards[i].position
|
||||
..setFrom(_cards[i - 1].position)
|
||||
..add(_cards[i - 1].isFaceDown ? _fanOffset1 : _fanOffset2);
|
||||
}
|
||||
}
|
||||
```
|
||||
Make sure to call this method at the end of `removeCard()`, `returnCard()`, and `acquireCard()` --
|
||||
replacing any current logic that handles card positioning.
|
||||
|
||||
Another problem that you may have noticed is that for taller card stacks it becomes hard to place a
|
||||
card there. This is because our logic for determining in which pile the card is being dropped checks
|
||||
whether the center of the card is inside any of the `TableauPile` components -- but those components
|
||||
have only the size of a single card! To fix this inconsistency, all we need is to declare that the
|
||||
height of the tableau pile is at least as tall as all the cards in it, or even higher. Add this line
|
||||
at the end of the `layOutCards()` method:
|
||||
```dart
|
||||
height = KlondikeGame.cardHeight * 1.5 + _cards.last.y - _cards.first.y;
|
||||
```
|
||||
The factor `1.5` here adds a little bit extra space at the bottom of each pile. You can temporarily
|
||||
turn the debug mode on to see the hitboxes.
|
||||
|
||||
Ok, let's get to our main topic: how to move a stack of cards at once.
|
||||
|
||||
First thing that we're going to add is the list of `attachedCards` for every card. This list will
|
||||
be non-empty only when the card is being dragged while having other cards on top. Add the following
|
||||
declaration to the `Card` class:
|
||||
```dart
|
||||
final List<Card> attachedCards = [];
|
||||
```
|
||||
|
||||
Now, in order to create this list in `onDragStart`, we need to query the `TableauPile` for the list
|
||||
of cards that are on top of the given card. Let's add such a method into the `TableauPile` class:
|
||||
```dart
|
||||
List<Card> cardsOnTop(Card card) {
|
||||
assert(card.isFaceUp && _cards.contains(card));
|
||||
final index = _cards.indexOf(card);
|
||||
return _cards.getRange(index + 1, _cards.length).toList();
|
||||
}
|
||||
```
|
||||
While we are in the `TableauPile` class, let's also update the `canMoveCard()` method to allow
|
||||
dragging cards that are not necessarily on top:
|
||||
```dart
|
||||
@override
|
||||
bool canMoveCard(Card card) => card.isFaceUp;
|
||||
```
|
||||
|
||||
Heading back into the `Card` class, we can use this method in order to populate the list of
|
||||
`attachedCards` when the card starts to move:
|
||||
```dart
|
||||
@override
|
||||
void onDragStart(DragStartEvent event) {
|
||||
if (pile?.canMoveCard(this) ?? false) {
|
||||
_isDragging = true;
|
||||
priority = 100;
|
||||
if (pile is TableauPile) {
|
||||
attachedCards.clear();
|
||||
final extraCards = (pile! as TableauPile).cardsOnTop(this);
|
||||
for (final card in extraCards) {
|
||||
card.priority = attachedCards.length + 101;
|
||||
attachedCards.add(card);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Now all we need to do is to make sure that the attached cards are also moved with the main card in
|
||||
the `onDragUpdate` method:
|
||||
```dart
|
||||
@override
|
||||
void onDragUpdate(DragUpdateEvent event) {
|
||||
if (!_isDragging) {
|
||||
return;
|
||||
}
|
||||
final cameraZoom = (findGame()! as FlameGame)
|
||||
.firstChild<CameraComponent>()!
|
||||
.viewfinder
|
||||
.zoom;
|
||||
final delta = event.delta / cameraZoom;
|
||||
position.add(delta);
|
||||
attachedCards.forEach((card) => card.position.add(delta));
|
||||
}
|
||||
```
|
||||
|
||||
This does the trick, almost. All that remains is to fix any loose ends. For example, we don't want
|
||||
to let the user drop a stack of cards onto a foundation pile, so let's head over into the
|
||||
`FoundationPile` class and modify the `canAcceptCard()` method accordingly:
|
||||
```dart
|
||||
@override
|
||||
bool canAcceptCard(Card card) {
|
||||
final topCardRank = _cards.isEmpty ? 0 : _cards.last.rank.value;
|
||||
return card.suit == suit &&
|
||||
card.rank.value == topCardRank + 1 &&
|
||||
card.attachedCards.isEmpty;
|
||||
}
|
||||
```
|
||||
|
||||
Secondly, we need to properly take care of the stack of card as it is being dropped into a tableau
|
||||
pile. So, go back into the `Card` class and update its `onDragEnd()` method to also move the
|
||||
attached cards into the pile, and the same when it comes to returning the cards into the old pile:
|
||||
```dart
|
||||
@override
|
||||
void onDragEnd(DragEndEvent event) {
|
||||
if (!_isDragging) {
|
||||
return;
|
||||
}
|
||||
_isDragging = false;
|
||||
final dropPiles = parent!
|
||||
.componentsAtPoint(position + size / 2)
|
||||
.whereType<Pile>()
|
||||
.toList();
|
||||
if (dropPiles.isNotEmpty) {
|
||||
if (dropPiles.first.canAcceptCard(this)) {
|
||||
pile!.removeCard(this);
|
||||
dropPiles.first.acquireCard(this);
|
||||
if (attachedCards.isNotEmpty) {
|
||||
attachedCards.forEach((card) => dropPiles.first.acquireCard(card));
|
||||
attachedCards.clear();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
pile!.returnCard(this);
|
||||
if (attachedCards.isNotEmpty) {
|
||||
attachedCards.forEach((card) => pile!.returnCard(card));
|
||||
attachedCards.clear();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Well, this is it! The game is now fully playable. Press the button below to see what the resulting
|
||||
code looks like, or to play it live. In the next section we will discuss how to make it more
|
||||
animated with the help of effects.
|
||||
|
||||
```{flutter-app}
|
||||
:sources: ../tutorials/klondike/app
|
||||
:page: step4
|
||||
:show: popup code
|
||||
```
|
||||
@ -37,6 +37,14 @@ abstract class PositionEvent extends Event {
|
||||
/// rendering order.
|
||||
final List<Vector2> renderingTrace = [];
|
||||
|
||||
Vector2? get parentPosition {
|
||||
if (renderingTrace.length >= 2) {
|
||||
return renderingTrace[renderingTrace.length - 2];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends the event to components of type <T> that are currently rendered at
|
||||
/// the [canvasPosition].
|
||||
@internal
|
||||
|
||||
Reference in New Issue
Block a user