...
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,197 @@
|
||||
// packages/feature_game_cardflip/lib/controllers/cardflip_controller.dart
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/cardflip_models.dart';
|
||||
|
||||
class CardFlipController with ChangeNotifier {
|
||||
late CardFlipDifficulty difficulty;
|
||||
late String userId;
|
||||
late String? userName;
|
||||
|
||||
List<CardItem> _cards = [];
|
||||
List<CardItem> get cards => _cards;
|
||||
|
||||
CardItem? _firstFlippedCard;
|
||||
|
||||
bool _isProcessing = false;
|
||||
bool get isProcessing => _isProcessing;
|
||||
|
||||
bool _isGameCompleted = false;
|
||||
bool get isGameCompleted => _isGameCompleted;
|
||||
|
||||
bool _isTimeOut = false;
|
||||
bool get isTimeOut => _isTimeOut;
|
||||
|
||||
int _flipCount = 0;
|
||||
int get flipCount => _flipCount;
|
||||
|
||||
Timer? _timer;
|
||||
int _secondsElapsed = 0;
|
||||
int _remainingTime = 0;
|
||||
int get remainingTime => _remainingTime;
|
||||
|
||||
// [🔥 신규] 게임이 시작되었는지(타이머가 도는지) 여부
|
||||
bool _isGameStarted = false;
|
||||
bool get isGameStarted => _isGameStarted;
|
||||
|
||||
void setUserInfo(String userId, String? userName) {
|
||||
this.userId = userId;
|
||||
this.userName = userName;
|
||||
}
|
||||
|
||||
void startNewGame(CardFlipDifficulty level) {
|
||||
difficulty = level;
|
||||
_flipCount = 0;
|
||||
_secondsElapsed = 0;
|
||||
_remainingTime = level.timeLimitSeconds;
|
||||
_isGameCompleted = false;
|
||||
_isTimeOut = false;
|
||||
_isProcessing = false;
|
||||
_isGameStarted = false; // 타이머 대기 상태
|
||||
_firstFlippedCard = null;
|
||||
|
||||
_generateCards();
|
||||
// [🔥 수정] startNewGame에서는 타이머를 시작하지 않음 (가이드 확인 후 시작)
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void restartGame() {
|
||||
startNewGame(difficulty);
|
||||
// 재시작 시에는 가이드 없이 바로 시작하고 싶다면 여기서 startTimer 호출
|
||||
// 하지만 일관성을 위해 UI에서 다시 가이드를 띄우도록 유도
|
||||
}
|
||||
|
||||
// [🔥 수정] Public으로 변경 (UI에서 호출)
|
||||
void startGameTimer() {
|
||||
if (_isGameStarted) return;
|
||||
_isGameStarted = true;
|
||||
|
||||
_timer?.cancel();
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
_secondsElapsed++;
|
||||
_remainingTime--;
|
||||
|
||||
if (_remainingTime <= 0) {
|
||||
_timer?.cancel();
|
||||
_isTimeOut = true;
|
||||
_isGameCompleted = true;
|
||||
}
|
||||
notifyListeners();
|
||||
});
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// 🔽 [🔥 핵심] 카드 생성 로직 (타입별 분기)
|
||||
void _generateCards() {
|
||||
final int totalCards = difficulty.totalCards;
|
||||
final int pairsCount = totalCards ~/ 2;
|
||||
List<CardItem> deck = [];
|
||||
|
||||
if (difficulty.contentType == CardContentType.calculation) {
|
||||
// 1. 연산 모드 (식 ↔ 답)
|
||||
var entries = CardFlipDifficulties.calculationPairs.entries.toList()..shuffle();
|
||||
for (int i = 0; i < pairsCount; i++) {
|
||||
var entry = entries[i % entries.length];
|
||||
String matchKey = "CALC_$i"; // 논리적 ID
|
||||
// 식 카드
|
||||
deck.add(CardItem(id: 0, matchId: matchKey, displayContent: entry.key));
|
||||
// 답 카드
|
||||
deck.add(CardItem(id: 0, matchId: matchKey, displayContent: entry.value));
|
||||
}
|
||||
}
|
||||
else if (difficulty.contentType == CardContentType.pairWord) {
|
||||
// 2. 연상 모드 (A ↔ B)
|
||||
var entries = CardFlipDifficulties.wordPairs.entries.toList()..shuffle();
|
||||
for (int i = 0; i < pairsCount; i++) {
|
||||
var entry = entries[i % entries.length];
|
||||
String matchKey = "PAIR_$i";
|
||||
// 단어 A
|
||||
deck.add(CardItem(id: 0, matchId: matchKey, displayContent: entry.key));
|
||||
// 단어 B
|
||||
deck.add(CardItem(id: 0, matchId: matchKey, displayContent: entry.value));
|
||||
}
|
||||
}
|
||||
else {
|
||||
// 3. 동일 매칭 모드 (이모지, 아이콘, 숫자)
|
||||
List<String> pool = List.of(CardFlipDifficulties.emojis)..shuffle();
|
||||
for (int i = 0; i < pairsCount; i++) {
|
||||
String content;
|
||||
String matchKey = "SAME_$i";
|
||||
|
||||
if (difficulty.contentType == CardContentType.number) {
|
||||
content = (i + 1).toString();
|
||||
} else if (difficulty.contentType == CardContentType.icon) {
|
||||
content = "ICON_$i";
|
||||
} else {
|
||||
content = pool[i % pool.length];
|
||||
}
|
||||
|
||||
// 똑같은 카드 2장
|
||||
deck.add(CardItem(id: 0, matchId: matchKey, displayContent: content));
|
||||
deck.add(CardItem(id: 0, matchId: matchKey, displayContent: content));
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 전체 섞기 및 ID 부여
|
||||
deck.shuffle(Random());
|
||||
for (int i = 0; i < deck.length; i++) {
|
||||
// 기존 객체를 복사하며 고유 ID 부여
|
||||
deck[i] = CardItem(
|
||||
id: i,
|
||||
matchId: deck[i].matchId,
|
||||
displayContent: deck[i].displayContent
|
||||
);
|
||||
}
|
||||
_cards = deck;
|
||||
}
|
||||
|
||||
// 🔽 [핵심] 카드 뒤집기 로직
|
||||
void onCardTapped(CardItem card) {
|
||||
// 게임이 시작되지 않았거나, 이미 완료되었거나, 처리 중이면 무시
|
||||
if (!_isGameStarted || _isGameCompleted || _isProcessing || card.isFaceUp || card.isMatched) return;
|
||||
|
||||
card.isFaceUp = true;
|
||||
_flipCount++;
|
||||
notifyListeners();
|
||||
|
||||
if (_firstFlippedCard == null) {
|
||||
_firstFlippedCard = card;
|
||||
} else {
|
||||
_isProcessing = true;
|
||||
_checkMatch(_firstFlippedCard!, card);
|
||||
_firstFlippedCard = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 🔽 [🔥 수정] 매칭 로직: matchId로 비교
|
||||
void _checkMatch(CardItem card1, CardItem card2) {
|
||||
if (card1.matchId == card2.matchId) {
|
||||
// 정답
|
||||
card1.isMatched = true;
|
||||
card2.isMatched = true;
|
||||
_isProcessing = false;
|
||||
|
||||
if (_cards.every((c) => c.isMatched)) {
|
||||
_isGameCompleted = true;
|
||||
_timer?.cancel();
|
||||
}
|
||||
notifyListeners();
|
||||
} else {
|
||||
// 오답
|
||||
Future.delayed(const Duration(milliseconds: 800), () {
|
||||
card1.isFaceUp = false;
|
||||
card2.isFaceUp = false;
|
||||
_isProcessing = false;
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export 'screens/cardflip_lobby_screen.dart';
|
||||
@@ -0,0 +1,111 @@
|
||||
// packages/feature_game_cardflip/lib/models/cardflip_models.dart
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:service_api/service_api.dart';
|
||||
|
||||
/// 카드에 들어갈 콘텐츠 타입
|
||||
enum CardContentType {
|
||||
emoji, // 기존: 똑같은 이모지 (🐰 ↔ 🐰)
|
||||
icon, // 기존: 똑같은 아이콘 (⭐️ ↔ ⭐️)
|
||||
number, // 기존: 똑같은 숫자 (1 ↔ 1)
|
||||
calculation, // [🔥 신규] 연산 (3+4 ↔ 7)
|
||||
pairWord, // [🔥 신규] 연상 단어 (토끼 ↔ 당근)
|
||||
}
|
||||
|
||||
/// 개별 카드 상태 모델
|
||||
class CardItem {
|
||||
final int id; // 카드의 고유 식별자 (GridView 인덱스와 무관, 셔플됨)
|
||||
final String matchId; // [🔥 신규] 매칭 판단용 ID (이게 같으면 정답)
|
||||
final String displayContent; // [🔥 신규] 화면에 보여질 내용
|
||||
|
||||
bool isFaceUp;
|
||||
bool isMatched;
|
||||
|
||||
CardItem({
|
||||
required this.id,
|
||||
required this.matchId,
|
||||
required this.displayContent,
|
||||
this.isFaceUp = false,
|
||||
this.isMatched = false,
|
||||
});
|
||||
}
|
||||
|
||||
class CardFlipDifficulty extends GameDifficulty {
|
||||
final int levelIndex;
|
||||
final int rows;
|
||||
final int cols;
|
||||
final int timeLimitSeconds;
|
||||
final CardContentType contentType;
|
||||
|
||||
const CardFlipDifficulty({
|
||||
required this.levelIndex,
|
||||
required super.name,
|
||||
required super.contextId,
|
||||
required this.rows,
|
||||
required this.cols,
|
||||
required this.timeLimitSeconds,
|
||||
required this.contentType,
|
||||
});
|
||||
|
||||
int get totalCards => rows * cols;
|
||||
}
|
||||
|
||||
class CardFlipDifficulties {
|
||||
// --- 콘텐츠 풀 ---
|
||||
static const List<String> emojis = [
|
||||
"🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", "🐨", "🐯",
|
||||
"🦁", "🐮", "🐷", "🐸", "🐵", "🐔", "🐧", "🐦", "🐤", "🦆",
|
||||
"🍎", "🍌", "🍇", "🍓", "🍊", "🍋", "🍉", "🍑", "🍒", "🥝",
|
||||
"⚽", "🏀", "🏈", "⚾", "🎾", "🏐", "🏉", "🎱", "🏓", "🏸"
|
||||
];
|
||||
|
||||
// [🔥 신규] 연산 문제 풀 (질문 : 정답)
|
||||
static const Map<String, String> calculationPairs = {
|
||||
"2 + 3": "5", "5 + 5": "10", "7 - 2": "5", "3 x 3": "9", "10 - 4": "6",
|
||||
"6 + 6": "12", "8 + 1": "9", "4 x 2": "8", "15 - 5": "10", "20 / 2": "10",
|
||||
"1 + 1": "2", "9 - 3": "6", "2 x 5": "10", "8 / 2": "4", "3 + 7": "10"
|
||||
};
|
||||
|
||||
// [🔥 신규] 연상 단어 풀 (A : B)
|
||||
static const Map<String, String> wordPairs = {
|
||||
"토끼": "당근", "원숭이": "바나나", "한국": "서울", "미국": "워싱턴",
|
||||
"해": "달", "여름": "겨울", "남자": "여자", "학교": "학생",
|
||||
"병원": "의사", "바늘": "실", "우산": "비", "책상": "의자",
|
||||
"봄": "꽃", "가을": "단풍", "숟가락": "젓가락"
|
||||
};
|
||||
|
||||
// --- 난이도 목록 (15단계) ---
|
||||
static final List<CardFlipDifficulty> allDifficulties = [
|
||||
// Phase 1: 입문 (동일 매칭)
|
||||
const CardFlipDifficulty(levelIndex: 1, name: 'Lv. 1: 입문 (이모지 12)', contextId: 'FLIP_L1_EMOJI', rows: 4, cols: 3, timeLimitSeconds: 40, contentType: CardContentType.emoji),
|
||||
const CardFlipDifficulty(levelIndex: 2, name: 'Lv. 2: 초급 (아이콘 12)', contextId: 'FLIP_L2_ICON', rows: 4, cols: 3, timeLimitSeconds: 30, contentType: CardContentType.icon),
|
||||
const CardFlipDifficulty(levelIndex: 3, name: 'Lv. 3: 기초 (숫자 16)', contextId: 'FLIP_L3_NUM', rows: 4, cols: 4, timeLimitSeconds: 50, contentType: CardContentType.number),
|
||||
|
||||
// Phase 2: 연산/연상 (기억 + 사고)
|
||||
const CardFlipDifficulty(levelIndex: 4, name: 'Lv. 4: 연산 (덧셈/뺄셈)', contextId: 'FLIP_L4_CALC', rows: 4, cols: 4, timeLimitSeconds: 60, contentType: CardContentType.calculation),
|
||||
const CardFlipDifficulty(levelIndex: 5, name: 'Lv. 5: 연상 (짝꿍 단어)', contextId: 'FLIP_L5_PAIR', rows: 4, cols: 4, timeLimitSeconds: 60, contentType: CardContentType.pairWord),
|
||||
const CardFlipDifficulty(levelIndex: 6, name: 'Lv. 6: 도전 (이모지 20)', contextId: 'FLIP_L6_EMOJI_20', rows: 5, cols: 4, timeLimitSeconds: 70, contentType: CardContentType.emoji),
|
||||
|
||||
// Phase 3: 상급 (혼합/확장)
|
||||
const CardFlipDifficulty(levelIndex: 7, name: 'Lv. 7: 상급 (연산 20)', contextId: 'FLIP_L7_CALC_20', rows: 5, cols: 4, timeLimitSeconds: 90, contentType: CardContentType.calculation),
|
||||
const CardFlipDifficulty(levelIndex: 8, name: 'Lv. 8: 전문가 (연상 20)', contextId: 'FLIP_L8_PAIR_20', rows: 5, cols: 4, timeLimitSeconds: 90, contentType: CardContentType.pairWord),
|
||||
const CardFlipDifficulty(levelIndex: 9, name: 'Lv. 9: 엘리트 (아이콘 24)', contextId: 'FLIP_L9_ICON_24', rows: 6, cols: 4, timeLimitSeconds: 100, contentType: CardContentType.icon),
|
||||
|
||||
// Phase 4: 마스터 (대형 그리드)
|
||||
const CardFlipDifficulty(levelIndex: 10, name: 'Lv. 10: 마스터 (24장)', contextId: 'FLIP_L10_6x4', rows: 6, cols: 4, timeLimitSeconds: 100, contentType: CardContentType.emoji),
|
||||
const CardFlipDifficulty(levelIndex: 11, name: 'Lv. 11: 그랜드마스터', contextId: 'FLIP_L11_6x4_CALC', rows: 6, cols: 4, timeLimitSeconds: 110, contentType: CardContentType.calculation),
|
||||
const CardFlipDifficulty(levelIndex: 12, name: 'Lv. 12: 레전드', contextId: 'FLIP_L12_6x4_PAIR', rows: 6, cols: 4, timeLimitSeconds: 110, contentType: CardContentType.pairWord),
|
||||
|
||||
// Phase 5: 신의 영역
|
||||
const CardFlipDifficulty(levelIndex: 13, name: 'Lv. 13: 갓모드 (30장)', contextId: 'FLIP_L13_6x5', rows: 6, cols: 5, timeLimitSeconds: 130, contentType: CardContentType.emoji),
|
||||
const CardFlipDifficulty(levelIndex: 14, name: 'Lv. 14: 타임어택 (연산)', contextId: 'FLIP_L14_6x5_CALC', rows: 6, cols: 5, timeLimitSeconds: 120, contentType: CardContentType.calculation),
|
||||
const CardFlipDifficulty(levelIndex: 15, name: 'Lv. 15: 엔드게임 (연상)', contextId: 'FLIP_L15_6x5_PAIR', rows: 6, cols: 5, timeLimitSeconds: 120, contentType: CardContentType.pairWord),
|
||||
];
|
||||
|
||||
static CardFlipDifficulty 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]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
// packages/feature_game_cardflip/lib/screens/cardflip_game_screen.dart
|
||||
|
||||
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/cardflip_controller.dart';
|
||||
import '../models/cardflip_models.dart';
|
||||
|
||||
class CardFlipGameScreen extends StatefulWidget {
|
||||
const CardFlipGameScreen({super.key});
|
||||
|
||||
@override
|
||||
State<CardFlipGameScreen> createState() => _CardFlipGameScreenState();
|
||||
}
|
||||
|
||||
class _CardFlipGameScreenState extends State<CardFlipGameScreen> {
|
||||
bool _isDialogShowing = false;
|
||||
|
||||
// 아이콘 풀
|
||||
static const List<IconData> _iconPool = [
|
||||
Icons.home, Icons.favorite, Icons.star, Icons.person, Icons.settings,
|
||||
Icons.lock, Icons.map, Icons.camera_alt, Icons.phone, Icons.music_note,
|
||||
Icons.flight, Icons.directions_car, Icons.shopping_cart, Icons.visibility,
|
||||
Icons.delete, Icons.edit, Icons.share, Icons.wifi, Icons.battery_full,
|
||||
Icons.bluetooth, Icons.lightbulb, Icons.wb_sunny, Icons.ac_unit, Icons.access_alarm,
|
||||
Icons.android, Icons.apple, Icons.attach_file, Icons.audiotrack, Icons.beach_access,
|
||||
Icons.cake, Icons.local_pizza, Icons.local_cafe, Icons.local_florist, Icons.local_shipping
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// [🔥 신규] 게임 시작 전 가이드 표시
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_showGameGuide();
|
||||
});
|
||||
}
|
||||
|
||||
// 🔽 [🔥 신규] 게임 가이드 다이얼로그
|
||||
void _showGameGuide() {
|
||||
final controller = context.read<CardFlipController>();
|
||||
final difficulty = controller.difficulty;
|
||||
|
||||
String title = "게임 방법";
|
||||
String message = "두 장의 카드를 뒤집어\n똑같은 그림을 찾으세요.";
|
||||
|
||||
if (difficulty.contentType == CardContentType.calculation) {
|
||||
title = "연산 매칭";
|
||||
message = "카드에 적힌 '계산식'과\n그 '정답'을 짝지어주세요.\n\n예: [2 + 3] ↔ [5]";
|
||||
} else if (difficulty.contentType == CardContentType.pairWord) {
|
||||
title = "연상 매칭";
|
||||
message = "서로 관련있는 '짝꿍 단어'를\n찾아주세요.\n\n예: [토끼] ↔ [당근]";
|
||||
}
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false, // 반드시 확인을 눌러야 함
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(title, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
content: Text(message, style: const TextStyle(fontSize: 16), textAlign: TextAlign.center),
|
||||
actions: [
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
// [🔥 핵심] 가이드 닫으면 타이머 시작
|
||||
controller.startGameTimer();
|
||||
},
|
||||
child: const Text("시작하기"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showGameCompletion(CardFlipController controller) async {
|
||||
String formatScore(int primary, int? secondary) {
|
||||
return '남은 시간: ${primary}초 (시도: $secondary회)';
|
||||
}
|
||||
|
||||
Future<void> saveProgress(String playerName) async {
|
||||
if (controller.isTimeOut) return;
|
||||
|
||||
final identityService = IdentityService();
|
||||
final int currentMaxLevel = await identityService.getMaxUnlockedLevel(gameType: 'CARD_FLIP');
|
||||
if (currentMaxLevel < 99) {
|
||||
if (controller.difficulty.levelIndex >= currentMaxLevel) {
|
||||
int nextLevel = controller.difficulty.levelIndex + 1;
|
||||
if (nextLevel > CardFlipDifficulties.allDifficulties.length) {
|
||||
await identityService.saveMaxUnlockedLevel(99, gameType: 'CARD_FLIP');
|
||||
} else {
|
||||
await identityService.saveMaxUnlockedLevel(nextLevel, gameType: 'CARD_FLIP');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => GameCompletionScreen(
|
||||
args: GameResultArgs(
|
||||
gameType: 'CARD_FLIP',
|
||||
contextId: controller.difficulty.contextId,
|
||||
primaryScore: controller.remainingTime,
|
||||
secondaryScore: controller.flipCount,
|
||||
userId: controller.userId,
|
||||
userName: controller.userName,
|
||||
scoreFormatter: formatScore,
|
||||
onProgressSave: saveProgress,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (mounted && Navigator.canPop(context)) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = context.watch<CardFlipController>();
|
||||
final theme = Theme.of(context);
|
||||
|
||||
if (controller.isGameCompleted && !_isDialogShowing) {
|
||||
_isDialogShowing = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_showGameCompletion(controller);
|
||||
});
|
||||
}
|
||||
|
||||
final Color timeColor = controller.remainingTime <= 10 ? theme.colorScheme.error : theme.colorScheme.onSurface;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(controller.difficulty.name),
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16.0),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${controller.remainingTime}s',
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: timeColor),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text("뒤집은 횟수: ${controller.flipCount}", style: TextStyle(fontSize: 16, color: theme.textTheme.bodyMedium?.color)),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final crossAxisCount = controller.difficulty.cols;
|
||||
|
||||
return GridView.builder(
|
||||
itemCount: controller.cards.length,
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: crossAxisCount,
|
||||
childAspectRatio: 0.85,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
return _buildCard(controller.cards[index], controller, theme);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCard(CardItem card, CardFlipController controller, ThemeData theme) {
|
||||
return GestureDetector(
|
||||
onTap: () => controller.onCardTapped(card),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
decoration: BoxDecoration(
|
||||
color: card.isFaceUp || card.isMatched
|
||||
? Colors.white
|
||||
: theme.primaryColor,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.black12),
|
||||
boxShadow: [
|
||||
BoxShadow(color: Colors.black12, blurRadius: 4, offset: const Offset(2, 2))
|
||||
],
|
||||
),
|
||||
child: Center(
|
||||
child: (card.isFaceUp || card.isMatched)
|
||||
? _buildCardContent(card)
|
||||
: Icon(Icons.help_outline, color: Colors.white.withOpacity(0.5), size: 32),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 🔽 [🔥 수정] displayContent 사용
|
||||
Widget _buildCardContent(CardItem card) {
|
||||
if (card.displayContent.startsWith("ICON_")) {
|
||||
final int iconIndex = int.tryParse(card.displayContent.split('_')[1]) ?? 0;
|
||||
final IconData icon = _iconPool[iconIndex % _iconPool.length];
|
||||
return Icon(icon, size: 40, color: Colors.orange);
|
||||
} else {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(4.0),
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
card.displayContent, // 👈 displayContent 표시
|
||||
style: const TextStyle(fontSize: 32, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// packages/feature_game_cardflip/lib/screens/cardflip_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 'cardflip_game_screen.dart';
|
||||
import '../models/cardflip_models.dart';
|
||||
import '../controllers/cardflip_controller.dart';
|
||||
|
||||
class CardFlipLobbyScreen extends StatefulWidget {
|
||||
const CardFlipLobbyScreen({super.key});
|
||||
|
||||
@override
|
||||
State<CardFlipLobbyScreen> createState() => _CardFlipLobbyScreenState();
|
||||
}
|
||||
|
||||
class _CardFlipLobbyScreenState extends State<CardFlipLobbyScreen> {
|
||||
int _maxUnlockedLevel = 1;
|
||||
Map<int, (int, int)> _rankHistory = {};
|
||||
bool _isLoading = false;
|
||||
|
||||
late final SessionNotifier _sessionNotifier;
|
||||
late final LobbyHelperService _lobbyHelper;
|
||||
final PuzzleService _puzzleService = PuzzleService();
|
||||
final IdentityService _identityService = IdentityService();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_sessionNotifier = context.read<SessionNotifier>();
|
||||
|
||||
// 헬퍼 서비스 초기화
|
||||
_lobbyHelper = LobbyHelperService(
|
||||
identityService: _identityService,
|
||||
puzzleService: _puzzleService,
|
||||
);
|
||||
|
||||
_loadProgress(forceRefreshRanks: true);
|
||||
}
|
||||
|
||||
/// [공통 로직 사용] 레벨 잠금 상태 및 랭킹 이력 로드
|
||||
Future<void> _loadProgress({bool forceRefreshRanks = false}) async {
|
||||
// 1. 최대 레벨 로드
|
||||
final maxLevel = await _lobbyHelper.loadMaxLevel('CARD_FLIP');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_maxUnlockedLevel = maxLevel;
|
||||
});
|
||||
}
|
||||
|
||||
// 2. 랭킹 이력 로드 (필요한 경우만)
|
||||
if (!forceRefreshRanks) return;
|
||||
final String? myName = _sessionNotifier.session?.userName;
|
||||
if (myName == null) return;
|
||||
|
||||
try {
|
||||
final rankHistory = await _lobbyHelper.loadRankHistory<CardFlipDifficulty>(
|
||||
gameType: 'CARD_FLIP',
|
||||
myName: myName,
|
||||
allLevels: CardFlipDifficulties.allDifficulties,
|
||||
getLevelIndex: (level) => level.levelIndex,
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_rankHistory = rankHistory;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
log("CardFlipLobbyScreen: 랭킹 확인 실패: $e");
|
||||
}
|
||||
}
|
||||
|
||||
/// [게임 시작] 컨트롤러 생성 및 화면 이동
|
||||
Future<void> _startGame(CardFlipDifficulty level) async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
final session = _sessionNotifier.session;
|
||||
if (session == null) {
|
||||
log("세션이 로드되지 않아 게임을 시작할 수 없습니다.");
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final String userId = session.userId;
|
||||
final String? userName = session.userName;
|
||||
|
||||
// 1. 컨트롤러 생성 및 시작
|
||||
final gameController = CardFlipController();
|
||||
gameController.setUserInfo(userId, userName);
|
||||
gameController.startNewGame(level);
|
||||
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
if (!mounted) return;
|
||||
|
||||
// 2. 게임 화면으로 이동 (Controller 주입)
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ChangeNotifierProvider.value(
|
||||
value: gameController,
|
||||
child: const CardFlipGameScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// 3. 게임 종료 후 레벨 잠금 상태만 새로고침
|
||||
_loadProgress(forceRefreshRanks: false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
context.watch<ThemeNotifier>();
|
||||
context.watch<SessionNotifier>();
|
||||
|
||||
final bool allLevelsUnlocked =
|
||||
_maxUnlockedLevel >= CardFlipDifficulties.allDifficulties.length;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return CommonGameShell(
|
||||
title: '카드 뒤집기 (기억력)',
|
||||
onRankingPressed: () {
|
||||
// 랭킹 화면 호출
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => RankingScreen(
|
||||
gameType: 'CARD_FLIP',
|
||||
difficulties: CardFlipDifficulties.allDifficulties,
|
||||
initialDifficultyName:
|
||||
CardFlipDifficulties.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: RefreshIndicator(
|
||||
onRefresh: () => _loadProgress(forceRefreshRanks: true),
|
||||
child: ListView.builder(
|
||||
itemCount: CardFlipDifficulties.allDifficulties.length,
|
||||
itemBuilder: (context, index) {
|
||||
final CardFlipDifficulty level =
|
||||
CardFlipDifficulties.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}위";
|
||||
subtitleText = "$rankStr (확인됨)";
|
||||
subtitleColor = Colors.blue;
|
||||
trailingWidget = const Icon(
|
||||
Icons.new_releases_rounded,
|
||||
color: Colors.blue,
|
||||
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,28 @@
|
||||
name: feature_game_cardflip
|
||||
description: "A new Flutter package project."
|
||||
version: 1.0.0
|
||||
publish_to: 'none'
|
||||
resolution: workspace
|
||||
environment:
|
||||
sdk: '^3.9.2'
|
||||
flutter: '>=3.10.0'
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
# 1. 공통 서비스 로직 (필수)
|
||||
# GameLevel, SudokuGameDto, SudokuTheme, PuzzleService, IdentityService 등을 사용
|
||||
service_api:
|
||||
path: ../service_api
|
||||
|
||||
# 2. UI 및 상태 관리
|
||||
provider: ^6.0.0 # (GameScreen에서 ThemeNotifier를 watch)
|
||||
|
||||
feature_common:
|
||||
path: ../feature_common
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
lints: ^3.0.0
|
||||
@@ -0,0 +1,12 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:feature_game_cardflip/feature_game_cardflip.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