...
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
|
||||
/pubspec.lock
|
||||
**/doc/api/
|
||||
.dart_tool/
|
||||
.flutter-plugins-dependencies
|
||||
/build/
|
||||
/coverage/
|
||||
@@ -0,0 +1,10 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "adc901062556672b4138e18a4dc62a4be8f4b3c2"
|
||||
channel: "stable"
|
||||
|
||||
project_type: package
|
||||
@@ -0,0 +1,3 @@
|
||||
## 0.0.1
|
||||
|
||||
* TODO: Describe initial release.
|
||||
@@ -0,0 +1 @@
|
||||
TODO: Add your license here.
|
||||
@@ -0,0 +1,39 @@
|
||||
<!--
|
||||
This README describes the package. If you publish this package to pub.dev,
|
||||
this README's contents appear on the landing page for your package.
|
||||
|
||||
For information about how to write a good package README, see the guide for
|
||||
[writing package pages](https://dart.dev/tools/pub/writing-package-pages).
|
||||
|
||||
For general information about developing packages, see the Dart guide for
|
||||
[creating packages](https://dart.dev/guides/libraries/create-packages)
|
||||
and the Flutter guide for
|
||||
[developing packages and plugins](https://flutter.dev/to/develop-packages).
|
||||
-->
|
||||
|
||||
TODO: Put a short description of the package here that helps potential users
|
||||
know whether this package might be useful for them.
|
||||
|
||||
## Features
|
||||
|
||||
TODO: List what your package can do. Maybe include images, gifs, or videos.
|
||||
|
||||
## Getting started
|
||||
|
||||
TODO: List prerequisites and provide or point to information on how to
|
||||
start using the package.
|
||||
|
||||
## Usage
|
||||
|
||||
TODO: Include short and useful examples for package users. Add longer examples
|
||||
to `/example` folder.
|
||||
|
||||
```dart
|
||||
const like = 'sample';
|
||||
```
|
||||
|
||||
## Additional information
|
||||
|
||||
TODO: Tell users more about the package: where to find more information, how to
|
||||
contribute to the package, how to file issues, what response they can expect
|
||||
from the package authors, and more.
|
||||
@@ -0,0 +1,4 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
@@ -0,0 +1,417 @@
|
||||
// packages/feature_game_spider/lib/controllers/spider_game_controller.dart
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:service_api/service_api.dart';
|
||||
import '../models/spider_card.dart';
|
||||
import '../models/spider_difficulty.dart';
|
||||
import '../models/spider_game_state.dart';
|
||||
|
||||
class SpiderGameController with ChangeNotifier {
|
||||
late final SpiderDifficulty difficulty;
|
||||
late final String userId;
|
||||
late final String? userName;
|
||||
|
||||
late SpiderGameState _currentState;
|
||||
SpiderGameState get currentState => _currentState;
|
||||
final List<SpiderGameHistory> _undoHistory = [];
|
||||
|
||||
Timer? _timer;
|
||||
int _secondsElapsed = 0;
|
||||
int get secondsElapsed => _secondsElapsed;
|
||||
|
||||
bool _isGameCompleted = false;
|
||||
bool get isGameCompleted => _isGameCompleted;
|
||||
|
||||
List<SpiderCard> _draggedCards = [];
|
||||
List<SpiderCard> get draggedCards => _draggedCards;
|
||||
|
||||
int _undoCount = 0;
|
||||
int get undoCount => _undoCount;
|
||||
static const int maxUndoCount = 5;
|
||||
|
||||
List<SpiderCard> _cardsToDealAnimate = [];
|
||||
List<SpiderCard> get cardsToDealAnimate => _cardsToDealAnimate;
|
||||
|
||||
void clearDealAnimationTrigger() {
|
||||
debugPrint("[LOG] clearDealAnimationTrigger: CALLED. Clearing animation queue (Current count: ${_cardsToDealAnimate.length}).");
|
||||
_cardsToDealAnimate.clear();
|
||||
}
|
||||
|
||||
List<SpiderCard> _cardsToAnimateStack = [];
|
||||
List<SpiderCard> get cardsToAnimateStack => _cardsToAnimateStack;
|
||||
int _animationSourcePileIndex = -1;
|
||||
int get animationSourcePileIndex => _animationSourcePileIndex;
|
||||
int _animationTargetFoundationIndex = -1;
|
||||
int get animationTargetFoundationIndex => _animationTargetFoundationIndex;
|
||||
|
||||
bool get canUndo {
|
||||
return _undoHistory.isNotEmpty &&
|
||||
!_isGameCompleted &&
|
||||
_undoCount < maxUndoCount;
|
||||
}
|
||||
|
||||
void setUserInfo(String userId, String? userName) {
|
||||
this.userId = userId;
|
||||
this.userName = userName;
|
||||
}
|
||||
|
||||
void startNewGame(SpiderDifficulty difficulty) {
|
||||
this.difficulty = difficulty;
|
||||
_undoHistory.clear();
|
||||
_isGameCompleted = false;
|
||||
_undoCount = 0;
|
||||
_cardsToDealAnimate = [];
|
||||
_cardsToAnimateStack = [];
|
||||
|
||||
final List<SpiderCard> deck = _createDeck(difficulty.numSuits);
|
||||
deck.shuffle(Random());
|
||||
final (List<List<SpiderCard>> tableau, List<SpiderCard> stock) =
|
||||
_dealCards(deck, difficulty.numCardsDistribution);
|
||||
|
||||
_currentState = SpiderGameState(
|
||||
tableau: tableau,
|
||||
stock: stock,
|
||||
foundation: [], // 👈 비어있는 리스트
|
||||
moves: 0,
|
||||
);
|
||||
_startTimer();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void restartGame() {
|
||||
startNewGame(difficulty);
|
||||
}
|
||||
|
||||
// ( _createDeck, _dealCards, _startTimer, stopTimer 는 동일 )
|
||||
List<SpiderCard> _createDeck(int numSuits) {
|
||||
final List<SpiderSuit> suitsToUse =
|
||||
SpiderSuit.values.take(numSuits).toList();
|
||||
final List<SpiderCard> deck = [];
|
||||
int cardId = 0;
|
||||
final int setsPerSuit = (104 / 13) ~/ numSuits;
|
||||
for (int i = 0; i < setsPerSuit; i++) {
|
||||
for (final suit in suitsToUse) {
|
||||
for (int rank = 1; rank <= 13; rank++) {
|
||||
deck.add(SpiderCard(id: cardId++, suit: suit, rank: rank));
|
||||
}
|
||||
}
|
||||
}
|
||||
return deck;
|
||||
}
|
||||
(List<List<SpiderCard>>, List<SpiderCard>) _dealCards(
|
||||
List<SpiderCard> shuffledDeck, String distribution) {
|
||||
final List<List<SpiderCard>> tableau = List.generate(10, (_) => []);
|
||||
final List<SpiderCard> stock = List.from(shuffledDeck);
|
||||
final parts = distribution.split(',');
|
||||
final int longStacksCount = 4;
|
||||
final int longStackSize = int.parse(parts[0]);
|
||||
final int shortStackSize = int.parse(parts[1]);
|
||||
for (int i = 0; i < 10; i++) {
|
||||
final int stackSize = (i < longStacksCount) ? longStackSize : shortStackSize;
|
||||
for (int j = 0; j < stackSize; j++) {
|
||||
tableau[i].add(stock.removeLast());
|
||||
}
|
||||
if (tableau[i].isNotEmpty) {
|
||||
tableau[i].last.isFaceUp = true;
|
||||
}
|
||||
}
|
||||
return (tableau, stock);
|
||||
}
|
||||
void _startTimer() {
|
||||
_timer?.cancel();
|
||||
_secondsElapsed = 0;
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
_secondsElapsed++;
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
void stopTimer() {
|
||||
_timer?.cancel();
|
||||
}
|
||||
|
||||
/// 🔽 덱 분배 (애니메이션 트리거)
|
||||
void dealFromStock() {
|
||||
debugPrint("[LOG] dealFromStock: CALLED. Checking conditions...");
|
||||
|
||||
if (_currentState.stock.isEmpty) {
|
||||
debugPrint("[LOG] dealFromStock: FAILED (Stock is empty)");
|
||||
return;
|
||||
}
|
||||
if (_isGameCompleted) {
|
||||
debugPrint("[LOG] dealFromStock: FAILED (Game completed)");
|
||||
return;
|
||||
}
|
||||
if (_cardsToDealAnimate.isNotEmpty) {
|
||||
debugPrint("[LOG] dealFromStock: FAILED (Animation already in progress)");
|
||||
return;
|
||||
}
|
||||
if (_draggedCards.isNotEmpty) {
|
||||
debugPrint("[LOG] dealFromStock: FAILED (A card drag is in progress)");
|
||||
return;
|
||||
}
|
||||
|
||||
final bool hasEmptyPile = _currentState.tableau.any((pile) => pile.isEmpty);
|
||||
debugPrint("[LOG] dealFromStock: Checking for empty piles... Result: $hasEmptyPile");
|
||||
|
||||
if (hasEmptyPile) {
|
||||
debugPrint("[LOG] dealFromStock: FAILED (Empty pile found)");
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint("[LOG] dealFromStock: All checks passed. Saving undo state.");
|
||||
_saveUndoState();
|
||||
|
||||
final int cardsToDealCount = min(10, _currentState.stock.length);
|
||||
debugPrint("[LOG] dealFromStock: Preparing ${cardsToDealCount} cards for animation.");
|
||||
|
||||
for (int i = 0; i < cardsToDealCount; i++) {
|
||||
_cardsToDealAnimate.add(_currentState.stock.removeLast());
|
||||
}
|
||||
|
||||
debugPrint("[LOG] dealFromStock: Cards moved to _cardsToDealAnimate queue (Total: ${_cardsToDealAnimate.length}). Notifying listeners...");
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 🔽 덱 분배 애니메이션이 끝난 후 UI가 호출
|
||||
void finalizeDealFromStock(List<SpiderCard> dealtCards) {
|
||||
debugPrint("[LOG] finalizeDealFromStock: CALLED. Finalizing ${dealtCards.length} cards.");
|
||||
|
||||
for (int i = 0; i < dealtCards.length; i++) {
|
||||
final card = dealtCards[i];
|
||||
card.isFaceUp = true;
|
||||
_currentState.tableau[i].add(card);
|
||||
}
|
||||
|
||||
_currentState = _currentState.copyWith(moves: _currentState.moves + 1);
|
||||
|
||||
debugPrint("[LOG] finalizeDealFromStock: FINISHED. Calling _checkCompletedStacks...");
|
||||
_checkCompletedStacks();
|
||||
}
|
||||
|
||||
// ( onDragStarted, onDragCancelled, onCardsDropped, _moveCards 는 동일 )
|
||||
void onDragStarted(List<SpiderCard> cards) {
|
||||
_draggedCards = cards;
|
||||
for (var card in cards) { card.isBeingDragged = true; }
|
||||
notifyListeners();
|
||||
}
|
||||
void onDragCancelled() {
|
||||
for (var card in _draggedCards) { card.isBeingDragged = false; }
|
||||
_draggedCards = [];
|
||||
notifyListeners();
|
||||
}
|
||||
void onCardsDropped(List<SpiderCard> cards, int targetPileIndex) {
|
||||
final int sourcePileIndex = _findPileIndexForCard(cards.first);
|
||||
for (var card in cards) { card.isBeingDragged = false; }
|
||||
_draggedCards = [];
|
||||
_moveCards(cards, sourcePileIndex, targetPileIndex);
|
||||
}
|
||||
void _moveCards(List<SpiderCard> cards, int fromIndex, int toIndex) {
|
||||
if (fromIndex == toIndex) {
|
||||
notifyListeners(); return;
|
||||
}
|
||||
_saveUndoState();
|
||||
final sourcePile = _currentState.tableau[fromIndex];
|
||||
sourcePile.removeRange(sourcePile.length - cards.length, sourcePile.length);
|
||||
if (sourcePile.isNotEmpty && !sourcePile.last.isFaceUp) {
|
||||
sourcePile.last.isFaceUp = true;
|
||||
}
|
||||
final targetPile = _currentState.tableau[toIndex];
|
||||
targetPile.addAll(cards);
|
||||
_currentState = _currentState.copyWith(moves: _currentState.moves + 1);
|
||||
_checkCompletedStacks();
|
||||
}
|
||||
|
||||
int undo() {
|
||||
if (!canUndo) return _undoCount;
|
||||
|
||||
final prevState = _undoHistory.removeLast();
|
||||
_currentState = SpiderGameState.fromHistory(prevState);
|
||||
_undoCount++;
|
||||
notifyListeners();
|
||||
return _undoCount;
|
||||
}
|
||||
|
||||
// ( canPickUpCard, getDraggableStack, isValidMove 는 동일 )
|
||||
bool canPickUpCard(SpiderCard card) {
|
||||
for (final pile in _currentState.tableau) {
|
||||
if (pile.isNotEmpty && pile.last == card) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
List<SpiderCard> getDraggableStack(SpiderCard tappedCard) {
|
||||
final int pileIndex = _findPileIndexForCard(tappedCard);
|
||||
if (pileIndex == -1) return [];
|
||||
final pile = _currentState.tableau[pileIndex];
|
||||
final int cardIndex = pile.indexOf(tappedCard);
|
||||
if (cardIndex == -1 || !tappedCard.isFaceUp) return [];
|
||||
final List<SpiderCard> draggableStack = [tappedCard];
|
||||
for (int i = cardIndex + 1; i < pile.length; i++) {
|
||||
final prevCard = pile[i - 1];
|
||||
final currentCard = pile[i];
|
||||
if (currentCard.isFaceUp &&
|
||||
prevCard.rank == currentCard.rank + 1 &&
|
||||
prevCard.suit == currentCard.suit)
|
||||
{
|
||||
draggableStack.add(currentCard);
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return draggableStack;
|
||||
}
|
||||
bool isValidMove(List<SpiderCard> cardsToMove, int targetPileIndex) {
|
||||
if (cardsToMove.isEmpty) return false;
|
||||
final targetPile = _currentState.tableau[targetPileIndex];
|
||||
if (targetPile.isEmpty) return true;
|
||||
final SpiderCard topCardToMove = cardsToMove.first;
|
||||
final SpiderCard targetTopCard = targetPile.last;
|
||||
return topCardToMove.rank == targetTopCard.rank - 1;
|
||||
}
|
||||
|
||||
/// 🔽 _checkCompletedStacks (애니메이션 트리거)
|
||||
void _checkCompletedStacks() {
|
||||
// 🔽 [수정] 애니메이션이 실행 중이면 중복 검사 방지
|
||||
if (_cardsToAnimateStack.isNotEmpty) return;
|
||||
|
||||
bool stackCompleted = false;
|
||||
for (int i = 0; i < _currentState.tableau.length; i++) {
|
||||
final pile = _currentState.tableau[i];
|
||||
if (pile.length < 13) continue;
|
||||
|
||||
final List<SpiderCard> last13Cards = pile.sublist(pile.length - 13);
|
||||
bool isComplete = true;
|
||||
final SpiderSuit targetSuit = last13Cards.first.suit;
|
||||
for (int j = 0; j < 13; j++) {
|
||||
final card = last13Cards[j];
|
||||
if (!card.isFaceUp || card.suit != targetSuit || card.rank != (13 - j)) {
|
||||
isComplete = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (isComplete) {
|
||||
// 🔽 [수정] 컨트롤러의 큐에만 추가 (인덱스 저장)
|
||||
_cardsToAnimateStack = last13Cards;
|
||||
_animationSourcePileIndex = i;
|
||||
_animationTargetFoundationIndex = _currentState.foundation.length;
|
||||
|
||||
stackCompleted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (stackCompleted) {
|
||||
notifyListeners(); // 👈 UI에 애니메이션을 그리라고 알림
|
||||
} else {
|
||||
_checkGameCompletion();
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔽 [수정] 스택 완성 애니메이션이 끝난 후 UI가 호출 (인자 받도록 변경)
|
||||
void finalizeStackCompletion(List<SpiderCard> cardsToAnimate, int sourceIndex) {
|
||||
debugPrint("[LOG] finalizeStackCompletion: CALLED. Source Index: $sourceIndex");
|
||||
|
||||
// 🔽 [수정] 크래시 방지
|
||||
if (sourceIndex < 0 || sourceIndex >= _currentState.tableau.length) {
|
||||
debugPrint("[LOG] finalizeStackCompletion: FAILED. Invalid Source Index: $sourceIndex");
|
||||
return;
|
||||
}
|
||||
|
||||
_currentState.foundation.add(cardsToAnimate);
|
||||
final pile = _currentState.tableau[sourceIndex];
|
||||
|
||||
if (pile.length >= cardsToAnimate.length) {
|
||||
pile.removeRange(pile.length - cardsToAnimate.length, pile.length);
|
||||
} else {
|
||||
debugPrint("[LOG] finalizeStackCompletion: WARNING. Pile length was ${pile.length}, expected >= ${cardsToAnimate.length}.");
|
||||
}
|
||||
|
||||
if (pile.isNotEmpty && !pile.last.isFaceUp) {
|
||||
pile.last.isFaceUp = true;
|
||||
}
|
||||
|
||||
// 🔽 [삭제] 인덱스 리셋 불필요 (지역 변수로 처리됨)
|
||||
// _animationSourcePileIndex = -1;
|
||||
// _animationTargetFoundationIndex = -1;
|
||||
|
||||
_checkGameCompletion(); // 👈 [핵심] 게임 완료 검사
|
||||
}
|
||||
|
||||
// 🔽 [복원됨]
|
||||
void clearStackAnimationTrigger() {
|
||||
debugPrint("[LOG] clearStackAnimationTrigger: CALLED. Clearing animation queue (Current count: ${_cardsToAnimateStack.length}).");
|
||||
_cardsToAnimateStack.clear();
|
||||
}
|
||||
|
||||
void _checkGameCompletion() {
|
||||
if (_currentState.foundation.length == 8 && !_isGameCompleted) {
|
||||
_isGameCompleted = true;
|
||||
stopTimer();
|
||||
debugPrint("게임 완료! 이동: ${_currentState.moves}, 시간: $_secondsElapsed");
|
||||
notifyListeners(); // 👈 [수정] 게임이 '완료'되었을 때만 notify
|
||||
} else if (!_isGameCompleted) {
|
||||
// 🔽 [수정] 게임이 완료되지 '않았을' 때도 notify (카드 이동 등을 반영하기 위해)
|
||||
notifyListeners();
|
||||
}
|
||||
// (게임이 완료된 후에는 더 이상 notify하지 않음)
|
||||
}
|
||||
|
||||
// ( _saveUndoState, _findPileIndexForCard, submitRank, dispose 는 동일 )
|
||||
void _saveUndoState() {
|
||||
_undoHistory.add(SpiderGameHistory.fromState(_currentState));
|
||||
if (_undoHistory.length > 20) {
|
||||
_undoHistory.removeAt(0);
|
||||
}
|
||||
}
|
||||
int _findPileIndexForCard(SpiderCard card) {
|
||||
return _currentState.tableau.indexWhere((pile) => pile.contains(card));
|
||||
}
|
||||
Future<RankSubmissionResult> submitRank(String playerName) async {
|
||||
final puzzleService = PuzzleService();
|
||||
final identityService = IdentityService();
|
||||
final rankDto = UnifiedRankDto(
|
||||
userId: userId,
|
||||
gameType: 'SPIDER',
|
||||
contextId: difficulty.contextId,
|
||||
playerName: playerName,
|
||||
primaryScore: _currentState.moves,
|
||||
secondaryScore: _secondsElapsed,
|
||||
);
|
||||
final result = await puzzleService.submitRank(rankDto);
|
||||
await identityService.saveUserName(playerName);
|
||||
final int currentMaxLevel = await identityService.getMaxUnlockedLevel(gameType: 'SPIDER');
|
||||
if (currentMaxLevel < 99) {
|
||||
if (difficulty.levelIndex >= currentMaxLevel) {
|
||||
int nextLevel = difficulty.levelIndex + 1;
|
||||
if (nextLevel > SpiderDifficulties.allDifficulties.length) {
|
||||
await identityService.saveMaxUnlockedLevel(99, gameType: 'SPIDER');
|
||||
} else {
|
||||
await identityService.saveMaxUnlockedLevel(nextLevel, gameType: 'SPIDER');
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
extension GameStateCopyWith on SpiderGameState {
|
||||
SpiderGameState copyWith({
|
||||
List<List<SpiderCard>>? tableau,
|
||||
List<SpiderCard>? stock,
|
||||
List<List<SpiderCard>>? foundation,
|
||||
int? moves,
|
||||
}) {
|
||||
return SpiderGameState(
|
||||
tableau: tableau ?? this.tableau,
|
||||
stock: stock ?? this.stock,
|
||||
foundation: foundation ?? this.foundation,
|
||||
moves: moves ?? this.moves,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// packages/feature_game_spider/lib/feature_game_spider.dart
|
||||
|
||||
// 스파이더 앱의 '로비 화면(메인)'
|
||||
export 'screens/spider_lobby_screen.dart';
|
||||
|
||||
// 로비에서 호출할 '게임 플레이 화면'
|
||||
export 'screens/spider_game_screen.dart';
|
||||
@@ -0,0 +1,76 @@
|
||||
// packages/feature_game_spider/lib/models/spider_card.dart
|
||||
|
||||
/// 카드의 4가지 무늬
|
||||
enum SpiderSuit {
|
||||
spade, // ♠️
|
||||
heart, // ♥️
|
||||
club, // ♣️
|
||||
diamond // ♦️
|
||||
}
|
||||
|
||||
/// 스파이더 카드 1장의 데이터 모델
|
||||
class SpiderCard {
|
||||
/// 카드의 고유 ID (Draggable 위젯의 Key로 사용)
|
||||
final int id;
|
||||
|
||||
/// 무늬 (spade, heart 등)
|
||||
final SpiderSuit suit;
|
||||
|
||||
/// 숫자 (1 = A, 11 = J, 12 = Q, 13 = K)
|
||||
final int rank;
|
||||
|
||||
/// 현재 앞면이 보이는지 여부
|
||||
bool isFaceUp;
|
||||
|
||||
/// [UI용] 카드가 현재 드래그 중인지 여부
|
||||
bool isBeingDragged;
|
||||
|
||||
SpiderCard({
|
||||
required this.id,
|
||||
required this.suit,
|
||||
required this.rank,
|
||||
this.isFaceUp = false,
|
||||
this.isBeingDragged = false,
|
||||
});
|
||||
|
||||
/// 카드가 빨간색(하트, 다이아)인지 확인
|
||||
bool get isRed => suit == SpiderSuit.heart || suit == SpiderSuit.diamond;
|
||||
|
||||
/// 랭크를 텍스트(A, K, Q, J, 10...)로 변환
|
||||
String get rankText {
|
||||
switch (rank) {
|
||||
case 1: return 'A';
|
||||
case 11: return 'J';
|
||||
case 12: return 'Q';
|
||||
case 13: return 'K';
|
||||
default: return rank.toString();
|
||||
}
|
||||
}
|
||||
|
||||
/// 무늬를 심볼(♠️, ♥️...)로 변환
|
||||
String get suitSymbol {
|
||||
switch (suit) {
|
||||
case SpiderSuit.spade: return '♠️';
|
||||
case SpiderSuit.heart: return '♥️';
|
||||
case SpiderSuit.club: return '♣️';
|
||||
case SpiderSuit.diamond: return '♦️';
|
||||
}
|
||||
}
|
||||
|
||||
// 객체 비교를 위한 == 및 hashCode 오버라이드
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is SpiderCard &&
|
||||
runtimeType == other.runtimeType &&
|
||||
id == other.id; // 고유 ID로만 비교
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
|
||||
/// 디버깅용
|
||||
@override
|
||||
String toString() {
|
||||
return '$rankText-$suitSymbol ($id)';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// packages/feature_game_spider/lib/models/spider_difficulty.dart
|
||||
|
||||
import 'package:service_api/service_api.dart'; // 👈 공통 GameDifficulty 모델
|
||||
|
||||
/// 스파이더 게임의 난이도 정의
|
||||
class SpiderDifficulty extends GameDifficulty {
|
||||
/// 레벨 순서 (1-9)
|
||||
final int levelIndex;
|
||||
|
||||
/// 무늬 수 (1, 2, 4)
|
||||
final int numSuits;
|
||||
|
||||
/// 카드 분배 문자열 (예: "4,3")
|
||||
final String numCardsDistribution;
|
||||
|
||||
const SpiderDifficulty({
|
||||
required this.levelIndex,
|
||||
required super.name,
|
||||
required super.contextId,
|
||||
required this.numSuits,
|
||||
required this.numCardsDistribution,
|
||||
});
|
||||
}
|
||||
|
||||
/// 앱 전역에서 사용할 스파이더 난이도 목록 (총 9개)
|
||||
/// (스도쿠의 AppLevels와 동일한 구조)
|
||||
class SpiderDifficulties {
|
||||
static final List<SpiderDifficulty> allDifficulties = [
|
||||
// --- 1 Suit (Easy) ---
|
||||
const SpiderDifficulty(
|
||||
levelIndex: 1,
|
||||
name: '입문 (1 Suit)',
|
||||
contextId: 'SPIDER_L1_1SUIT_4-3',
|
||||
numSuits: 1,
|
||||
numCardsDistribution: '4,3',
|
||||
),
|
||||
const SpiderDifficulty(
|
||||
levelIndex: 2,
|
||||
name: '초급 (1 Suit)',
|
||||
contextId: 'SPIDER_L2_1SUIT_5-4',
|
||||
numSuits: 1,
|
||||
numCardsDistribution: '5,4',
|
||||
),
|
||||
const SpiderDifficulty(
|
||||
levelIndex: 3,
|
||||
name: '중급 (1 Suit)',
|
||||
contextId: 'SPIDER_L3_1SUIT_6-5',
|
||||
numSuits: 1,
|
||||
numCardsDistribution: '6,5',
|
||||
),
|
||||
|
||||
// --- 2 Suits (Medium) ---
|
||||
const SpiderDifficulty(
|
||||
levelIndex: 4,
|
||||
name: '상급 (2 Suits)',
|
||||
contextId: 'SPIDER_L4_2SUITS_5-4',
|
||||
numSuits: 2,
|
||||
numCardsDistribution: '5,4',
|
||||
),
|
||||
const SpiderDifficulty(
|
||||
levelIndex: 5,
|
||||
name: '전문가 (2 Suits)',
|
||||
contextId: 'SPIDER_L5_2SUITS_6-5',
|
||||
numSuits: 2,
|
||||
numCardsDistribution: '6,5',
|
||||
),
|
||||
const SpiderDifficulty(
|
||||
levelIndex: 6,
|
||||
name: '마스터 (2 Suits)',
|
||||
contextId: 'SPIDER_L6_2SUITS_7-6',
|
||||
numSuits: 2,
|
||||
numCardsDistribution: '7,6',
|
||||
),
|
||||
|
||||
// --- 4 Suits (Hard) ---
|
||||
const SpiderDifficulty(
|
||||
levelIndex: 7,
|
||||
name: '최상급 (4 Suits)',
|
||||
contextId: 'SPIDER_L7_4SUITS_6-5',
|
||||
numSuits: 4,
|
||||
numCardsDistribution: '6,5',
|
||||
),
|
||||
const SpiderDifficulty(
|
||||
levelIndex: 8,
|
||||
name: '지옥 (4 Suits)',
|
||||
contextId: 'SPIDER_L8_4SUITS_7-6',
|
||||
numSuits: 4,
|
||||
numCardsDistribution: '7,6',
|
||||
),
|
||||
const SpiderDifficulty(
|
||||
levelIndex: 9,
|
||||
name: '챔피언 (4 Suits)',
|
||||
contextId: 'SPIDER_L9_4SUITS_8-7',
|
||||
numSuits: 4,
|
||||
numCardsDistribution: '8,7',
|
||||
),
|
||||
];
|
||||
|
||||
/// 레벨 인덱스(1-9)로 레벨 정보 찾기
|
||||
static SpiderDifficulty getLevel(int levelIndex) {
|
||||
if (levelIndex < 1) levelIndex = 1;
|
||||
if (levelIndex > allDifficulties.length) levelIndex = allDifficulties.length;
|
||||
return allDifficulties.firstWhere((level) => level.levelIndex == levelIndex,
|
||||
orElse: () => allDifficulties[0]
|
||||
);
|
||||
}
|
||||
|
||||
/// 랭킹 화면용 맵 (ContextId -> 이름)
|
||||
static Map<String, String> get contextIdToNameMap {
|
||||
return { for (var level in allDifficulties) level.contextId : level.name };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// packages/feature_game_spider/lib/models/spider_game_state.dart
|
||||
import 'spider_card.dart';
|
||||
|
||||
/// 게임 보드 전체의 상태를 저장하는 클래스
|
||||
class SpiderGameState {
|
||||
final List<List<SpiderCard>> tableau;
|
||||
final List<SpiderCard> stock;
|
||||
final List<List<SpiderCard>> foundation;
|
||||
final int moves;
|
||||
// ❌ undoCount가 여기서 제거됨
|
||||
|
||||
SpiderGameState({
|
||||
required this.tableau,
|
||||
required this.stock,
|
||||
required this.foundation,
|
||||
required this.moves,
|
||||
});
|
||||
|
||||
/// `spider.html`의 `undoHistory`에 해당하는
|
||||
/// 되돌리기용 복사본을 생성하는 팩토리 생성자
|
||||
factory SpiderGameState.fromHistory(SpiderGameHistory history) {
|
||||
return SpiderGameState(
|
||||
tableau: history.tableau.map((pile) => List.of(pile)).toList(),
|
||||
stock: List.of(history.stock),
|
||||
foundation: history.foundation.map((pile) => List.of(pile)).toList(),
|
||||
moves: history.moves,
|
||||
// ❌ undoCount가 여기서 제거됨
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 되돌리기(Undo)를 위해 저장되는 게임 상태의 스냅샷
|
||||
class SpiderGameHistory {
|
||||
final List<List<SpiderCard>> tableau;
|
||||
final List<SpiderCard> stock;
|
||||
final List<List<SpiderCard>> foundation;
|
||||
final int moves;
|
||||
// ❌ undoCount가 여기서 제거됨
|
||||
|
||||
SpiderGameHistory({
|
||||
required this.tableau,
|
||||
required this.stock,
|
||||
required this.foundation,
|
||||
required this.moves,
|
||||
});
|
||||
|
||||
/// 현재 게임 상태(GameState)로부터 스냅샷 생성
|
||||
factory SpiderGameHistory.fromState(SpiderGameState state) {
|
||||
return SpiderGameHistory(
|
||||
tableau: state.tableau.map((pile) => List.of(pile)).toList(),
|
||||
stock: List.of(state.stock),
|
||||
foundation: state.foundation.map((pile) => List.of(pile)).toList(),
|
||||
moves: state.moves,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
// packages/feature_game_spider/lib/screens/spider_game_screen.dart
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:service_api/service_api.dart';
|
||||
import 'package:feature_common/feature_common.dart';
|
||||
import '../controllers/spider_game_controller.dart';
|
||||
import '../models/spider_difficulty.dart';
|
||||
import '../models/spider_card.dart';
|
||||
import '../widgets/tableau_pile_widget.dart';
|
||||
import '../widgets/bottom_bar_widget.dart';
|
||||
import '../widgets/card_widget.dart';
|
||||
|
||||
// ❌ [삭제] enum _RankSubmissionStep
|
||||
|
||||
class SpiderGameScreen extends StatefulWidget {
|
||||
const SpiderGameScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SpiderGameScreen> createState() => _SpiderGameScreenState();
|
||||
}
|
||||
|
||||
class _SpiderGameScreenState extends State<SpiderGameScreen> {
|
||||
bool _isDialogShowing = false;
|
||||
final List<GlobalKey> _tableauKeys = List.generate(10, (_) => GlobalKey());
|
||||
final GlobalKey _stockKey = GlobalKey();
|
||||
final GlobalKey _bodyStackKey = GlobalKey();
|
||||
final List<Widget> _animationOverlays = [];
|
||||
bool _showDimOverlay = false;
|
||||
|
||||
VoidCallback? _controllerListener;
|
||||
|
||||
bool _isDealAnimationRunning = false;
|
||||
bool _isStackAnimationRunning = false;
|
||||
|
||||
// ( _buildGameAppBar, _showSurrenderDialog 는 동일 )
|
||||
AppBar _buildGameAppBar(BuildContext context, SpiderGameController controller) {
|
||||
return AppBar(
|
||||
leading: IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.of(context).pop()),
|
||||
title: Consumer<SpiderGameController>(
|
||||
builder: (context, controller, child) {
|
||||
final seconds = controller.secondsElapsed;
|
||||
final timeStr = "${(seconds ~/ 60).toString().padLeft(2, '0')}:${(seconds % 60).toString().padLeft(2, '0')}";
|
||||
return Text(timeStr, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 22));
|
||||
},
|
||||
),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
Consumer<SpiderGameController>(
|
||||
builder: (context, controller, child) {
|
||||
final bool canUndo = controller.canUndo;
|
||||
return IconButton(
|
||||
icon: Icon(Icons.undo, color: canUndo ? null : Colors.grey),
|
||||
onPressed: canUndo ? () {
|
||||
final int currentCount = controller.undo();
|
||||
if (currentCount >= SpiderGameController.maxUndoCount) {
|
||||
_showSurrenderDialog(context);
|
||||
}
|
||||
} : null,
|
||||
);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () {
|
||||
Provider.of<SpiderGameController>(context, listen: false).restartGame();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
void _showSurrenderDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('게임 포기'),
|
||||
content: const Text('되돌리기 횟수를 모두 사용했습니다. 게임을 포기하고 로비로 돌아가시겠습니까?'),
|
||||
actions: [
|
||||
TextButton(child: const Text('취소'), onPressed: () => Navigator.of(ctx).pop()),
|
||||
TextButton(
|
||||
child: const Text('포기하기'),
|
||||
onPressed: () {
|
||||
Navigator.of(ctx).pop();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final controller = Provider.of<SpiderGameController>(context, listen: false);
|
||||
|
||||
_controllerListener = () {
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
const double horizontalPadding = 10;
|
||||
const double cardGap = 5;
|
||||
final double cardWidth = (screenSize.width - (horizontalPadding * 2) - (cardGap * 9)) / 10;
|
||||
final double cardHeight = cardWidth * 1.45;
|
||||
|
||||
// 🔽 [수정] 덱 분배 애니메이션 (플래그 가드 추가)
|
||||
if (controller.cardsToDealAnimate.isNotEmpty && !_isDealAnimationRunning) {
|
||||
_isDealAnimationRunning = true; // 👈 [잠금]
|
||||
debugPrint("[LOG] initState Listener: Detected cardsToDealAnimate. Running animation...");
|
||||
_runDealAnimation(controller, cardWidth, cardHeight);
|
||||
}
|
||||
|
||||
// 🔽 [수정] 스택 완성 애니메이션 (경주 조건 해결 로직)
|
||||
if (controller.cardsToAnimateStack.isNotEmpty && !_isStackAnimationRunning) {
|
||||
_isStackAnimationRunning = true; // 👈 [잠금]
|
||||
|
||||
// [핵심] 큐를 복사하고, 인덱스도 *지금* 읽어서 복사합니다.
|
||||
final List<SpiderCard> cardsToAnimate = List.of(controller.cardsToAnimateStack);
|
||||
final int sourceIndex = controller.animationSourcePileIndex;
|
||||
final int targetIndex = controller.animationTargetFoundationIndex;
|
||||
|
||||
// 큐를 즉시 비웁니다.
|
||||
controller.clearStackAnimationTrigger();
|
||||
|
||||
debugPrint("[LOG] initState Listener: Detected cardsToAnimateStack (Source: $sourceIndex). Running animation...");
|
||||
// 복사한 데이터를 인자로 전달합니다.
|
||||
_runStackCompletionAnimation(controller, cardWidth, cardHeight, cardsToAnimate, sourceIndex, targetIndex);
|
||||
}
|
||||
};
|
||||
|
||||
controller.addListener(_controllerListener!);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_controllerListener != null) {
|
||||
final controller = Provider.of<SpiderGameController>(context, listen: false);
|
||||
controller.removeListener(_controllerListener!);
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
debugPrint("[LOG] SpiderGameScreen: --- Main Build Method CALLED ---");
|
||||
|
||||
final controller = context.read<SpiderGameController>();
|
||||
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
const double horizontalPadding = 10;
|
||||
const double cardGap = 5;
|
||||
final double cardWidth = (screenSize.width - (horizontalPadding * 2) - (cardGap * 9)) / 10;
|
||||
final double cardHeight = cardWidth * 1.45;
|
||||
final double cardOverlap = cardHeight * 0.4;
|
||||
|
||||
final bool isGameCompleted = context.select((SpiderGameController c) => c.isGameCompleted);
|
||||
if (isGameCompleted && !_isDialogShowing) {
|
||||
_isDialogShowing = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
_runGameWinAnimation(context, controller, cardWidth, cardHeight);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: _buildGameAppBar(context, controller),
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
body: Stack(
|
||||
key: _bodyStackKey,
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
color: const Color(0xFF008000),
|
||||
padding: EdgeInsets.symmetric(horizontal: horizontalPadding, vertical: 10),
|
||||
child: Stack(
|
||||
children: [
|
||||
Consumer<SpiderGameController>(
|
||||
builder: (context, controller, child) {
|
||||
debugPrint("[LOG] Tableau Consumer: Rebuilding");
|
||||
return Stack(
|
||||
children: List.generate(10, (index) {
|
||||
return Positioned(
|
||||
left: index * (cardWidth + cardGap),
|
||||
top: 0,
|
||||
child: TableauPileWidget(
|
||||
key: _tableauKeys[index],
|
||||
pileIndex: index,
|
||||
cards: controller.currentState.tableau[index],
|
||||
cardWidth: cardWidth,
|
||||
cardHeight: cardHeight,
|
||||
cardOverlap: cardOverlap,
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const AdBannerWidget(),
|
||||
BottomBarWidget(
|
||||
key: _stockKey,
|
||||
cardWidth: cardWidth,
|
||||
cardHeight: cardHeight,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
if (_showDimOverlay)
|
||||
Container(
|
||||
color: Colors.black.withOpacity(0.5),
|
||||
),
|
||||
|
||||
..._animationOverlays,
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// 🔽 덱 분배 애니메이션 (오버레이)
|
||||
void _runDealAnimation(
|
||||
SpiderGameController controller,
|
||||
double cardWidth,
|
||||
double cardHeight,
|
||||
) {
|
||||
// 🔽 [수정] 덱 분배 애니메이션도 경주 조건을 피하기 위해 인자로 받도록 수정
|
||||
final List<SpiderCard> cardsToDeal = List.of(controller.cardsToDealAnimate);
|
||||
controller.clearDealAnimationTrigger();
|
||||
|
||||
debugPrint("[LOG] _runDealAnimation: Starting. ${cardsToDeal.length} cards.");
|
||||
|
||||
final RenderBox? bodyStackBox = _bodyStackKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
final RenderBox? stockBox = _stockKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (bodyStackBox == null || stockBox == null) {
|
||||
debugPrint("[LOG] _runDealAnimation: FAILED (Keys not ready)");
|
||||
_isDealAnimationRunning = false; // 👈 [잠금 해제]
|
||||
return;
|
||||
}
|
||||
|
||||
final Offset globalStartPos = stockBox.localToGlobal(Offset.zero);
|
||||
final Offset localStartPos = bodyStackBox.globalToLocal(globalStartPos);
|
||||
|
||||
for (int i = 0; i < cardsToDeal.length; i++) {
|
||||
final card = cardsToDeal[i];
|
||||
final RenderBox? targetBox = _tableauKeys[i].currentContext?.findRenderObject() as RenderBox?;
|
||||
if (targetBox == null) continue;
|
||||
|
||||
final Offset globalEndPos = targetBox.localToGlobal(Offset.zero);
|
||||
final double targetY = globalEndPos.dy + controller.currentState.tableau[i].length * (cardHeight * 0.4);
|
||||
final Offset localEndPos = bodyStackBox.globalToLocal(Offset(globalEndPos.dx, targetY));
|
||||
|
||||
final animationDelayMs = i * 100;
|
||||
final animationDurationMs = 600;
|
||||
|
||||
final overlayEntry = TweenAnimationBuilder<double>(
|
||||
key: ValueKey('deal_${card.id}'),
|
||||
tween: Tween(begin: 0.0, end: 1.0),
|
||||
duration: Duration(milliseconds: animationDurationMs),
|
||||
builder: (context, value, child) {
|
||||
final currentPos = Offset.lerp(localStartPos, localEndPos, value)!;
|
||||
final bool isFlipping = value > 0.5;
|
||||
final double rotationY = isFlipping ? (value - 0.5) * 2 * pi : 0;
|
||||
|
||||
return Positioned(
|
||||
left: currentPos.dx,
|
||||
top: currentPos.dy,
|
||||
child: Transform(
|
||||
alignment: Alignment.center,
|
||||
transform: Matrix4.identity()
|
||||
..setEntry(3, 2, 0.001)
|
||||
..rotateY(rotationY),
|
||||
child: CardWidget(
|
||||
card: card..isFaceUp = (value > 0.5),
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
isDraggable: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Future.delayed(Duration(milliseconds: animationDelayMs), () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_animationOverlays.add(overlayEntry);
|
||||
});
|
||||
|
||||
Future.delayed(Duration(milliseconds: animationDurationMs), () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_animationOverlays.remove(overlayEntry);
|
||||
});
|
||||
|
||||
if (i == cardsToDeal.length - 1) {
|
||||
debugPrint("[LOG] _runDealAnimation: Animation FINISHED. Calling finalizeDealFromStock.");
|
||||
controller.finalizeDealFromStock(cardsToDeal);
|
||||
_isDealAnimationRunning = false; // 👈 [잠금 해제]
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔽 스택 완성 애니메이션 (오버레이)
|
||||
void _runStackCompletionAnimation(
|
||||
SpiderGameController controller,
|
||||
double cardWidth,
|
||||
double cardHeight,
|
||||
// 🔽 [수정] 인자를 받습니다.
|
||||
List<SpiderCard> cardsToAnimate,
|
||||
int sourceIndex,
|
||||
int targetIndex,
|
||||
) {
|
||||
debugPrint("[LOG] _runStackCompletionAnimation: Starting. ${cardsToAnimate.length} cards from index $sourceIndex.");
|
||||
|
||||
final RenderBox? bodyStackBox = _bodyStackKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
final RenderBox? stockBox = _stockKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
// 🔽 [수정] 인자로 받은 sourceIndex 사용
|
||||
final RenderBox? startBox = _tableauKeys[sourceIndex].currentContext?.findRenderObject() as RenderBox?;
|
||||
|
||||
if (bodyStackBox == null || stockBox == null || startBox == null) {
|
||||
debugPrint("[LOG] _runStackCompletionAnimation: FAILED (Keys not ready for index $sourceIndex)");
|
||||
_isStackAnimationRunning = false; // 👈 [잠금 해제]
|
||||
return;
|
||||
}
|
||||
|
||||
final Offset globalStartPos = startBox.localToGlobal(Offset.zero);
|
||||
final Offset localStartPos = bodyStackBox.globalToLocal(globalStartPos);
|
||||
// 🔽 [수정] 스택이 제거되기 '전'의 길이를 기준으로 계산 (정확)
|
||||
final double startY = localStartPos.dy + (controller.currentState.tableau[sourceIndex].length - cardsToAnimate.length) * (cardHeight * 0.4);
|
||||
|
||||
// 🔽 [수정] 인자로 받은 targetIndex 사용
|
||||
final Offset globalEndPos = stockBox.localToGlobal(Offset( (targetIndex * (cardWidth * 0.15)) - cardWidth*3, 10));
|
||||
final Offset localEndPos = bodyStackBox.globalToLocal(globalEndPos);
|
||||
|
||||
for (int i = 0; i < cardsToAnimate.length; i++) {
|
||||
final card = cardsToAnimate[i];
|
||||
final animationDelayMs = i * 80;
|
||||
final animationDurationMs = 400;
|
||||
|
||||
final overlayEntry = TweenAnimationBuilder<double>(
|
||||
key: ValueKey('stack_${card.id}'),
|
||||
tween: Tween(begin: 0.0, end: 1.0),
|
||||
duration: Duration(milliseconds: animationDurationMs),
|
||||
builder: (context, value, child) {
|
||||
final currentPos = Offset.lerp(Offset(localStartPos.dx, startY + (i * cardHeight * 0.4)), localEndPos, value)!;
|
||||
return Positioned(
|
||||
left: currentPos.dx,
|
||||
top: currentPos.dy,
|
||||
child: CardWidget(
|
||||
card: card..isFaceUp = true,
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
isDraggable: false,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Future.delayed(Duration(milliseconds: animationDelayMs), () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_animationOverlays.add(overlayEntry);
|
||||
});
|
||||
|
||||
Future.delayed(Duration(milliseconds: animationDurationMs), () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_animationOverlays.remove(overlayEntry);
|
||||
});
|
||||
if (i == cardsToAnimate.length - 1) {
|
||||
debugPrint("[LOG] _runStackCompletionAnimation: Animation FINISHED. Calling finalizeStackCompletion for index $sourceIndex.");
|
||||
// 🔽 [수정] finalize가 어떤 스택을 처리할지 인덱스를 전달
|
||||
controller.finalizeStackCompletion(cardsToAnimate, sourceIndex);
|
||||
_isStackAnimationRunning = false; // 👈 [잠금 해제]
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔽 [수정] _runGameWinAnimation (팝업 호출 로직 변경)
|
||||
void _runGameWinAnimation(
|
||||
BuildContext context,
|
||||
SpiderGameController controller,
|
||||
double cardWidth,
|
||||
double cardHeight,
|
||||
) {
|
||||
final RenderBox? bodyStackBox = _bodyStackKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (bodyStackBox == null) return;
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
final random = Random();
|
||||
final List<SpiderCard> allCards = controller.currentState.foundation.expand((pile) => pile).toList();
|
||||
final Offset globalStartPos = Offset(screenSize.width / 2, screenSize.height * 0.8);
|
||||
final Offset localStartPos = bodyStackBox.globalToLocal(globalStartPos);
|
||||
|
||||
for (int i = 0; i < allCards.length; i++) {
|
||||
final card = allCards[i];
|
||||
final animationDelay = Duration(milliseconds: i * 30);
|
||||
final animationDuration = const Duration(milliseconds: 1500);
|
||||
final Offset globalEndPos = Offset(random.nextDouble() * screenSize.width, -cardHeight - (AppBar().preferredSize.height));
|
||||
final Offset localEndPos = bodyStackBox.globalToLocal(globalEndPos);
|
||||
|
||||
final overlayEntry = TweenAnimationBuilder<double>(
|
||||
key: ValueKey('win_${card.id}'),
|
||||
tween: Tween(begin: 0.0, end: 1.0),
|
||||
duration: animationDuration,
|
||||
builder: (context, value, child) {
|
||||
final currentPos = Offset.lerp(localStartPos, localEndPos, value)!;
|
||||
return Positioned(
|
||||
left: currentPos.dx,
|
||||
top: currentPos.dy,
|
||||
child: Transform.rotate(
|
||||
angle: value * pi * 2,
|
||||
child: CardWidget(
|
||||
card: card..isFaceUp=true,
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
isDraggable: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Future.delayed(animationDelay, () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_animationOverlays.add(overlayEntry);
|
||||
});
|
||||
|
||||
// ❌ [삭제] 500ms 후에 팝업을 띄우는 로직
|
||||
// if (i == 0) { ... }
|
||||
|
||||
Future.delayed(animationDuration, () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_animationOverlays.remove(overlayEntry);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 🔽 [추가] 딤 오버레이(배경 어두워짐)는 500ms 뒤에 바로 표시
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
if (mounted && controller.isGameCompleted) {
|
||||
setState(() { _showDimOverlay = true; });
|
||||
}
|
||||
});
|
||||
|
||||
// 🔽 [추가] 랭킹 팝업은 약 2초 뒤 표시
|
||||
final popupDelay = (allCards.length > 70) ? const Duration(seconds: 2) : const Duration(milliseconds: 500);
|
||||
Future.delayed(popupDelay, () {
|
||||
if (mounted && controller.isGameCompleted) {
|
||||
|
||||
// [수정] _showGameCompletedDialog() 호출 대신 공통 화면으로 이동
|
||||
|
||||
// 1. 점수 포맷터 정의
|
||||
String formatSpiderScore(int primary, int? secondary) {
|
||||
final moves = primary.toString();
|
||||
final time = (secondary ?? 0).toString();
|
||||
return '${moves}회 (${time}초)';
|
||||
}
|
||||
|
||||
// 2. 레벨 저장 콜백 정의
|
||||
Future<void> saveSpiderProgress(String playerName) async {
|
||||
// (playerName은 공통 화면이 IdentityService로 저장)
|
||||
|
||||
final identityService = IdentityService();
|
||||
|
||||
final int currentMaxLevel = await identityService.getMaxUnlockedLevel(gameType: 'SPIDER');
|
||||
if (currentMaxLevel < 99) {
|
||||
if (controller.difficulty.levelIndex >= currentMaxLevel) {
|
||||
int nextLevel = controller.difficulty.levelIndex + 1;
|
||||
if (nextLevel > SpiderDifficulties.allDifficulties.length) {
|
||||
await identityService.saveMaxUnlockedLevel(99, gameType: 'SPIDER');
|
||||
} else {
|
||||
await identityService.saveMaxUnlockedLevel(nextLevel, gameType: 'SPIDER');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 화면 이동
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => GameCompletionScreen(
|
||||
args: GameResultArgs(
|
||||
gameType: 'SPIDER',
|
||||
contextId: controller.difficulty.contextId,
|
||||
primaryScore: controller.currentState.moves,
|
||||
secondaryScore: controller.secondsElapsed,
|
||||
userId: controller.userId,
|
||||
userName: controller.userName,
|
||||
scoreFormatter: formatSpiderScore,
|
||||
onProgressSave: saveSpiderProgress,
|
||||
onScreenClose: () {
|
||||
// 공통 화면에서 '닫기'를 누르면 게임 화면도 닫힘
|
||||
if (Navigator.canPop(context)) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ❌ [삭제] _showGameCompletedDialog() 메서드 전체 (약 200줄) 삭제
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
// packages/feature_game_spider/lib/screens/spider_lobby_screen.dart
|
||||
import 'dart:developer';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:service_api/service_api.dart';
|
||||
import 'package:feature_common/feature_common.dart';
|
||||
import 'spider_game_screen.dart';
|
||||
import '../models/spider_difficulty.dart';
|
||||
import '../controllers/spider_game_controller.dart';
|
||||
|
||||
class SpiderLobbyScreen extends StatefulWidget {
|
||||
const SpiderLobbyScreen({ super.key });
|
||||
@override
|
||||
State<SpiderLobbyScreen> createState() => _SpiderLobbyScreenState();
|
||||
}
|
||||
|
||||
class _SpiderLobbyScreenState extends State<SpiderLobbyScreen> {
|
||||
int _maxUnlockedLevel = 1;
|
||||
Map<int, (int, int)> _rankHistory = {};
|
||||
String? _userName;
|
||||
bool _isLoading = false;
|
||||
|
||||
final PuzzleService _puzzleService = PuzzleService();
|
||||
final IdentityService _identityService = IdentityService();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadProgress();
|
||||
}
|
||||
|
||||
// ( _loadProgress 메서드는 이전과 동일 )
|
||||
Future<void> _loadProgress() async {
|
||||
final maxLevel = await _identityService.getMaxUnlockedLevel(gameType: 'SPIDER');
|
||||
final String? myName = await _identityService.getSavedUserName();
|
||||
if (mounted) { setState(() { _maxUnlockedLevel = maxLevel; _userName = myName; }); }
|
||||
if (myName == null) return;
|
||||
try {
|
||||
final Map<int, int> oldRankMap = await _identityService.getLastSavedRankMap(gameType: 'SPIDER');
|
||||
List<Future<List<GameRankDto>>> rankFutures = [];
|
||||
for (final level in SpiderDifficulties.allDifficulties) {
|
||||
rankFutures.add(_puzzleService.fetchRanks('SPIDER', level.contextId));
|
||||
}
|
||||
final List<List<GameRankDto>> allRankResults = await Future.wait(rankFutures);
|
||||
Map<int, int> newRankMapForStorage = {};
|
||||
Map<int, (int, int)> newRankHistoryForState = {};
|
||||
for (int i = 0; i < SpiderDifficulties.allDifficulties.length; i++) {
|
||||
final level = SpiderDifficulties.allDifficulties[i];
|
||||
final currentRanks = allRankResults[i];
|
||||
final int levelIndex = level.levelIndex;
|
||||
final int oldRank = oldRankMap[levelIndex] ?? 0;
|
||||
int currentRank = 0;
|
||||
int myRankIndex = currentRanks.indexWhere((r) => r.playerName == myName);
|
||||
if (myRankIndex != -1) { currentRank = myRankIndex + 1; }
|
||||
newRankMapForStorage[levelIndex] = currentRank;
|
||||
newRankHistoryForState[levelIndex] = (oldRank, currentRank);
|
||||
}
|
||||
await _identityService.saveLastRankMap(newRankMapForStorage, gameType: 'SPIDER');
|
||||
if (mounted) { setState(() { _rankHistory = newRankHistoryForState; }); }
|
||||
log("스파이더 랭킹 변동 확인 완료. (유저: $myName)");
|
||||
} catch (e) {
|
||||
log("SpiderLobbyScreen: 랭킹 확인 실패: $e");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 🔽 [수정] _startGame 메서드 (UserInfo 주입)
|
||||
Future<void> _startGame(SpiderDifficulty level) async {
|
||||
setState(() { _isLoading = true; });
|
||||
|
||||
// 1. [수정] 랭킹 등록에 필요한 정보 미리 로드
|
||||
final String userId = await _identityService.getOrCreateUserId();
|
||||
final String? userName = _userName; // (이미 _loadProgress에서 로드됨)
|
||||
|
||||
// 2. 컨트롤러 생성 및 새 게임 시작
|
||||
final gameController = SpiderGameController();
|
||||
gameController.setUserInfo(userId, userName); // 👈 유저 정보 주입
|
||||
gameController.startNewGame(level);
|
||||
|
||||
setState(() { _isLoading = false; });
|
||||
if (!mounted) return;
|
||||
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
ChangeNotifierProvider.value(
|
||||
value: gameController,
|
||||
child: const SpiderGameScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
_loadProgress();
|
||||
}
|
||||
|
||||
// ( build 메서드는 이전과 동일 )
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
context.watch<ThemeNotifier>();
|
||||
final bool allLevelsUnlocked = _maxUnlockedLevel >= 9;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return CommonGameShell(
|
||||
title: '스파이더 솔리테어',
|
||||
onRankingPressed: () {
|
||||
final List<GameDifficulty> spiderDifficulties = SpiderDifficulties.allDifficulties
|
||||
.map((level) => GameDifficulty(
|
||||
name: level.name,
|
||||
contextId: level.contextId,
|
||||
))
|
||||
.toList();
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => RankingScreen(
|
||||
gameType: 'SPIDER',
|
||||
difficulties: spiderDifficulties,
|
||||
initialDifficultyName: SpiderDifficulties.getLevel(_maxUnlockedLevel).name,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
const double maxContentRatio = 0.6;
|
||||
final double constrainedWidth = (constraints.maxHeight * maxContentRatio) > 500
|
||||
? 500 : (constraints.maxHeight * maxContentRatio);
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: constrainedWidth),
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: SpiderDifficulties.allDifficulties.length,
|
||||
itemBuilder: (context, index) {
|
||||
final SpiderDifficulty level = SpiderDifficulties.allDifficulties[index];
|
||||
final bool isUnlocked = allLevelsUnlocked || level.levelIndex <= _maxUnlockedLevel;
|
||||
final (int oldRank, int currentRank) = _rankHistory[level.levelIndex] ?? (0, 0);
|
||||
Widget? trailingWidget = isUnlocked ? const Icon(Icons.play_arrow_rounded) : null;
|
||||
String? subtitleText;
|
||||
Color? subtitleColor;
|
||||
if (currentRank > 0) {
|
||||
String rankStr = "${currentRank}위";
|
||||
if (oldRank > 0) {
|
||||
int change = oldRank - currentRank;
|
||||
if (change > 0) {
|
||||
subtitleText = "$rankStr (▲ $change)";
|
||||
subtitleColor = Colors.green;
|
||||
trailingWidget = const Icon(Icons.arrow_circle_up_rounded, color: Colors.green, size: 28);
|
||||
} else if (change < 0) {
|
||||
subtitleText = "$rankStr (▼ ${change.abs()})";
|
||||
subtitleColor = Colors.red;
|
||||
trailingWidget = const Icon(Icons.arrow_circle_down_rounded, color: Colors.red, size: 28);
|
||||
} else {
|
||||
subtitleText = "$rankStr (유지)";
|
||||
subtitleColor = Colors.grey;
|
||||
trailingWidget = const Icon(Icons.check_circle_outline_rounded, color: Colors.grey, size: 28);
|
||||
}
|
||||
} else {
|
||||
subtitleText = "$rankStr (신규 진입)";
|
||||
subtitleColor = Colors.blue;
|
||||
trailingWidget = const Icon(Icons.new_releases_rounded, color: Colors.blue, size: 28);
|
||||
}
|
||||
} else {
|
||||
if (oldRank > 0) {
|
||||
subtitleText = "랭킹 이탈 (이전 ${oldRank}위)";
|
||||
subtitleColor = Colors.orange;
|
||||
trailingWidget = const Icon(Icons.warning_amber_rounded, color: Colors.orange, size: 28);
|
||||
}
|
||||
}
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0),
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
isUnlocked ? Icons.lock_open_rounded : Icons.lock_rounded,
|
||||
color: isUnlocked ? theme.primaryColor : Colors.grey,
|
||||
),
|
||||
title: Text(level.name, style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: isUnlocked ? FontWeight.bold : FontWeight.normal,
|
||||
color: isUnlocked ? theme.textTheme.bodyLarge?.color : Colors.grey,
|
||||
)),
|
||||
subtitle: subtitleText != null
|
||||
? Text(subtitleText, style: TextStyle(color: subtitleColor, fontWeight: FontWeight.bold))
|
||||
: null,
|
||||
trailing: trailingWidget,
|
||||
onTap: isUnlocked && !_isLoading
|
||||
? () => _startGame(level)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// packages/feature_game_spider/lib/widgets/bottom_bar_widget.dart
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/spider_card.dart';
|
||||
import '../controllers/spider_game_controller.dart';
|
||||
import 'card_widget.dart';
|
||||
|
||||
class BottomBarWidget extends StatelessWidget {
|
||||
final double cardWidth;
|
||||
final double cardHeight;
|
||||
|
||||
const BottomBarWidget({
|
||||
super.key, // 👈 GameScreen에서 _stockKey가 전달됨
|
||||
required this.cardWidth,
|
||||
required this.cardHeight,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = Provider.of<SpiderGameController>(context);
|
||||
final state = controller.currentState;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
height: cardHeight + 20,
|
||||
color: theme.bottomAppBarTheme.color,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// 1. 파운데이션 (왼쪽)
|
||||
_buildFoundationPiles(context, state.foundation),
|
||||
|
||||
// 2. 이동 횟수
|
||||
Text(
|
||||
"이동: ${state.moves}",
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onSurface,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold
|
||||
),
|
||||
),
|
||||
|
||||
// 3. 스톡 (오른쪽)
|
||||
// 🔽 [수정] _buildStockPile에 key 전달
|
||||
_buildStockPile(context, controller, state.stock, key),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 🔽 [수정] Key? key 파라미터 추가
|
||||
Widget _buildStockPile(BuildContext context, SpiderGameController controller, List<SpiderCard> stock, Key? key) {
|
||||
return GestureDetector(
|
||||
key: key, // 👈 [수정] GameScreen에서 전달받은 _stockKey를 여기에 할당
|
||||
onTap: (){
|
||||
debugPrint("[LOG] BottomBarWidget: Stock pile tapped!");
|
||||
controller.dealFromStock();
|
||||
},
|
||||
child: Container(
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).primaryColor,
|
||||
border: Border.all(color: Colors.white54, width: 0.5),
|
||||
borderRadius: BorderRadius.circular(cardWidth * 0.08),
|
||||
),
|
||||
child: (stock.isEmpty)
|
||||
? Center(child: Icon(Icons.block, color: Colors.white.withOpacity(0.5), size: cardWidth * 0.5))
|
||||
: Center(
|
||||
child: Text(
|
||||
"${(stock.length / 10).ceil()}",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: cardWidth * 0.5,
|
||||
fontWeight: FontWeight.bold,
|
||||
shadows: [Shadow(blurRadius: 2, color: Colors.black.withOpacity(0.5))]
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 🔽 [수정] key 파라미터 제거 (Foundation은 위치 계산이 필요 없음)
|
||||
Widget _buildFoundationPiles(BuildContext context, List<List<SpiderCard>> foundation) {
|
||||
return SizedBox(
|
||||
// key: foundationKey, (제거)
|
||||
width: (cardWidth * 0.7) * 4 + cardWidth,
|
||||
height: cardHeight,
|
||||
child: Stack(
|
||||
children: List.generate(8, (index) {
|
||||
return Positioned(
|
||||
left: index * (cardWidth * 0.15),
|
||||
child: Container(
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.white54, width: 0.5),
|
||||
borderRadius: BorderRadius.circular(cardWidth * 0.08),
|
||||
color: Colors.black.withOpacity(0.2),
|
||||
),
|
||||
child: (foundation.length > index && foundation[index].isNotEmpty)
|
||||
? CardWidget(
|
||||
card: foundation[index].last..isFaceUp=true,
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
isDraggable: false, // 👈 [추가]
|
||||
)
|
||||
: Center(child: Icon(Icons.diamond_outlined, color: Colors.white.withOpacity(0.3), size: cardWidth * 0.3)),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
// packages/feature_game_spider/lib/widgets/card_widget.dart
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/spider_card.dart';
|
||||
import '../controllers/spider_game_controller.dart';
|
||||
|
||||
class CardWidget extends StatelessWidget {
|
||||
final SpiderCard card;
|
||||
final double width;
|
||||
final double height;
|
||||
final bool isDraggable; // 👈 [추가]
|
||||
|
||||
const CardWidget({
|
||||
super.key,
|
||||
required this.card,
|
||||
required this.width,
|
||||
required this.height,
|
||||
this.isDraggable = true, // 👈 [추가] 기본값은 true
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 🔽 [수정] isDraggable이 false이면, 드래그 기능 없이 카드 앞면만 즉시 반환
|
||||
if (!isDraggable) {
|
||||
return _buildCardFace(context, card);
|
||||
}
|
||||
|
||||
// --- (isDraggable이 true일 때만 아래 로직 실행) ---
|
||||
final controller = Provider.of<SpiderGameController>(context, listen: false);
|
||||
|
||||
final List<SpiderCard> draggableStack = controller.getDraggableStack(card);
|
||||
final bool canDrag = draggableStack.isNotEmpty;
|
||||
|
||||
return Draggable<List<SpiderCard>>(
|
||||
data: draggableStack,
|
||||
|
||||
// 🔽 [수정] 겹침 높이 계산을 0.4로 수정
|
||||
feedback: Opacity(
|
||||
opacity: 0.8,
|
||||
child: SizedBox(
|
||||
width: width,
|
||||
height: height + (draggableStack.length - 1) * (height * 0.4), // 👈 0.22 -> 0.4
|
||||
child: Stack(
|
||||
children: List.generate(draggableStack.length, (index) {
|
||||
return Positioned(
|
||||
top: index * (height * 0.4), // 👈 0.22 -> 0.4
|
||||
left: 0,
|
||||
// 🔽 [수정] 여기는 CardWidget이 아닌 _buildCardFace를 직접 호출 (중첩 Draggable 방지)
|
||||
child: _buildCardFace(context, draggableStack[index]),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
childWhenDragging: _buildCardPlaceholder(context),
|
||||
child: (card.isBeingDragged)
|
||||
? _buildCardPlaceholder(context)
|
||||
: _buildCardFace(context, card),
|
||||
|
||||
onDragStarted: () {
|
||||
if (canDrag) {
|
||||
controller.onDragStarted(draggableStack);
|
||||
}
|
||||
},
|
||||
onDraggableCanceled: (velocity, offset) {
|
||||
controller.onDragCancelled();
|
||||
},
|
||||
onDragEnd: (details) {
|
||||
if (controller.draggedCards.isNotEmpty) {
|
||||
controller.onDragCancelled();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ( _buildCardFace, _buildRankText, _buildCenterSymbols 는 이전과 동일 )
|
||||
Widget _buildCardFace(BuildContext context, SpiderCard card) {
|
||||
return Container(
|
||||
width: width,
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
color: card.isFaceUp ? Colors.white : Theme.of(context).primaryColor,
|
||||
border: Border.all(color: Colors.black54, width: 0.5),
|
||||
borderRadius: BorderRadius.circular(width * 0.08),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.2),
|
||||
blurRadius: 2,
|
||||
offset: const Offset(1, 1),
|
||||
)
|
||||
],
|
||||
),
|
||||
child: card.isFaceUp
|
||||
? Stack(
|
||||
children: [
|
||||
_buildRankText(card, Alignment.topLeft),
|
||||
_buildRankText(card, Alignment.bottomRight),
|
||||
_buildCenterSymbols(card),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
Widget _buildRankText(SpiderCard card, Alignment alignment) {
|
||||
final bool isTopLeft = alignment == Alignment.topLeft;
|
||||
final double fontSize = width * 0.4;
|
||||
final double padding = width * 0.05;
|
||||
Widget content = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
card.rankText,
|
||||
style: TextStyle(
|
||||
color: card.isRed ? Colors.red : Colors.black,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: fontSize,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
card.suitSymbol,
|
||||
style: TextStyle(
|
||||
color: card.isRed ? Colors.red : Colors.black,
|
||||
fontSize: fontSize * 0.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
if (!isTopLeft) {
|
||||
content = Transform.rotate(
|
||||
angle: math.pi,
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
return Positioned(
|
||||
top: isTopLeft ? padding : null,
|
||||
left: isTopLeft ? padding : null,
|
||||
bottom: isTopLeft ? null : padding,
|
||||
right: isTopLeft ? null : padding,
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
Widget _buildCenterSymbols(SpiderCard card) {
|
||||
final double symbolSize = width * 0.2;
|
||||
final double bigSymbolSize = width * 0.7;
|
||||
if (card.rank > 10) {
|
||||
return Center(
|
||||
child: Text(
|
||||
card.suitSymbol,
|
||||
style: TextStyle(
|
||||
color: card.isRed ? Colors.red : Colors.black,
|
||||
fontSize: bigSymbolSize,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (card.rank == 1) {
|
||||
return Center(
|
||||
child: Text(
|
||||
card.suitSymbol,
|
||||
style: TextStyle(
|
||||
color: card.isRed ? Colors.red : Colors.black,
|
||||
fontSize: bigSymbolSize * 0.8,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
List<Widget> symbols = [];
|
||||
Widget symbol(Alignment align) {
|
||||
return Align(
|
||||
alignment: align,
|
||||
child: Text(
|
||||
card.suitSymbol,
|
||||
style: TextStyle(
|
||||
color: card.isRed ? Colors.red : Colors.black,
|
||||
fontSize: symbolSize,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
Widget flippedSymbol(Alignment align) {
|
||||
return Align(
|
||||
alignment: align,
|
||||
child: Transform.rotate(
|
||||
angle: math.pi,
|
||||
child: Text(
|
||||
card.suitSymbol,
|
||||
style: TextStyle(
|
||||
color: card.isRed ? Colors.red : Colors.black,
|
||||
fontSize: symbolSize,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
switch (card.rank) {
|
||||
case 2:
|
||||
symbols.add(symbol(Alignment.topCenter));
|
||||
symbols.add(flippedSymbol(Alignment.bottomCenter));
|
||||
break;
|
||||
case 3:
|
||||
symbols.add(symbol(Alignment.topCenter));
|
||||
symbols.add(symbol(Alignment.center));
|
||||
symbols.add(flippedSymbol(Alignment.bottomCenter));
|
||||
break;
|
||||
case 4:
|
||||
symbols.add(symbol(Alignment.topLeft));
|
||||
symbols.add(symbol(Alignment.topRight));
|
||||
symbols.add(flippedSymbol(Alignment.bottomLeft));
|
||||
symbols.add(flippedSymbol(Alignment.bottomRight));
|
||||
break;
|
||||
case 5:
|
||||
symbols.addAll([
|
||||
symbol(Alignment.topLeft),
|
||||
symbol(Alignment.topRight),
|
||||
symbol(Alignment.center),
|
||||
flippedSymbol(Alignment.bottomLeft),
|
||||
flippedSymbol(Alignment.bottomRight),
|
||||
]);
|
||||
break;
|
||||
case 6:
|
||||
symbols.addAll([
|
||||
symbol(Alignment.topLeft),
|
||||
symbol(Alignment.topRight),
|
||||
symbol(Alignment.centerLeft),
|
||||
symbol(Alignment.centerRight),
|
||||
flippedSymbol(Alignment.bottomLeft),
|
||||
flippedSymbol(Alignment.bottomRight),
|
||||
]);
|
||||
break;
|
||||
case 7:
|
||||
symbols.addAll([
|
||||
symbol(Alignment.topLeft),
|
||||
symbol(Alignment.topRight),
|
||||
symbol(const Alignment(0.0, -0.25)),
|
||||
symbol(Alignment.centerLeft),
|
||||
symbol(Alignment.centerRight),
|
||||
flippedSymbol(Alignment.bottomLeft),
|
||||
flippedSymbol(Alignment.bottomRight),
|
||||
]);
|
||||
break;
|
||||
case 8:
|
||||
symbols.addAll([
|
||||
symbol(Alignment.topLeft),
|
||||
symbol(Alignment.topRight),
|
||||
symbol(const Alignment(0.0, -0.25)),
|
||||
symbol(Alignment.centerLeft),
|
||||
symbol(Alignment.centerRight),
|
||||
flippedSymbol(Alignment.bottomLeft),
|
||||
flippedSymbol(Alignment.bottomRight),
|
||||
flippedSymbol(const Alignment(0.0, 0.25)),
|
||||
]);
|
||||
break;
|
||||
case 9:
|
||||
symbols.addAll([
|
||||
symbol(const Alignment(-0.8, -0.6)),
|
||||
symbol(const Alignment(0.8, -0.6)),
|
||||
symbol(const Alignment(-0.8, 0.0)),
|
||||
symbol(const Alignment(0.8, 0.0)),
|
||||
symbol(Alignment.center),
|
||||
flippedSymbol(const Alignment(-0.8, 0.6)),
|
||||
flippedSymbol(const Alignment(0.8, 0.6)),
|
||||
symbol(const Alignment(0.0, -0.8)),
|
||||
flippedSymbol(const Alignment(0.0, 0.8)),
|
||||
]);
|
||||
break;
|
||||
case 10:
|
||||
symbols.addAll([
|
||||
symbol(const Alignment(-0.8, -0.7)),
|
||||
symbol(const Alignment(0.8, -0.7)),
|
||||
symbol(const Alignment(-0.8, -0.1)),
|
||||
symbol(const Alignment(0.8, -0.1)),
|
||||
symbol(const Alignment(0.0, -0.9)),
|
||||
symbol(const Alignment(0.0, -0.4)),
|
||||
flippedSymbol(const Alignment(-0.8, 0.7)),
|
||||
flippedSymbol(const Alignment(0.8, 0.7)),
|
||||
flippedSymbol(const Alignment(0.0, 0.9)),
|
||||
flippedSymbol(const Alignment(0.0, 0.4)),
|
||||
]);
|
||||
break;
|
||||
}
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: width * 0.2, vertical: height * 0.15),
|
||||
child: Stack(children: symbols),
|
||||
);
|
||||
}
|
||||
Widget _buildCardPlaceholder(BuildContext context) {
|
||||
return Container(
|
||||
width: width,
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).scaffoldBackgroundColor.withOpacity(0.5),
|
||||
borderRadius: BorderRadius.circular(width * 0.08),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// packages/feature_game_spider/lib/widgets/tableau_pile_widget.dart
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/spider_card.dart';
|
||||
import '../controllers/spider_game_controller.dart';
|
||||
import 'card_widget.dart';
|
||||
|
||||
class TableauPileWidget extends StatelessWidget {
|
||||
final int pileIndex;
|
||||
final List<SpiderCard> cards;
|
||||
final double cardWidth;
|
||||
final double cardHeight;
|
||||
final double cardOverlap;
|
||||
|
||||
const TableauPileWidget({
|
||||
super.key,
|
||||
required this.pileIndex,
|
||||
required this.cards,
|
||||
required this.cardWidth,
|
||||
required this.cardHeight,
|
||||
required this.cardOverlap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 🔽 [수정] 'context.watch'를 사용하여 컨트롤러의 애니메이션 상태를 실시간으로 감지
|
||||
final controller = context.watch<SpiderGameController>();
|
||||
|
||||
return DragTarget<List<SpiderCard>>(
|
||||
onWillAccept: (draggedCards) {
|
||||
if (draggedCards == null) return false;
|
||||
|
||||
if (controller.cardsToDealAnimate.isNotEmpty ||
|
||||
controller.cardsToAnimateStack.isNotEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return controller.isValidMove(draggedCards, pileIndex);
|
||||
},
|
||||
onAccept: (draggedCards) {
|
||||
controller.onCardsDropped(draggedCards, pileIndex);
|
||||
},
|
||||
builder: (context, candidateData, rejectedData) {
|
||||
|
||||
final bool isAnimating = controller.cardsToDealAnimate.isNotEmpty ||
|
||||
controller.cardsToAnimateStack.isNotEmpty;
|
||||
final bool isHighlighted = candidateData.isNotEmpty && !isAnimating;
|
||||
|
||||
// 🔽 [로그 추가] "초록 선"의 원인을 추적합니다.
|
||||
// (하이라이트되거나, 애니메이션 중이거나, 드래그가 감지되면 로그 출력)
|
||||
if (candidateData.isNotEmpty || isAnimating || isHighlighted) {
|
||||
debugPrint("[LOG] TableauPileWidget (Pile $pileIndex): "
|
||||
"candidateData.isNotEmpty = ${candidateData.isNotEmpty}, "
|
||||
"isAnimating = $isAnimating, "
|
||||
"==> isHighlighted = $isHighlighted");
|
||||
}
|
||||
|
||||
return Container(
|
||||
width: cardWidth,
|
||||
constraints: BoxConstraints(
|
||||
minHeight: cardHeight,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isHighlighted
|
||||
? Colors.green.withOpacity(0.3)
|
||||
: (cards.isEmpty ? Colors.black.withOpacity(0.1) : null),
|
||||
borderRadius: BorderRadius.circular(cardWidth * 0.08),
|
||||
),
|
||||
child: Stack(
|
||||
children: List.generate(cards.length, (index) {
|
||||
final card = cards[index];
|
||||
return Positioned(
|
||||
top: index * cardOverlap,
|
||||
left: 0,
|
||||
child: CardWidget(
|
||||
card: card,
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
# packages/feature_game_spider/pubspec.yaml
|
||||
|
||||
name: feature_game_spider
|
||||
description: The Spider Solitaire game feature, using WebView and local assets.
|
||||
publish_to: 'none'
|
||||
resolution: workspace
|
||||
|
||||
version: 1.0.0+1
|
||||
|
||||
environment:
|
||||
sdk: '^3.9.2' # (루트와 동일하게)
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
# [C] 공통 서비스
|
||||
service_api:
|
||||
path: ../service_api
|
||||
|
||||
# [A] 공통 UI 셸
|
||||
feature_common:
|
||||
path: ../feature_common
|
||||
|
||||
# 상태 관리
|
||||
provider: ^6.0.0
|
||||
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^3.0.0
|
||||
|
||||
# 🔽 [추가] 로컬 HTML/CSS/JS 파일을 앱에 포함
|
||||
flutter:
|
||||
assets:
|
||||
- assets/spider_game/
|
||||
# (CSS/이미지 등 하위 폴더가 있다면 그것도 명시)
|
||||
- assets/spider_game/css/images/
|
||||
@@ -0,0 +1,12 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:feature_game_spider/feature_game_spider.dart';
|
||||
|
||||
void main() {
|
||||
test('adds one to input values', () {
|
||||
final calculator = Calculator();
|
||||
expect(calculator.addOne(2), 3);
|
||||
expect(calculator.addOne(-7), -6);
|
||||
expect(calculator.addOne(0), 1);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user