Development/Dart&Flutter

[Flame] Klondike 3 - 카드 상호작용 구현

사이바 미도리 2024. 9. 12. 00:45

 

 

Piles 구현(=> 유희왕 덱)

클래스 리팩토링

  • common feature를 common한 API로 구현하기 위해, Pile class로 붂음.
    • Stock -> StockPile
    • Waste -> WastePile
    • Foundation -> FoundationPile
    • Pile -> TableauPile

구현사양

StockPile

  • 좌상단에 위치함.
  • 뒷면으로 쌓여있으며, 터치시 3장을 뽑아 앞면으로 돌리고, Waste로 옮김
  • 바닥나면 바닥을 드러냄.
    • 다 닳으면, Waste를 다시 Stock에 넣고 셔플.

 

FoundationPile

  • 우상단에 위치
  • A부터 K까지 sequential하게 쌓아야 함.
  • 쌓인 카드를 유지해야 함.
  • 쌓인 카드가 없다면 바닥을 드러내야 함.

WastePile

  • 최상단 3장의 카드를 앞면으로 배치함.

TableauPile

  • 맨 아래에 7칸의 tableau가 있음.

구현

StockPile 구현

  • acquireCard 구현: private 변수인 _cards에, card를 append함.
import 'package:flame/components.dart';
import 'package:klondike3/klondike_game.dart';

import './card.dart';

class StockPile extends PositionComponent {
  StockPile({super.position}) : super(size: KlondikeGame.cardSize);

  final List<Card> _cards = [];

  void acquireCard(Card card) {
    card.position = position;
    card.priority = _cards.length;
    _cards.add(card);
  }
}
  • klondlike_game에서, card 배열 없애기(기존에는 4*7 로 나오게 임시로 짰었음)
    // 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();
    //     }
    //   }
    // }
    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);

덩그러니 카드뭉치 하나만 화면에 남았다.

  • 마찬가지로, wastepile도 구현하자.
    • stockPile과 동일하다. 다만, 앞면으로 나오게 한다.

wastePile: 앞면으로 나올 때, 3장 포개져서 나오게 구현

final Vector2 _fanOffset = Vector2(KlondikeGame.cardWidth * 0.2, 0);

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);
    }
  }

 

StockPile: 터치해서 3장 Waste로 옮기기 구현

class StockPile extends PositionComponent with TapCallbacks{
//...

  @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);
      }
    }
  }
}

터치시 앞면배치되는것 확인가능

Klondike_game: 게임 셋업시 카드 셔플되게 구현

  • rank기준 오름차순 정렬이라 노잼이므로, 최초에 셔플되게 구현
    final cards = [
      for (var rank = 1; rank <= 13; rank++)
        for (var suit = 0; suit < 4; suit++) Card(rank, suit)
    ];
    cards.shuffle(); // 추가된 줄

stockPile: 바닥이 드러나게 만들기

  • klondlike_game
import 'dart:ui';


...
static final cardRRect = RRect.fromRectAndRadius(
    const Rect.fromLTWH(0, 0, cardWidth, cardHeight),
    const Radius.circular(cardRadius),
  );
  • stock_pile
  final _borderPaint = Paint()
    ..style = PaintingStyle.stroke
    ..strokeWidth = 10
    ..color = const Color(0xFF3F5B5D);
  final _circlePaint = Paint()
    ..style = PaintingStyle.stroke
    ..strokeWidth = 100
    ..color = const Color(0xFFFF0000);
  @override
  void render(Canvas canvas) {
    canvas.drawRRect(KlondikeGame.cardRRect, _borderPaint);
    canvas.drawCircle(
      Offset(width / 2, height / 2),
      KlondikeGame.cardWidth * 0.3,
      _circlePaint,
    );
  }

이제 다 닳았을 때, Stock의 바닥 보임

 

waste_pile: 3장 포개기

void acquireCard(Card card) {
    assert(card.isFaceUp);
    card.position = position;
    card.priority = _cards.length;
    _cards.add(card);
    _fanOutTopCards(); // 추가된 줄: 3장을 포개서 출력
  }

 

