This commit is contained in:
2025-11-14 18:03:50 +09:00
parent 1f5cea9a96
commit 13ed537b23
342 changed files with 18293 additions and 0 deletions
+31
View File
@@ -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/
+10
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
TODO: Add your license here.
+39
View File
@@ -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,
),
);
}),
),
);
},
);
}
}
+39
View File
@@ -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);
});
}