docs: Klondike Step5, re-work drags and taps (#2894)

The objective of this fix is to make a tap on a card more
positive-feeling for the player and not to disappear silently if it is
interpreted as a drag.

It adds a Base Card to make an empty Stock Pile behave as a Card and use
the tap and drag logic of the `Card` class. Any attempted drag on a
Stock Pile card, including the Base Card, is now changed to a tap in
onTapCancel() and the drag is not followed. The Base Card is rendered in
outline only and does *not* take part in gameplay.

In other Piles a short drag is either treated as a tap or ignored. Only
the Waste and Tableau Piles allow such taps. As before in Klondike
Step5, they result in the tapped card moving automatically to its
Foundation Pile if it is eligible to "go out".

As before in Klondike Step4 and Step5, all piles except the Stock Pile
allow drags to start on them and they can finish on a Foundation Pile or
a Tableau Pile.

Closes #2890.

When testing, try sliding the finger or mouse slightly while making a
tap.

---------

Co-authored-by: Lukas Klingsbo <me@lukas.fyi>
This commit is contained in:
Ian Wadham
2023-12-07 07:04:52 +11:00
committed by GitHub
parent 001c870d61
commit 16a45b27a2
4 changed files with 75 additions and 13 deletions

View File

@ -17,7 +17,7 @@ import 'tableau_pile.dart';
class Card extends PositionComponent
with DragCallbacks, TapCallbacks, HasWorldReference<KlondikeWorld> {
Card(int intRank, int intSuit)
Card(int intRank, int intSuit, {this.isBaseCard = false})
: rank = Rank.fromInt(intRank),
suit = Suit.fromInt(intSuit),
super(
@ -27,6 +27,14 @@ class Card extends PositionComponent
final Rank rank;
final Suit suit;
Pile? pile;
// A Base Card is rendered in outline only and is NOT playable. It can be
// added to the base of a Pile (e.g. the Stock Pile) to allow it to handle
// taps and short drags (on an empty Pile) with the same behavior and
// tolerances as for regular cards (see KlondikeGame.dragTolerance) and using
// the same event-handling code, but with different handleTapUp() methods.
final bool isBaseCard;
bool _faceUp = false;
bool _isAnimatedFlip = false;
bool _isFaceUpView = false;
@ -55,6 +63,10 @@ class Card extends PositionComponent
@override
void render(Canvas canvas) {
if (isBaseCard) {
_renderBaseCard(canvas);
return;
}
if (_isFaceUpView) {
_renderFront(canvas);
} else {
@ -86,6 +98,10 @@ class Card extends PositionComponent
flameSprite.render(canvas, position: size / 2, anchor: Anchor.center);
}
void _renderBaseCard(Canvas canvas) {
canvas.drawRRect(cardRRect, backBorderPaint1);
}
static final Paint frontBackgroundPaint = Paint()
..color = const Color(0xff000000);
static final Paint redBorderPaint = Paint()
@ -241,16 +257,28 @@ class Card extends PositionComponent
//#region Card-Dragging
@override
void onTapCancel(TapCancelEvent event) {
if (pile is StockPile) {
_isDragging = false;
handleTapUp();
}
}
@override
void onDragStart(DragStartEvent event) {
super.onDragStart(event);
if (pile is StockPile) {
_isDragging = false;
return;
}
// Clone the position, else _whereCardStarted changes as the position does.
_whereCardStarted = position.clone();
attachedCards.clear();
if (pile?.canMoveCard(this, MoveMethod.drag) ?? false) {
_isDragging = true;
priority = 100;
// Copy each co-ord, else _whereCardStarted changes as the position does.
_whereCardStarted = Vector2(position.x, position.y);
if (pile is TableauPile) {
attachedCards.clear();
final extraCards = (pile! as TableauPile).cardsOnTop(this);
for (final card in extraCards) {
card.priority = attachedCards.length + 101;
@ -277,6 +305,22 @@ class Card extends PositionComponent
return;
}
_isDragging = false;
// If short drag, return card to Pile and treat it as having been tapped.
final shortDrag =
(position - _whereCardStarted).length < KlondikeGame.dragTolerance;
if (shortDrag && attachedCards.isEmpty) {
doMove(
_whereCardStarted,
onComplete: () {
pile!.returnCard(this);
// Card moves to its Foundation Pile next, if valid, or it stays put.
handleTapUp();
},
);
return;
}
// Find out what is under the center-point of this card when it is dropped.
final dropPiles = parent!
.componentsAtPoint(position + size / 2)
@ -284,8 +328,7 @@ class Card extends PositionComponent
.toList();
if (dropPiles.isNotEmpty) {
if (dropPiles.first.canAcceptCard(this)) {
// Found a Pile.
// Move card(s) gracefully into position on the receiving pile.
// Found a Pile: move card(s) the rest of the way onto it.
pile!.removeCard(this, MoveMethod.drag);
if (dropPiles.first is TableauPile) {
// Get TableauPile to handle positions, priorities and moves of cards.
@ -335,6 +378,12 @@ class Card extends PositionComponent
@override
void onTapUp(TapUpEvent event) {
handleTapUp();
}
void handleTapUp() {
// Can be called by onTapUp or after a very short (failed) drag-and-drop.
// We need to be more user-friendly towards taps that include a short drag.
if (pile?.canMoveCard(this, MoveMethod.tap) ?? false) {
final suitIndex = suit.value;
if (world.foundations[suitIndex].canAcceptCard(this)) {
@ -347,7 +396,7 @@ class Card extends PositionComponent
);
}
} else if (pile is StockPile) {
world.stock.onTapUp(event);
world.stock.handleTapUp(this);
}
}

View File

@ -1,7 +1,6 @@
import 'dart:ui';
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import '../klondike_game.dart';
import '../pile.dart';
@ -9,7 +8,7 @@ import 'card.dart';
import 'waste_pile.dart';
class StockPile extends PositionComponent
with TapCallbacks, HasGameReference<KlondikeGame>
with HasGameReference<KlondikeGame>
implements Pile {
StockPile({super.position}) : super(size: KlondikeGame.cardSize);
@ -31,7 +30,8 @@ class StockPile extends PositionComponent
throw StateError('cannot remove cards');
@override
void returnCard(Card card) => throw StateError('cannot remove cards');
// Card cannot be removed but could have been dragged out of place.
void returnCard(Card card) => card.priority = _cards.indexOf(card);
@override
void acquireCard(Card card) {
@ -44,10 +44,11 @@ class StockPile extends PositionComponent
//#endregion
@override
void onTapUp(TapUpEvent event) {
void handleTapUp(Card card) {
final wastePile = parent!.firstChild<WastePile>()!;
if (_cards.isEmpty) {
assert(card.isBaseCard, 'Stock Pile is empty, but no Base Card present');
card.position = position; // Force Base Card (back) into correct position.
wastePile.removeAllCards().reversed.forEach((card) {
card.flip();
acquireCard(card);

View File

@ -22,7 +22,10 @@ class KlondikeGame extends FlameGame<KlondikeWorld> {
const Radius.circular(cardRadius),
);
// Constant used when creating Random seed.
/// Constant used to decide when a short drag is treated as a TapUp event.
static const double dragTolerance = cardWidth / 5;
/// Constant used when creating Random seed.
static const int maxInt = 0xFFFFFFFE; // = (2 to the power 32) - 1
// This KlondikeGame constructor also initiates the first KlondikeWorld.

View File

@ -51,6 +51,14 @@ class KlondikeWorld extends World with HasGameReference<KlondikeGame> {
),
);
}
// Add a Base Card to the Stock Pile, above the pile and below other cards.
final baseCard = Card(1, 0, isBaseCard: true);
baseCard.position = stock.position;
baseCard.priority = -1;
baseCard.pile = stock;
stock.priority = -2;
for (var rank = 1; rank <= 13; rank++) {
for (var suit = 0; suit < 4; suit++) {
final card = Card(rank, suit);
@ -64,6 +72,7 @@ class KlondikeWorld extends World with HasGameReference<KlondikeGame> {
addAll(foundations);
addAll(tableauPiles);
addAll(cards);
add(baseCard);
playAreaSize =
Vector2(7 * cardSpaceWidth + cardGap, 4 * cardSpaceHeight + topGap);