Stock_pile: 카드 리필 from wastePile

  • wastePile에 먼저, removeAllCards 메소드 추가구현
  List<Card> removeAllCards() {
    final cards = _cards.toList();
    _cards.clear();
    return cards;
  }
  • stockPile의 onTapUp 변경
  @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);
        }
      }
    }
  }
  • reversed.forEach를 통해, 이전 순서가 그대로 유지되게끔 한다.
    • 즉, refill해도 덱의 순서는 기존과 동일.

Foundation Pile 구현: 각 Suit(문양)별로만 카드를 쌓을 수 있다.

  • intSuit 인수를 추가한다.
  • rendering 함수를 추가한다.
  final _borderPaint = Paint()
    ..style = PaintingStyle.stroke
    ..strokeWidth = 10
    ..color = const Color(0xff0000ff);
  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,
    );
  }
  • klondike에, 새로 생긴 intSuit를 위한 argument 추가
    final foundations = List.generate(
      4,
      (i) => FoundationPile(i) // (i) 추가

 

TableauPile 구현

  • 일단 와꾸부터 잡기(이미 klondlike_game.dart에서, List로 7개 entry 만들게 선언되어있어서, 자동으로 7칸 깔린다.)
import 'dart:ui';

import 'package:flame/components.dart';

import '../klondike_game.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);
  }
}

  • TableauPile에, 카드를 쌓을 수 있는 기능을 추가.
    • 다만 WastePile과 다르게, 이건 가로가 아니라 세로로 미세하게 포개지도록.(장수확인목적)
  /// 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);
  }
  • TableauPile의 맨 위의 카드는 앞면이 나오도록
void flipTopCard() {
    assert(_cards.last.isFaceDown);
    _cards.last.flip();
  }

 

  • klondlike에서, onLoad함수에서 pile에 각 필요한 장수를 배부하게끔 코드추가
    world.addAll(cards);
    cards.forEach(stock.acquireCard);
    
// TableauPile에도 카드 배치
    int cardToDeal = cards.length - 1;
    for (var i = 0; i < 7; i++) {
      for (var j = i; j < 7; j++) {
        piles[j].acquireCard(cards[cardToDeal--]);
      }
      piles[i].flipTopCard();
    }
    for (int n = 0; n <= cardToDeal; n++) {
      stock.acquireCard(cards[n]);
    }

 

 

카드 옮기기 구현

Cards: ~~~ with DragCallBacks

class Card extends PositionComponent with DragCallbacks {
}
  • onDragStart, onDragupdate, onDragEnd 세 가지 메소드를 구현해야만 한다.
  • 일단 Drag된 카드는, 제일 위에 표시되어야 한다.
  @override
  void onDragStart(DragStartEvent event) {
    super.onDragStart(event);
    priority = 100;
  }

  @override
  void onDragUpdate(DragUpdateEvent event) {
    super.onDragUpdate(event);
    position += event.localDelta;
  }

abstract class Pile: 오로지 허용된 카드만 이동가능하도록.

각 Pile이 아래와 같은 method를 가지도록 추상화한다.

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);

  /// 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);
}

각각의 XXX_Pile 클래스가 implements Pile하도록 한다.

  • 인터페이스를 구현
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;
}

 

현재 Card가 어느 Pile 소속인지 알 수 있게 멤버변수 추가

Pile? pile; // 현재 어느 Pile 소속인지

 

그리고, 각 Pile들의 acquireCard() 함수에 card.pile 초기화함수 추가

  void acquireCard(Card card) {
    ...
    card.pile = this;
  }

 

지금은 Drag 가능여부 체크를 하지않아서, 밑장빼기가 가능하다.

이제, card 드래그 전에, 드래그가 애초에 가능한지 확인하는 함수 추가

  void onDragStart(DragStartEvent event) {
    if (pile?.canMoveCard(this) ?? false) {
      super.onDragStart(event);
      priority = 100;
    }
  }

 

또한, Drag가 Update될 때와, DragEnd시의 처리를 위한 함수의 와꾸를 잡아준다.

  @override
  void onDragUpdate(DragUpdateEvent event) {
    if (!isDragged) {
      return;
    }
    position += event.delta;
  }

  @override
  void onDragEnd(DragEndEvent event) {
    super.onDragEnd(event);
  }

