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;
}
이제, 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트째에 겨우 풀릴 견적이 나왔다.
다 풀었다.
'Development > Dart&Flutter' 카테고리의 다른 글
[Flutter] 운동용 Interval Timer 앱 개발 (0) | 2024.09.17 |
---|---|
[Flame] Klondlike 2 - 카드 렌더링 구현 (1) | 2024.09.07 |
[Flame] Klondlike 1 - World 레이아웃 구현 (0) | 2024.09.07 |
[Flame] Assertion failed: overlay_manager.dart:51:7 - overlay 오타를 살펴보라. (0) | 2024.09.05 |