onDragEnd() -> componentsAtPoint(): 제대로 된 곳에 카드 떨구기

  @override
  void onDragEnd(DragEndEvent event) {
    if (!isDragged) {
      return;
    }
    super.onDragEnd(event);
    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
  }

 

중요한건, "이 pile에 카드를 놓아도 되냐?" 이다.

이걸 위해, canAcceptCard()를 추가하자.

abstract class Pile에, canAcceptCard() 추가

그리고, 각 _____Pile 클래스들에, canAcceptCard(Card card)를 구현한다..

Card를 놓을 수 있는 Pile은, FoundationPile과 TableauPile 뿐이니, 이들에만 구현하자. 그 외의 Pile들에는 just return false 하면 된다.

그리고, 이 룰이 이 게임의 사실상 전부이다.(난 나무위키 게임설명봐도 이해가 안되었는데, 이 코드를 보고 단번에 이해가 되었다)

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;
    }
  }
}

 

Pile 에 평생 있을거냐?  RemovePile() 구현

마찬가지로, 추상클래스 Pile 에, removePile을 구현하자.

주의할 것은, Tableau에선, 뒷면의 Card는 클릭해도 Remove로 반응해서는 안된다는 것이다. 따라서, isFaceUp을 넣어준다.

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();
    }
  }
}

 

TableauPile간에 카드뭉치 움직이기

이 게임에는, TableauPile 내의 앞면카드는, 맨 앞장으로부터 임의의 장수를 선택해서 옮길 수 있다.

이 때, 적절한 간격을 띄워야 뒷장의 숫자를 볼 수 있을 것이다.

 

removeCard(), returnCard(), acquireCard() 아래에 각각 이 메소드를 부르게 추가하자.

  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);
    }
  }

이대로만 둔다면 문제가 생기는데, 각각의 카드 장의 중앙이 Droppable한지를 체크해서, 매우 놓기 어려워진다.

따라서, 마찬가지로 tableau_pile에 아래 줄을 추가한다.

    height = KlondikeGame.cardHeight * 3.5 + _cards.last.y - _cards.first.y;

 

자, 이제 어떻게 card의 뭉치를 한번에 옮길것이냐?

Card.attachedCards = [];

onDragStart 할 때, 적절한 cards들을 attachedCard에 넣고싶다.

일단 앞면의 카드들만을 반환하는 함수를 만들자.

  List<Card> cardsOnTop(Card card) {
    assert(card.isFaceUp && _cards.contains(card));
    final index = _cards.indexOf(card);
    return _cards.getRange(index + 1, _cards.length).toList();
  }

 

이제, 최상단의 카드 이외에, 다른 카드들도 옮길 가능성이 있다.

정확히는, 앞면이면 옮길 수 있다. 이걸 구현하자.

  bool canMoveCard(Card card) => card.isFaceUp;

 

attachedCards에 add하는 함수를 구현하자. 정확히는, onDragStart 시에, add된다.

  @override
  void onDragStart(DragStartEvent event) {
    if (pile?.canMoveCard(this) ?? false) {
      super.onDragStart();
      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);
        }
      }
    }
  }

드래그할 때, attachedCards 전체가 이동해야 한다.

  @override
  void onDragUpdate(DragUpdateEvent event) {
    if (!isDragged) {
      return;
    }
    final delta = event.delta;
    position.add(delta);
    attachedCards.forEach((card) => card.position.add(delta));
  }

착륙가능여부 판단을 할 때, 최상단의 카드를 기준으로 판단을 해야하므로, input이 List<Card> 일 가능성도 열어놓아야 한다.

  @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;
  }

또한, DragEnd일 때, pile의 모든 card를 내려놓아야 한다.

  @override
  void onDragEnd(DragEndEvent event) {
    if (!isDragged) {
      return;
    }
    super.onDragEnd(event);
    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();
    }
  }

기존코드와 비교해보자.

 

  @override
  void onDragEnd(DragEndEvent event) {
    if (!isDragged) {
      return;
    }
    super.onDragEnd(event);
    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
  }

 

플레이 - 예상보다 재미있다..

 

코드짜면서 처음으로 배운 게임인데..이거 생각보다 재밌네?

 

1시간 꼬라박고, 3트째에 겨우 풀릴 견적이 나왔다.

다 풀었다.