...
This commit is contained in:
@@ -26,18 +26,17 @@ class _GameCompletionScreenState extends State<GameCompletionScreen> {
|
||||
GameRankWithRankNumber? _myRankResult;
|
||||
String? _dialogErrorMessage;
|
||||
String _submittedPlayerName = "";
|
||||
|
||||
// 🔽 [신규] 랭킹 등록을 건너뛰었는지 확인하는 플래그
|
||||
bool _didSkipRank = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// 🔽 [핵심 수정]
|
||||
// 랭킹 등록 여부와 상관없이, 이 화면에 진입한 것 자체가 "레벨 클리어"이므로
|
||||
// onProgressSave (레벨 잠금 해제)를 즉시 호출합니다.
|
||||
// (playerName은 이 콜백에서 사용되지 않으므로 빈 값을 전달합니다.)
|
||||
// 레벨 클리어 (레벨 잠금 해제)를 즉시 호출
|
||||
widget.args.onProgressSave("");
|
||||
|
||||
// --- (이하 기존 로직) ---
|
||||
final session = context.read<SessionNotifier>().session;
|
||||
_nameController = TextEditingController(text: session?.userName ?? widget.args.userName);
|
||||
|
||||
@@ -45,7 +44,7 @@ class _GameCompletionScreenState extends State<GameCompletionScreen> {
|
||||
if (session != null && !session.isGuest) {
|
||||
_rankStep = _RankSubmissionStep.submitting;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_submitRank(autoSubmitName: session.userName);
|
||||
_submitRank(autoSubmitName: session.userName);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -91,13 +90,8 @@ class _GameCompletionScreenState extends State<GameCompletionScreen> {
|
||||
await _identityService.saveUserName(playerName);
|
||||
}
|
||||
|
||||
// 🔽 [수정]
|
||||
// onProgressSave는 initState에서 이미 호출되었지만,
|
||||
// saveMaxUnlockedLevel 함수 자체가 멱등성(Idempotent)을 가지므로
|
||||
// (이미 레벨이 6인데 6으로 덮어써도 문제없음)
|
||||
// 혹시 모를 실패에 대비해 여기서 한 번 더 호출해도 안전합니다.
|
||||
await widget.args.onProgressSave(playerName);
|
||||
|
||||
await widget.args.onProgressSave(playerName); // 레벨 저장 재확인
|
||||
|
||||
setState(() {
|
||||
_rankingList = result.topRanks;
|
||||
_myRankResult = result.myRank;
|
||||
@@ -109,35 +103,123 @@ class _GameCompletionScreenState extends State<GameCompletionScreen> {
|
||||
setState(() {
|
||||
_rankStep = _RankSubmissionStep.enterName;
|
||||
if (autoSubmitName != null) {
|
||||
_rankStep = _RankSubmissionStep.showList;
|
||||
_rankStep = _RankSubmissionStep.showList; // 자동 등록 실패 시 리스트라도 보여줌
|
||||
}
|
||||
_dialogErrorMessage = e.toString().replaceFirst("Exception: ", "");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 닫기 버튼 로직
|
||||
void _closeScreen() {
|
||||
// 이 화면만 닫습니다. (GameScreen으로 돌아감)
|
||||
Navigator.of(context).pop();
|
||||
/// 🔽 [신규] 랭킹 등록 건너뛰기 및 화면 닫기
|
||||
void _skipRankAndClose() {
|
||||
setState(() {
|
||||
_didSkipRank = true;
|
||||
_rankStep = _RankSubmissionStep.showList; // 리스트 화면으로 전환하여 기록은 볼 수 있게 함
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// ... (이하 UI 빌드 로직은 모두 동일) ...
|
||||
final theme = Theme.of(context);
|
||||
/// 🔽 [신규] 점수 표시 위젯 (최상단 고정)
|
||||
Widget _buildScoreWidget(ThemeData theme) {
|
||||
final String scoreText = widget.args.scoreFormatter(
|
||||
widget.args.primaryScore, widget.args.secondaryScore);
|
||||
|
||||
// --- UI 섹션 정의 ---
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 20.0),
|
||||
child: Card(
|
||||
color: theme.colorScheme.primary.withOpacity(0.1),
|
||||
margin: EdgeInsets.zero,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'나의 최종 기록',
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
scoreText,
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 🔽 [신규] 이름 입력 및 버튼 섹션 (키보드 대응)
|
||||
Widget _buildNameEntrySection(ThemeData theme) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('축하합니다! 랭킹에 등록할 이름을 입력하세요.', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 20),
|
||||
TextField(
|
||||
controller: _nameController,
|
||||
autofocus: true,
|
||||
maxLength: 20,
|
||||
decoration: InputDecoration(
|
||||
labelText: '이름 (20자 이내)',
|
||||
border: const OutlineInputBorder(),
|
||||
errorText: _dialogErrorMessage,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// [🔥 수정] 버튼을 입력창 바로 아래 배치
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: _skipRankAndClose, child: const Text('건너뛰기')),
|
||||
const SizedBox(width: 10),
|
||||
ElevatedButton(
|
||||
onPressed: () => _submitRank(), child: const Text('랭킹 등록')),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 🔽 [신규] 랭킹 리스트 섹션 (기록 보기)
|
||||
Widget _buildRankingListSection(ThemeData theme) {
|
||||
if (_didSkipRank) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('랭킹 등록을 건너뛰었습니다.', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 10),
|
||||
Text('기록은 위 "나의 최종 기록"에서 확인 가능합니다.', style: theme.textTheme.bodyMedium),
|
||||
const SizedBox(height: 20),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('로비로 돌아가기'),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 랭킹 리스트 (기존 로직과 유사)
|
||||
Widget topRankListWidget = _rankingList.isEmpty
|
||||
? const Center(child: Text("현재 랭킹이 없습니다."))
|
||||
? const Center(child: Text("등록된 랭킹이 없습니다."))
|
||||
: ListView.builder(
|
||||
itemCount: _rankingList.length,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(), // SingleChildScrollView 내부이므로 필요
|
||||
itemBuilder: (context, index) {
|
||||
final rank = _rankingList[index];
|
||||
final bool isMe = rank.playerName == _submittedPlayerName;
|
||||
final String scoreText = widget.args.scoreFormatter(rank.primaryScore, rank.secondaryScore);
|
||||
|
||||
|
||||
return ListTile(
|
||||
selected: isMe,
|
||||
selectedTileColor: theme.primaryColor.withOpacity(0.1),
|
||||
@@ -160,7 +242,7 @@ class _GameCompletionScreenState extends State<GameCompletionScreen> {
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: ListTile(
|
||||
selected: true,
|
||||
selectedTileColor: theme.primaryColor.withOpacity(0.1),
|
||||
selectedTileColor: theme.colorScheme.secondary.withOpacity(0.1),
|
||||
leading: Text('$myRankNum.', style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
title: Text(myRank.playerName, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
trailing: Text(scoreText, style: TextStyle(fontWeight: FontWeight.bold, color: theme.textTheme.bodyMedium?.color?.withOpacity(0.9))),
|
||||
@@ -169,62 +251,48 @@ class _GameCompletionScreenState extends State<GameCompletionScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Widget rankDisplaySection = Column(
|
||||
children: [
|
||||
if (_dialogErrorMessage != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Text(_dialogErrorMessage!, style: TextStyle(color: theme.colorScheme.error)),
|
||||
),
|
||||
Expanded(child: topRankListWidget),
|
||||
if (myRankWidget != null) ...[
|
||||
const Divider(height: 16, thickness: 1),
|
||||
myRankWidget,
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
Widget nameEntryWidget = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('축하합니다! 랭킹에 등록할 이름을 입력하세요.', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 20),
|
||||
TextField(
|
||||
controller: _nameController,
|
||||
autofocus: true,
|
||||
maxLength: 20,
|
||||
decoration: InputDecoration(
|
||||
labelText: '이름 (20자 이내)',
|
||||
border: const OutlineInputBorder(),
|
||||
errorText: _dialogErrorMessage,
|
||||
),
|
||||
return Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
if (_dialogErrorMessage != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Text(_dialogErrorMessage!, style: TextStyle(color: theme.colorScheme.error)),
|
||||
),
|
||||
topRankListWidget,
|
||||
if (myRankWidget != null) ...[
|
||||
const Divider(height: 16, thickness: 1),
|
||||
myRankWidget,
|
||||
],
|
||||
const SizedBox(height: 40),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('로비로 돌아가기'),
|
||||
)
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// --- 상태에 따라 UI와 버튼 결정 ---
|
||||
Widget content;
|
||||
List<Widget> actions = [];
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
String titleText;
|
||||
Widget content;
|
||||
|
||||
if (_rankStep == _RankSubmissionStep.enterName) {
|
||||
titleText = '🎉 게임 완료!';
|
||||
content = nameEntryWidget;
|
||||
actions = [
|
||||
TextButton(onPressed: _closeScreen, child: const Text('나중에 하기')),
|
||||
ElevatedButton(onPressed: () => _submitRank(), child: const Text('랭킹 등록')),
|
||||
];
|
||||
}
|
||||
content = _buildNameEntrySection(theme); // 이름 입력 섹션
|
||||
}
|
||||
else if (_rankStep == _RankSubmissionStep.submitting) {
|
||||
titleText = '랭킹 등록 중...';
|
||||
content = const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
}
|
||||
else { // _RankSubmissionStep.showList
|
||||
titleText = '🏆 랭킹 (${widget.args.contextId})';
|
||||
content = rankDisplaySection;
|
||||
actions = [
|
||||
TextButton(onPressed: _closeScreen, child: const Text('닫기')),
|
||||
];
|
||||
titleText = _didSkipRank ? '✅ 기록 확인' : '🏆 랭킹 등록 완료';
|
||||
content = _buildRankingListSection(theme); // 랭킹 리스트 섹션
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
@@ -232,17 +300,29 @@ class _GameCompletionScreenState extends State<GameCompletionScreen> {
|
||||
title: Text(titleText),
|
||||
automaticallyImplyLeading: false,
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: content,
|
||||
),
|
||||
bottomNavigationBar: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: actions,
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// 1. [🔥 수정] 최종 기록 섹션 (스크롤과 분리된 최상단)
|
||||
_buildScoreWidget(theme),
|
||||
|
||||
// 2. [🔥 수정] 메인 컨텐츠 섹션
|
||||
if (_rankStep == _RankSubmissionStep.enterName || _rankStep == _RankSubmissionStep.submitting)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: content,
|
||||
)
|
||||
else
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: content,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// ❌ bottomNavigationBar는 제거됨
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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,231 @@
|
||||
// packages/feature_game_colormatch/lib/controllers/colormatch_controller.dart
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/colormatch_models.dart';
|
||||
|
||||
class ColorMatchController with ChangeNotifier {
|
||||
late ColorMatchDifficulty difficulty;
|
||||
late String userId;
|
||||
late String? userName;
|
||||
|
||||
late MatchChallenge _currentChallenge;
|
||||
MatchChallenge get currentChallenge => _currentChallenge;
|
||||
|
||||
int _currentRound = 0;
|
||||
int get currentRound => _currentRound;
|
||||
|
||||
int _score = 0;
|
||||
int get score => _score;
|
||||
|
||||
int _incorrectCount = 0;
|
||||
int get incorrectCount => _incorrectCount;
|
||||
|
||||
Timer? _timer;
|
||||
int _secondsElapsed = 0;
|
||||
int get secondsElapsed => _secondsElapsed;
|
||||
|
||||
bool _isGameCompleted = false;
|
||||
bool get isGameCompleted => _isGameCompleted;
|
||||
|
||||
bool _showFeedback = false;
|
||||
bool get showFeedback => _showFeedback;
|
||||
|
||||
bool _isLastAnswerCorrect = false;
|
||||
bool get isLastAnswerCorrect => _isLastAnswerCorrect;
|
||||
|
||||
Timer? _challengeTimer;
|
||||
int _remainingChallengeTime = 0;
|
||||
int get remainingChallengeTime => _remainingChallengeTime;
|
||||
bool _isTimeOutFailure = false;
|
||||
bool get isTimeOutFailure => _isTimeOutFailure;
|
||||
|
||||
MatchMode _currentMatchMode = MatchMode.matchColor;
|
||||
MatchMode get currentMatchMode => _currentMatchMode;
|
||||
|
||||
final Random _random = Random();
|
||||
|
||||
// 🔽 [🔥 신규] 3가지 목표 중 하나를 랜덤으로 반환
|
||||
MatchMode _getRandomMatchMode() {
|
||||
final modes = MatchMode.values;
|
||||
return modes[_random.nextInt(modes.length)];
|
||||
}
|
||||
|
||||
// 🔽 [🔥 신규] MatchColor 리스트에서 이름으로 색상 객체를 찾는 헬퍼
|
||||
MatchColor _getMatchColorByName(String name, List<MatchColor> colors) {
|
||||
return colors.firstWhere((c) => c.name == name);
|
||||
}
|
||||
|
||||
void setUserInfo(String userId, String? userName) {
|
||||
this.userId = userId;
|
||||
this.userName = userName;
|
||||
}
|
||||
|
||||
void startNewGame(ColorMatchDifficulty level) {
|
||||
difficulty = level;
|
||||
// ... (상태 초기화 로직은 이전과 동일) ...
|
||||
_currentRound = 0;
|
||||
_score = 0;
|
||||
_incorrectCount = 0;
|
||||
_isGameCompleted = false;
|
||||
_secondsElapsed = 0;
|
||||
_showFeedback = false;
|
||||
_isTimeOutFailure = false;
|
||||
_currentMatchMode = MatchMode.matchColor;
|
||||
|
||||
_generateNextChallenge();
|
||||
_startTimer();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void restartGame() {
|
||||
startNewGame(difficulty);
|
||||
}
|
||||
|
||||
void _startTimer() {
|
||||
_timer?.cancel();
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
_secondsElapsed++;
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
void _startChallengeTimer() {
|
||||
_challengeTimer?.cancel();
|
||||
if (difficulty.timeLimitSeconds <= 0) return;
|
||||
|
||||
_remainingChallengeTime = difficulty.timeLimitSeconds;
|
||||
|
||||
_challengeTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
if (_remainingChallengeTime <= 0) {
|
||||
_challengeTimer?.cancel();
|
||||
_handleTimeOut();
|
||||
} else {
|
||||
_remainingChallengeTime--;
|
||||
notifyListeners();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _handleTimeOut() {
|
||||
if (_isGameCompleted || _showFeedback) return;
|
||||
_challengeTimer?.cancel();
|
||||
_currentRound++;
|
||||
_incorrectCount++;
|
||||
_isLastAnswerCorrect = false;
|
||||
_isTimeOutFailure = true;
|
||||
_showFeedback = true;
|
||||
notifyListeners();
|
||||
_completeRoundSequence();
|
||||
}
|
||||
|
||||
// 🔽 [🔥 핵심 수정] 다음 문제 생성 로직 (배경색 및 3중 목표)
|
||||
void _generateNextChallenge() {
|
||||
final colors = difficulty.colorSet;
|
||||
final bool enableStroop = difficulty.enableStroopEffect;
|
||||
|
||||
// 1. 세 가지 주요 색상 요소 선택
|
||||
final MatchColor colorMeaning = colors[_random.nextInt(colors.length)]; // 단어의 의미 (정답 후보 1)
|
||||
final MatchColor colorText = colors[_random.nextInt(colors.length)]; // 텍스트의 실제 색상 (정답 후보 2)
|
||||
final MatchColor colorBackground = colors[_random.nextInt(colors.length)]; // 배경색 (정답 후보 3)
|
||||
|
||||
String displayedWord = colorMeaning.name;
|
||||
Color textColor = colorText.color;
|
||||
Color finalTargetColor;
|
||||
|
||||
if (enableStroop) {
|
||||
// 2. Lv 5-6: Stroop ON (MatchColor/MatchWord 50% 전환)
|
||||
if (difficulty.levelIndex < 7) {
|
||||
_currentMatchMode = _random.nextBool() ? MatchMode.matchColor : MatchMode.matchWord;
|
||||
}
|
||||
// 3. Lv 7 이상: 3가지 목표 중 무작위 선택
|
||||
else {
|
||||
_currentMatchMode = _getRandomMatchMode();
|
||||
}
|
||||
|
||||
// 4. 최종 정답 색상 결정
|
||||
switch (_currentMatchMode) {
|
||||
case MatchMode.matchColor:
|
||||
finalTargetColor = textColor;
|
||||
break;
|
||||
case MatchMode.matchWord:
|
||||
finalTargetColor = colorMeaning.color;
|
||||
break;
|
||||
case MatchMode.matchBackground:
|
||||
finalTargetColor = colorBackground.color;
|
||||
break;
|
||||
}
|
||||
|
||||
// 인지 충돌 유도: 텍스트 색상과 단어의 의미가 50% 확률로 다르게 설정
|
||||
if (_random.nextBool()) {
|
||||
final List<Color> availableTextColors = colors.map((c) => c.color).where((c) => c != colorMeaning.color).toList();
|
||||
if (availableTextColors.isNotEmpty) {
|
||||
textColor = availableTextColors[_random.nextInt(availableTextColors.length)];
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
// Lv 1-4: Stroop OFF (항상 텍스트 색상 매칭, 단어와 색상 일치)
|
||||
_currentMatchMode = MatchMode.matchColor;
|
||||
displayedWord = colorMeaning.name;
|
||||
textColor = colorMeaning.color;
|
||||
finalTargetColor = textColor;
|
||||
}
|
||||
|
||||
_currentChallenge = MatchChallenge(
|
||||
displayedWord: displayedWord,
|
||||
textColor: textColor,
|
||||
backgroundColor: colorBackground.color, // 👈 [추가]
|
||||
targetColor: finalTargetColor,
|
||||
);
|
||||
|
||||
_startChallengeTimer();
|
||||
}
|
||||
|
||||
// 🔽 정답 확인 및 지연 로직 (이전과 동일)
|
||||
void checkAnswer(Color selectedColor) {
|
||||
if (_isGameCompleted || _showFeedback) return;
|
||||
|
||||
_challengeTimer?.cancel();
|
||||
|
||||
final bool isCorrect = selectedColor == _currentChallenge.targetColor;
|
||||
|
||||
_currentRound++;
|
||||
|
||||
if (isCorrect) {
|
||||
_score++;
|
||||
} else {
|
||||
_incorrectCount++;
|
||||
}
|
||||
|
||||
_isTimeOutFailure = false;
|
||||
_showFeedback = true;
|
||||
_isLastAnswerCorrect = isCorrect;
|
||||
notifyListeners();
|
||||
|
||||
_completeRoundSequence();
|
||||
}
|
||||
|
||||
void _completeRoundSequence() {
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
if (_currentRound >= difficulty.totalRounds) {
|
||||
_isGameCompleted = true;
|
||||
_timer?.cancel();
|
||||
} else {
|
||||
_showFeedback = false;
|
||||
_isTimeOutFailure = false;
|
||||
_generateNextChallenge();
|
||||
}
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
_challengeTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// packages/feature_game_colormatch/lib/feature_game_colormatch.dart
|
||||
|
||||
// 메인 앱이 IntroScreen의 다음 화면으로 사용할 '로비 화면'
|
||||
export 'screens/colormatch_lobby_screen.dart';
|
||||
|
||||
// (GameScreen 등은 로비 화면만 알면 되므로 굳이 export 안 해도 됨)
|
||||
@@ -0,0 +1,98 @@
|
||||
// packages/feature_game_colormatch/lib/models/colormatch_models.dart
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:service_api/service_api.dart';
|
||||
|
||||
/// 스트룹 효과에 사용될 핵심 색상 및 이름 정의
|
||||
class MatchColor {
|
||||
final String name; // 예: "빨강", "파랑"
|
||||
final Color color; // 예: Colors.red, Colors.blue
|
||||
|
||||
const MatchColor({required this.name, required this.color});
|
||||
}
|
||||
|
||||
/// 현재 라운드의 정답 목표를 정의 (인지 부하 증가)
|
||||
enum MatchMode {
|
||||
matchColor, // 단어의 '텍스트 색상'을 맞춰야 함
|
||||
matchWord, // 단어가 '표현하는 의미'를 맞춰야 함
|
||||
matchBackground, // 단어의 '배경색'을 맞춰야 함
|
||||
}
|
||||
|
||||
/// 게임 난이도 및 규칙 정의
|
||||
class ColorMatchDifficulty extends GameDifficulty {
|
||||
final int levelIndex;
|
||||
final int totalRounds; // 총 라운드 수
|
||||
final int timeLimitSeconds; // 라운드당 시간 제한
|
||||
final bool enableStroopEffect;
|
||||
final List<MatchColor> colorSet;
|
||||
|
||||
ColorMatchDifficulty({
|
||||
required this.levelIndex,
|
||||
required super.name,
|
||||
required super.contextId,
|
||||
required this.totalRounds,
|
||||
required this.timeLimitSeconds,
|
||||
required this.enableStroopEffect,
|
||||
required this.colorSet,
|
||||
});
|
||||
}
|
||||
|
||||
class ColorMatchDifficulties {
|
||||
// 10가지 색상 세트 정의
|
||||
static final List<MatchColor> baseColors = [
|
||||
const MatchColor(name: "빨강", color: Colors.red),
|
||||
const MatchColor(name: "파랑", color: Colors.blue),
|
||||
const MatchColor(name: "노랑", color: Colors.yellow),
|
||||
const MatchColor(name: "초록", color: Colors.green),
|
||||
const MatchColor(name: "보라", color: Colors.purple),
|
||||
const MatchColor(name: "주황", color: Colors.orange),
|
||||
const MatchColor(name: "분홍", color: Colors.pink),
|
||||
const MatchColor(name: "갈색", color: Colors.brown),
|
||||
const MatchColor(name: "하늘", color: Colors.cyan),
|
||||
const MatchColor(name: "회색", color: Colors.grey),
|
||||
];
|
||||
|
||||
// [🔥 수정] 난이도 목록 (시간 증가 적용)
|
||||
static final List<ColorMatchDifficulty> allDifficulties = [
|
||||
// --- Lv 1-4: 기본 색상 매칭 (시간 증가) ---
|
||||
ColorMatchDifficulty( levelIndex: 1, name: 'Lv. 1: 초급 (2색)', contextId: 'MATCH_L1_BASE_2', totalRounds: 15, timeLimitSeconds: 6, enableStroopEffect: false, colorSet: baseColors.sublist(0, 2)),
|
||||
ColorMatchDifficulty( levelIndex: 2, name: 'Lv. 2: 기본 (4색)', contextId: 'MATCH_L2_BASE_4', totalRounds: 20, timeLimitSeconds: 5, enableStroopEffect: false, colorSet: baseColors.sublist(0, 4)),
|
||||
ColorMatchDifficulty( levelIndex: 3, name: 'Lv. 3: 숙련 (6색)', contextId: 'MATCH_L3_BASE_6', totalRounds: 25, timeLimitSeconds: 4, enableStroopEffect: false, colorSet: baseColors.sublist(0, 6)),
|
||||
ColorMatchDifficulty( levelIndex: 4, name: 'Lv. 4: 전문가 (8색)', contextId: 'MATCH_L4_BASE_8', totalRounds: 30, timeLimitSeconds: 3, enableStroopEffect: false, colorSet: baseColors.sublist(0, 8)),
|
||||
// --- Lv 5-6: 스트룹 시작 ---
|
||||
ColorMatchDifficulty( levelIndex: 5, name: 'Lv. 5: 스트룹 시작 (4색)', contextId: 'MATCH_L5_STROOP_4', totalRounds: 20, timeLimitSeconds: 5, enableStroopEffect: true, colorSet: baseColors.sublist(0, 4)),
|
||||
ColorMatchDifficulty( levelIndex: 6, name: 'Lv. 6: 스트룹 중급 (6색)', contextId: 'MATCH_L6_STROOP_6', totalRounds: 30, timeLimitSeconds: 4, enableStroopEffect: true, colorSet: baseColors.sublist(0, 6)),
|
||||
|
||||
// --- Lv 7-9: 3가지 목표 중 무작위 선택 (배경색 도입) ---
|
||||
ColorMatchDifficulty( levelIndex: 7, name: 'Lv. 7: 3중 인지 (6색)', contextId: 'MATCH_L7_TRIPLE_6', totalRounds: 30, timeLimitSeconds: 4, enableStroopEffect: true, colorSet: baseColors.sublist(0, 6)),
|
||||
ColorMatchDifficulty( levelIndex: 8, name: 'Lv. 8: 3중 인지 (8색)', contextId: 'MATCH_L8_TRIPLE_8', totalRounds: 40, timeLimitSeconds: 3, enableStroopEffect: true, colorSet: baseColors.sublist(0, 8)),
|
||||
ColorMatchDifficulty( levelIndex: 9, name: 'Lv. 9: 3중 인지 (10색)', contextId: 'MATCH_L9_TRIPLE_10', totalRounds: 50, timeLimitSeconds: 3, enableStroopEffect: true, colorSet: baseColors.sublist(0, 10)),
|
||||
|
||||
// --- Lv 10-12: 최상 난이도 (배경색 + 초고속, 시간 증가) ---
|
||||
ColorMatchDifficulty( levelIndex: 10, name: 'Lv. 10: 마스터 (10색 초고속)', contextId: 'MATCH_L10_MASTER', totalRounds: 50, timeLimitSeconds: 2, enableStroopEffect: true, colorSet: baseColors.sublist(0, 10)),
|
||||
ColorMatchDifficulty( levelIndex: 11, name: 'Lv. 11: 지옥 (10색 초고속)', contextId: 'MATCH_L11_HELL', totalRounds: 60, timeLimitSeconds: 2, enableStroopEffect: true, colorSet: baseColors.sublist(0, 10)),
|
||||
ColorMatchDifficulty( levelIndex: 12, name: 'Lv. 12: 궁극 (10색, 초고속)', contextId: 'MATCH_L12_ULTIMATE', totalRounds: 70, timeLimitSeconds: 2, enableStroopEffect: true, colorSet: baseColors.sublist(0, 10)),
|
||||
];
|
||||
|
||||
static ColorMatchDifficulty 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]);
|
||||
}
|
||||
}
|
||||
|
||||
/// 화면에 제시될 단일 문제 (Text와 Color의 조합)
|
||||
class MatchChallenge {
|
||||
final String displayedWord;
|
||||
final Color textColor;
|
||||
final Color backgroundColor;
|
||||
final Color targetColor;
|
||||
|
||||
MatchChallenge({
|
||||
required this.displayedWord,
|
||||
required this.textColor,
|
||||
required this.backgroundColor,
|
||||
required this.targetColor,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
// packages/feature_game_colormatch/lib/screens/colormatch_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/colormatch_controller.dart';
|
||||
import '../models/colormatch_models.dart';
|
||||
|
||||
class ColorMatchGameScreen extends StatefulWidget {
|
||||
const ColorMatchGameScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ColorMatchGameScreen> createState() => _ColorMatchGameScreenState();
|
||||
}
|
||||
|
||||
class _ColorMatchGameScreenState extends State<ColorMatchGameScreen> {
|
||||
bool _isDialogShowing = false;
|
||||
|
||||
void _showGameCompletion(ColorMatchController controller) async {
|
||||
String formatMatchScore(int primary, int? secondary) {
|
||||
final correct = primary;
|
||||
final incorrect = secondary ?? 0;
|
||||
return '${correct}개 맞춤 (${incorrect}개 틀림)';
|
||||
}
|
||||
|
||||
Future<void> saveMatchProgress(String playerName) async {
|
||||
// [🔥 핵심] 정답률 70% 미만이면 레벨 저장 안 함
|
||||
final int total = controller.difficulty.totalRounds;
|
||||
final int correct = controller.score;
|
||||
final double accuracy = (total > 0) ? (correct / total) : 0.0;
|
||||
const double passingThreshold = 0.7;
|
||||
|
||||
if (accuracy < passingThreshold) {
|
||||
debugPrint("레벨 클리어 실패: 정답률 ${(accuracy * 100).toStringAsFixed(1)}% < 70%");
|
||||
return;
|
||||
}
|
||||
|
||||
final identityService = IdentityService();
|
||||
final int currentMaxLevel = await identityService.getMaxUnlockedLevel(gameType: 'COLOR_MATCH');
|
||||
if (currentMaxLevel < 99) {
|
||||
if (controller.difficulty.levelIndex >= currentMaxLevel) {
|
||||
int nextLevel = controller.difficulty.levelIndex + 1;
|
||||
if (nextLevel > ColorMatchDifficulties.allDifficulties.length) {
|
||||
await identityService.saveMaxUnlockedLevel(99, gameType: 'COLOR_MATCH');
|
||||
} else {
|
||||
await identityService.saveMaxUnlockedLevel(nextLevel, gameType: 'COLOR_MATCH');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => GameCompletionScreen(
|
||||
args: GameResultArgs(
|
||||
gameType: 'COLOR_MATCH',
|
||||
contextId: controller.difficulty.contextId,
|
||||
primaryScore: controller.score,
|
||||
secondaryScore: controller.incorrectCount,
|
||||
userId: controller.userId,
|
||||
userName: controller.userName,
|
||||
scoreFormatter: formatMatchScore,
|
||||
onProgressSave: saveMatchProgress,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (mounted && Navigator.canPop(context)) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
|
||||
// 🔽 피드백 위젯 빌더
|
||||
Widget _buildFeedbackWidget(ColorMatchController controller, ThemeData theme) {
|
||||
if (!controller.showFeedback) {
|
||||
// 라운드 타이머가 실행 중이면 남은 시간을 표시
|
||||
if (controller.remainingChallengeTime > 0) {
|
||||
return SizedBox(
|
||||
height: 60,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'${controller.remainingChallengeTime}s',
|
||||
style: TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox(height: 60);
|
||||
}
|
||||
|
||||
// 피드백 메시지 결정
|
||||
String text;
|
||||
Color color;
|
||||
|
||||
if (controller.isTimeOutFailure) {
|
||||
text = "시간 초과! ⏱️";
|
||||
color = Colors.orange;
|
||||
} else if (controller.isLastAnswerCorrect) {
|
||||
text = "정답! 👍";
|
||||
color = Colors.green;
|
||||
} else {
|
||||
text = "오답! 👎";
|
||||
color = theme.colorScheme.error;
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
height: 60,
|
||||
child: Center(
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 🔽 지침 텍스트 빌더 (RichText)
|
||||
TextSpan _buildInstructionTextSpan(MatchMode mode, ThemeData theme) {
|
||||
const TextStyle defaultStyle = TextStyle(fontSize: 18);
|
||||
const TextStyle boldStyle = TextStyle(fontSize: 20, fontWeight: FontWeight.w900, color: Colors.black);
|
||||
|
||||
switch (mode) {
|
||||
case MatchMode.matchColor:
|
||||
return TextSpan(
|
||||
style: defaultStyle,
|
||||
children: [
|
||||
const TextSpan(text: '아래 단어의 "'),
|
||||
TextSpan(text: '글자 색상', style: boldStyle.copyWith(color: theme.colorScheme.primary)),
|
||||
const TextSpan(text: '"에 해당하는 버튼을 누르세요.'),
|
||||
],
|
||||
);
|
||||
case MatchMode.matchWord:
|
||||
return const TextSpan(
|
||||
style: defaultStyle,
|
||||
children: [
|
||||
TextSpan(text: '아래 단어가 "'),
|
||||
TextSpan(text: '표현하는 의미', style: boldStyle),
|
||||
TextSpan(text: '"에 해당하는 버튼을 누르세요.'),
|
||||
],
|
||||
);
|
||||
case MatchMode.matchBackground:
|
||||
return const TextSpan(
|
||||
style: defaultStyle,
|
||||
children: [
|
||||
TextSpan(text: '아래 단어의 "'),
|
||||
TextSpan(text: '배경 색상', style: boldStyle),
|
||||
TextSpan(text: '"에 해당하는 버튼을 누르세요.'),
|
||||
],
|
||||
);
|
||||
default:
|
||||
return const TextSpan(text: '버튼을 누르세요.');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = context.watch<ColorMatchController>();
|
||||
|
||||
if (controller.isGameCompleted && !_isDialogShowing) {
|
||||
_isDialogShowing = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_showGameCompletion(controller);
|
||||
});
|
||||
}
|
||||
|
||||
final seconds = controller.secondsElapsed;
|
||||
final timeStr = "${(seconds ~/ 60).toString().padLeft(2, '0')}:${(seconds % 60).toString().padLeft(2, '0')}";
|
||||
final MatchChallenge challenge = controller.currentChallenge;
|
||||
final theme = Theme.of(context);
|
||||
final bool isInputBlocked = controller.showFeedback || controller.isGameCompleted;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(controller.difficulty.name),
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16.0),
|
||||
child: Center(
|
||||
// 🔽 점수 및 라운드 정보 표시
|
||||
child: Text(
|
||||
'RND: ${controller.currentRound}/${controller.difficulty.totalRounds} | S: ${controller.score} / W: ${controller.incorrectCount} | T: $timeStr',
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// 🔽 피드백 영역
|
||||
_buildFeedbackWidget(controller, theme),
|
||||
const SizedBox(height: 40),
|
||||
|
||||
// 🔽 지침 영역 (RichText)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Text.rich(
|
||||
_buildInstructionTextSpan(controller.currentMatchMode, theme),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
|
||||
// 🔽 문제 제시 영역 (배경색 + 음영)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: challenge.backgroundColor,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: Colors.black12, width: 2),
|
||||
),
|
||||
child: Text(
|
||||
challenge.displayedWord,
|
||||
style: TextStyle(
|
||||
fontSize: 60,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: challenge.textColor,
|
||||
shadows: const [
|
||||
Shadow(
|
||||
blurRadius: 3.0,
|
||||
color: Colors.black, // 글자 가독성을 위한 검은색 음영
|
||||
offset: Offset(1.0, 1.0),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 80),
|
||||
|
||||
// 🔽 버튼 영역
|
||||
Wrap(
|
||||
spacing: 16.0,
|
||||
runSpacing: 16.0,
|
||||
alignment: WrapAlignment.center,
|
||||
children: controller.difficulty.colorSet.map((matchColor) {
|
||||
return SizedBox(
|
||||
width: 150,
|
||||
height: 60,
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: matchColor.color,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
onPressed: isInputBlocked
|
||||
? null
|
||||
: () => controller.checkAnswer(matchColor.color),
|
||||
child: Text(
|
||||
matchColor.name,
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// packages/feature_game_colormatch/lib/screens/colormatch_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 'colormatch_game_screen.dart';
|
||||
import '../models/colormatch_models.dart';
|
||||
import '../controllers/colormatch_controller.dart';
|
||||
|
||||
class ColorMatchLobbyScreen extends StatefulWidget {
|
||||
const ColorMatchLobbyScreen({ super.key });
|
||||
@override
|
||||
State<ColorMatchLobbyScreen> createState() => _ColorMatchLobbyScreenState();
|
||||
}
|
||||
|
||||
class _ColorMatchLobbyScreenState extends State<ColorMatchLobbyScreen> {
|
||||
int _maxUnlockedLevel = 1;
|
||||
Map<int, (int, int)> _rankHistory = {};
|
||||
bool _isLoading = false;
|
||||
|
||||
late final SessionNotifier _sessionNotifier;
|
||||
late final LobbyHelperService _lobbyHelper;
|
||||
final IdentityService _identityService = IdentityService(); // 👈 레벨 잠금 해제용
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_sessionNotifier = context.read<SessionNotifier>();
|
||||
|
||||
_lobbyHelper = LobbyHelperService(
|
||||
identityService: context.read<IdentityService>(),
|
||||
puzzleService: context.read<PuzzleService>(),
|
||||
);
|
||||
|
||||
_loadProgress(forceRefreshRanks: true);
|
||||
}
|
||||
|
||||
/// 🔽 공통 헬퍼를 사용한 로드 로직
|
||||
Future<void> _loadProgress({bool forceRefreshRanks = false}) async {
|
||||
final maxLevel = await _lobbyHelper.loadMaxLevel('COLOR_MATCH');
|
||||
if (mounted) { setState(() { _maxUnlockedLevel = maxLevel; }); }
|
||||
|
||||
if (!forceRefreshRanks) return;
|
||||
final String? myName = _sessionNotifier.session?.userName;
|
||||
if (myName == null) return;
|
||||
|
||||
try {
|
||||
final rankHistory = await _lobbyHelper.loadRankHistory<ColorMatchDifficulty>(
|
||||
gameType: 'COLOR_MATCH',
|
||||
myName: myName,
|
||||
allLevels: ColorMatchDifficulties.allDifficulties,
|
||||
getLevelIndex: (level) => level.levelIndex,
|
||||
);
|
||||
if (mounted) { setState(() { _rankHistory = rankHistory; }); }
|
||||
log("색상 매칭 랭킹 변동 확인 완료. (유저: $myName)");
|
||||
} catch (e) {
|
||||
log("ColorMatchLobbyScreen: 랭킹 확인 실패: $e");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 🔽 게임 시작 로직
|
||||
Future<void> _startGame(ColorMatchDifficulty 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 = ColorMatchController();
|
||||
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 ColorMatchGameScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// 3. 게임 종료 후 레벨 잠금 상태만 새로고침
|
||||
_loadProgress(forceRefreshRanks: false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
context.watch<ThemeNotifier>();
|
||||
context.watch<SessionNotifier>();
|
||||
|
||||
final bool allLevelsUnlocked = _maxUnlockedLevel >= ColorMatchDifficulties.allDifficulties.length;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
// 🔽 [핵심] CommonGameShell 사용
|
||||
return CommonGameShell(
|
||||
title: '색상 인지 퀴즈 (Stroop)',
|
||||
onRankingPressed: () {
|
||||
// 랭킹 화면 호출
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => RankingScreen(
|
||||
gameType: 'COLOR_MATCH',
|
||||
difficulties: ColorMatchDifficulties.allDifficulties,
|
||||
initialDifficultyName: ColorMatchDifficulties.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: ColorMatchDifficulties.allDifficulties.length,
|
||||
itemBuilder: (context, index) {
|
||||
final ColorMatchDifficulty level = ColorMatchDifficulties.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_colormatch
|
||||
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_colormatch/feature_game_colormatch.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);
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
name: feature_game_mathquiz
|
||||
description: "A new Flutter package project."
|
||||
version: 0.0.1
|
||||
homepage:
|
||||
resolution: workspace
|
||||
|
||||
environment:
|
||||
sdk: ^3.9.2
|
||||
@@ -26,7 +26,7 @@ dependencies:
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^5.0.0
|
||||
flutter_lints: ^3.0.0
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
@@ -64,3 +64,4 @@ flutter:
|
||||
#
|
||||
# For details regarding fonts in packages, see
|
||||
# https://flutter.dev/to/font-from-package
|
||||
|
||||
@@ -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,276 @@
|
||||
// packages/feature_game_sequence/lib/controllers/sequence_game_controller.dart
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/sequence_models.dart';
|
||||
|
||||
enum SequenceGameState {
|
||||
idle,
|
||||
presenting,
|
||||
postDelay,
|
||||
input,
|
||||
processing,
|
||||
completed,
|
||||
}
|
||||
|
||||
class SequenceGameController with ChangeNotifier {
|
||||
late SequenceDifficulty difficulty;
|
||||
late String userId;
|
||||
late String? userName;
|
||||
|
||||
SequenceGameState _currentState = SequenceGameState.idle;
|
||||
SequenceGameState get currentState => _currentState;
|
||||
|
||||
List<SequenceButtonId> _currentSequence = [];
|
||||
List<SequenceButtonId> get currentSequence => _currentSequence;
|
||||
List<SequenceButtonId> _userInput = [];
|
||||
List<SequenceButtonId> get userInput => _userInput;
|
||||
|
||||
late List<String> _activeSymbolPool;
|
||||
List<String> get activeSymbolPool => _activeSymbolPool;
|
||||
|
||||
int _currentSequenceLength = 0;
|
||||
int get currentSequenceLength => _currentSequenceLength;
|
||||
|
||||
int _maxAchievedLength = 0;
|
||||
int get maxAchievedLength => _maxAchievedLength;
|
||||
|
||||
int _roundsCompleted = 0;
|
||||
int get roundsCompleted => _roundsCompleted;
|
||||
|
||||
int _roundsFailed = 0;
|
||||
int get roundsFailed => _roundsFailed;
|
||||
|
||||
SequenceButtonId? _activePresentationId;
|
||||
SequenceButtonId? get activePresentationId => _activePresentationId;
|
||||
|
||||
bool _isLastInputCorrect = true;
|
||||
bool get isLastInputCorrect => _isLastInputCorrect;
|
||||
|
||||
bool _showFeedback = false;
|
||||
bool get showFeedback => _showFeedback;
|
||||
|
||||
Timer? _scoreTimer;
|
||||
Timer? _roundTimer;
|
||||
double _remainingRoundTime = 0.0;
|
||||
double get remainingRoundTime => _remainingRoundTime;
|
||||
|
||||
int _secondsElapsed = 0;
|
||||
int get secondsElapsed => _secondsElapsed;
|
||||
|
||||
int _presentationStep = 0;
|
||||
int get presentationStep => _presentationStep;
|
||||
|
||||
set presentationStep(int step) {
|
||||
_presentationStep = step;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
final Random _random = Random();
|
||||
|
||||
String getSymbolForId(SequenceButtonId id) {
|
||||
if (id.index >= _activeSymbolPool.length) {
|
||||
return '?';
|
||||
}
|
||||
return _activeSymbolPool[id.index];
|
||||
}
|
||||
|
||||
void setUserInfo(String userId, String? userName) {
|
||||
this.userId = userId;
|
||||
this.userName = userName;
|
||||
}
|
||||
|
||||
void startNewGame(SequenceDifficulty level) {
|
||||
difficulty = level;
|
||||
_currentSequence.clear();
|
||||
_userInput.clear();
|
||||
_currentSequenceLength = level.initialLength;
|
||||
_maxAchievedLength = level.initialLength - 1;
|
||||
_currentState = SequenceGameState.idle;
|
||||
_secondsElapsed = 0;
|
||||
_roundsCompleted = 0;
|
||||
_roundsFailed = 0;
|
||||
_showFeedback = false;
|
||||
_activePresentationId = null;
|
||||
_presentationStep = 0;
|
||||
|
||||
_initializeSymbolPool();
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_startPresentationPhase();
|
||||
});
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void restartGame() {
|
||||
startNewGame(difficulty);
|
||||
}
|
||||
|
||||
void setActivePresentation(SequenceButtonId? id) {
|
||||
_activePresentationId = id;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// 🔽 [🔥 수정] 레벨 10 이상만 믹스, 나머지는 단일 테마
|
||||
void _initializeSymbolPool() {
|
||||
if (difficulty.levelIndex >= 10) {
|
||||
// Lv 10+: 모든 풀을 합치고 섞음 (Mixed)
|
||||
final List<String> allSymbols = [
|
||||
...SequenceDifficulties.getSymbolPool(SequenceContentPool.number),
|
||||
...SequenceDifficulties.getSymbolPool(SequenceContentPool.letter),
|
||||
...SequenceDifficulties.getSymbolPool(SequenceContentPool.koreanChar),
|
||||
...SequenceDifficulties.getSymbolPool(SequenceContentPool.emoji),
|
||||
// (단어는 길이가 길어 혼합 시 시각적 밸런스를 위해 제외하거나 포함 가능. 여기선 제외)
|
||||
];
|
||||
_activeSymbolPool = (allSymbols..shuffle()).sublist(0, difficulty.buttonsCount);
|
||||
} else {
|
||||
// Lv 1~9: 지정된 단일 풀 사용
|
||||
_activeSymbolPool = SequenceDifficulties.getSymbolPool(difficulty.contentPoolType);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void _startPresentationPhase() {
|
||||
_roundTimer?.cancel();
|
||||
_currentState = SequenceGameState.presenting;
|
||||
_presentationStep = 0;
|
||||
_generateNextSequence();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _generateNextSequence() {
|
||||
_currentSequence.clear();
|
||||
final int maxIndex = difficulty.buttonsCount;
|
||||
for (int i = 0; i < _currentSequenceLength; i++) {
|
||||
final int randIndex = _random.nextInt(maxIndex);
|
||||
_currentSequence.add(SequenceButtonId.values[randIndex]);
|
||||
}
|
||||
}
|
||||
|
||||
void runPostPresentationDelay() {
|
||||
_currentState = SequenceGameState.postDelay;
|
||||
notifyListeners();
|
||||
|
||||
Future.delayed(const Duration(milliseconds: 1000), () {
|
||||
if (currentState == SequenceGameState.postDelay) {
|
||||
startInputPhase();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void startInputPhase() {
|
||||
_startScoreTimer();
|
||||
|
||||
_userInput.clear();
|
||||
_currentState = SequenceGameState.input;
|
||||
_startRoundTimer();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _startRoundTimer() {
|
||||
_roundTimer?.cancel();
|
||||
_remainingRoundTime = difficulty.roundTimeLimit;
|
||||
_roundTimer = Timer.periodic(const Duration(milliseconds: 100), (timer) {
|
||||
if (_remainingRoundTime <= 0) {
|
||||
_roundTimer?.cancel();
|
||||
_roundsFailed++;
|
||||
_handleFeedbackAndTermination();
|
||||
} else {
|
||||
_remainingRoundTime -= 0.1;
|
||||
notifyListeners();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _startScoreTimer() {
|
||||
_scoreTimer?.cancel();
|
||||
_scoreTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
_secondsElapsed++;
|
||||
});
|
||||
}
|
||||
|
||||
void recordUserInput(SequenceButtonId inputId) {
|
||||
if (_currentState != SequenceGameState.input || _showFeedback) return;
|
||||
|
||||
_userInput.add(inputId);
|
||||
_currentState = SequenceGameState.processing;
|
||||
notifyListeners();
|
||||
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
_checkUserInput();
|
||||
});
|
||||
}
|
||||
|
||||
void _checkUserInput() {
|
||||
if (_currentState != SequenceGameState.processing) return;
|
||||
|
||||
final int inputLength = _userInput.length;
|
||||
final int currentInputIndex = inputLength - 1;
|
||||
|
||||
if (_userInput[currentInputIndex] != _currentSequence[currentInputIndex]) {
|
||||
_roundsFailed++;
|
||||
_isLastInputCorrect = false;
|
||||
_handleFeedbackAndTermination();
|
||||
return;
|
||||
}
|
||||
|
||||
_isLastInputCorrect = true;
|
||||
|
||||
if (inputLength == _currentSequenceLength) {
|
||||
_roundsCompleted++;
|
||||
_maxAchievedLength = _currentSequenceLength;
|
||||
|
||||
_handleFeedbackAndNextRound();
|
||||
return;
|
||||
}
|
||||
|
||||
_currentState = SequenceGameState.input;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _handleFeedbackAndTermination() {
|
||||
_stopAllTimers();
|
||||
_showFeedback = true;
|
||||
notifyListeners();
|
||||
|
||||
Future.delayed(const Duration(seconds: 1), () {
|
||||
_stopAllTimers();
|
||||
_currentState = SequenceGameState.completed;
|
||||
_showFeedback = false;
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
void _handleFeedbackAndNextRound() {
|
||||
_roundTimer?.cancel();
|
||||
_showFeedback = true;
|
||||
notifyListeners();
|
||||
|
||||
Future.delayed(const Duration(milliseconds: 1000), () {
|
||||
_showFeedback = false;
|
||||
|
||||
if (_currentSequenceLength < difficulty.maxGameLength) {
|
||||
_currentSequenceLength++;
|
||||
} else {
|
||||
_stopAllTimers();
|
||||
_currentState = SequenceGameState.completed;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
_startPresentationPhase();
|
||||
});
|
||||
}
|
||||
|
||||
void _stopAllTimers() {
|
||||
_scoreTimer?.cancel();
|
||||
_roundTimer?.cancel();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stopAllTimers();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export 'screens/sequence_lobby_screen.dart';
|
||||
export 'screens/sequence_game_screen.dart';
|
||||
@@ -0,0 +1,108 @@
|
||||
// packages/feature_game_sequence/lib/models/sequence_models.dart
|
||||
|
||||
import 'package:service_api/service_api.dart';
|
||||
|
||||
enum SequenceButtonId {
|
||||
btn0, btn1, btn2, btn3,
|
||||
btn4, btn5, btn6, btn7, btn8, btn9
|
||||
}
|
||||
|
||||
enum SequenceContentPool {
|
||||
number, // 1, 2, 3...
|
||||
letter, // A, B, C...
|
||||
koreanChar, // 가, 나, 다...
|
||||
emoji, // 이모지
|
||||
wordVeg, // 야채
|
||||
wordFruit, // 과일
|
||||
wordItem, // 사물
|
||||
wordNature, // 자연
|
||||
wordPlace, // 장소
|
||||
mixed, // [🔥 의미적 구분용] 실제로는 컨트롤러에서 섞음
|
||||
}
|
||||
|
||||
class SequenceDifficulty extends GameDifficulty {
|
||||
final int levelIndex;
|
||||
final int initialLength;
|
||||
final int maxGameLength;
|
||||
final int buttonsCount;
|
||||
final double roundTimeLimit;
|
||||
final double presentationSpeed;
|
||||
final SequenceContentPool contentPoolType;
|
||||
|
||||
const SequenceDifficulty({
|
||||
required this.levelIndex,
|
||||
required super.name,
|
||||
required super.contextId,
|
||||
required this.initialLength,
|
||||
required this.maxGameLength,
|
||||
required this.buttonsCount,
|
||||
required this.roundTimeLimit,
|
||||
required this.presentationSpeed,
|
||||
required this.contentPoolType,
|
||||
});
|
||||
}
|
||||
|
||||
class SequenceDifficulties {
|
||||
// --- 콘텐츠 풀 데이터 ---
|
||||
static const List<String> pool_Number = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"];
|
||||
static const List<String> pool_Letter = ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J"];
|
||||
static const List<String> pool_KoreanChar = ["가", "나", "다", "라", "마", "바", "사", "아", "자", "차"];
|
||||
static const List<String> pool_Emoji = ["🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", "🐨", "🐯"];
|
||||
|
||||
static const List<String> pool_Veg = ["당근", "오이", "가지", "배추", "무", "파", "고추", "마늘", "감자", "양파"];
|
||||
static const List<String> pool_Fruit = ["사과", "배", "포도", "수박", "딸기", "참외", "귤", "자두", "복숭아", "감"];
|
||||
static const List<String> pool_Item = ["시계", "안경", "모자", "가방", "우산", "신발", "거울", "빗", "수건", "비누"];
|
||||
static const List<String> pool_Nature = ["하늘", "구름", "바람", "비", "눈", "해", "달", "별", "꽃", "나무"];
|
||||
static const List<String> pool_Place = ["버스", "기차", "학교", "병원", "시장", "공원", "집", "바다", "산", "마트"];
|
||||
|
||||
static List<String> getSymbolPool(SequenceContentPool type) {
|
||||
switch (type) {
|
||||
case SequenceContentPool.number: return pool_Number;
|
||||
case SequenceContentPool.letter: return pool_Letter;
|
||||
case SequenceContentPool.koreanChar: return pool_KoreanChar;
|
||||
case SequenceContentPool.emoji: return pool_Emoji;
|
||||
case SequenceContentPool.wordVeg: return pool_Veg;
|
||||
case SequenceContentPool.wordFruit: return pool_Fruit;
|
||||
case SequenceContentPool.wordItem: return pool_Item;
|
||||
case SequenceContentPool.wordNature: return pool_Nature;
|
||||
case SequenceContentPool.wordPlace: return pool_Place;
|
||||
default: return pool_Number;
|
||||
}
|
||||
}
|
||||
|
||||
// [🔥 수정] 15단계 난이도 (Lv 1~9: 단일 테마, Lv 10+: 믹스)
|
||||
static final List<SequenceDifficulty> allDifficulties = [
|
||||
// --- Phase 1: 기호/숫자 (단일) ---
|
||||
const SequenceDifficulty(levelIndex: 1, name: 'Lv. 1: 입문 (숫자/3버튼)', contextId: 'SEQ_L1_NUM', initialLength: 2, maxGameLength: 4, buttonsCount: 3, roundTimeLimit: 20.0, presentationSpeed: 1.5, contentPoolType: SequenceContentPool.number),
|
||||
const SequenceDifficulty(levelIndex: 2, name: 'Lv. 2: 초급 (알파벳/4버튼)', contextId: 'SEQ_L2_LETTER', initialLength: 3, maxGameLength: 5, buttonsCount: 4, roundTimeLimit: 15.0, presentationSpeed: 1.2, contentPoolType: SequenceContentPool.letter),
|
||||
const SequenceDifficulty(levelIndex: 3, name: 'Lv. 3: 기초 (한글/4버튼)', contextId: 'SEQ_L3_KOR', initialLength: 3, maxGameLength: 7, buttonsCount: 4, roundTimeLimit: 12.0, presentationSpeed: 1.0, contentPoolType: SequenceContentPool.koreanChar),
|
||||
|
||||
// --- Phase 2: 이모지/단어 (단일) ---
|
||||
const SequenceDifficulty(levelIndex: 4, name: 'Lv. 4: 중급 (이모지/4버튼)', contextId: 'SEQ_L4_EMOJI', initialLength: 4, maxGameLength: 9, buttonsCount: 4, roundTimeLimit: 10.0, presentationSpeed: 0.9, contentPoolType: SequenceContentPool.emoji),
|
||||
const SequenceDifficulty(levelIndex: 5, name: 'Lv. 5: 중급 (야채/6버튼)', contextId: 'SEQ_L5_VEG', initialLength: 4, maxGameLength: 7, buttonsCount: 6, roundTimeLimit: 12.0, presentationSpeed: 0.9, contentPoolType: SequenceContentPool.wordVeg),
|
||||
const SequenceDifficulty(levelIndex: 6, name: 'Lv. 6: 숙련 (과일/6버튼)', contextId: 'SEQ_L6_FRUIT', initialLength: 5, maxGameLength: 10, buttonsCount: 6, roundTimeLimit: 10.0, presentationSpeed: 0.8, contentPoolType: SequenceContentPool.wordFruit),
|
||||
|
||||
// --- Phase 3: 단어 심화 (단일) ---
|
||||
const SequenceDifficulty(levelIndex: 7, name: 'Lv. 7: 상급 (사물/6버튼)', contextId: 'SEQ_L7_ITEM', initialLength: 5, maxGameLength: 12, buttonsCount: 6, roundTimeLimit: 10.0, presentationSpeed: 0.8, contentPoolType: SequenceContentPool.wordItem),
|
||||
const SequenceDifficulty(levelIndex: 8, name: 'Lv. 8: 전문가 (자연/8버튼)', contextId: 'SEQ_L8_NATURE', initialLength: 5, maxGameLength: 10, buttonsCount: 8, roundTimeLimit: 12.0, presentationSpeed: 0.7, contentPoolType: SequenceContentPool.wordNature),
|
||||
const SequenceDifficulty(levelIndex: 9, name: 'Lv. 9: 전문가 (장소/8버튼)', contextId: 'SEQ_L9_PLACE', initialLength: 6, maxGameLength: 15, buttonsCount: 8, roundTimeLimit: 10.0, presentationSpeed: 0.7, contentPoolType: SequenceContentPool.wordPlace),
|
||||
|
||||
// --- Phase 4: 마스터 (믹스 - Lv 10부터 시작) ---
|
||||
// (contentPoolType은 mixed로 표시하지만 실제로는 Controller에서 처리)
|
||||
const SequenceDifficulty(levelIndex: 10, name: 'Lv. 10: 믹스 (8버튼)', contextId: 'SEQ_L10_MIX_8', initialLength: 6, maxGameLength: 20, buttonsCount: 8, roundTimeLimit: 10.0, presentationSpeed: 0.6, contentPoolType: SequenceContentPool.mixed),
|
||||
const SequenceDifficulty(levelIndex: 11, name: 'Lv. 11: 믹스 (10버튼)', contextId: 'SEQ_L11_MIX_10', initialLength: 6, maxGameLength: 15, buttonsCount: 10, roundTimeLimit: 10.0, presentationSpeed: 0.6, contentPoolType: SequenceContentPool.mixed),
|
||||
const SequenceDifficulty(levelIndex: 12, name: 'Lv. 12: 레전드 (10버튼)', contextId: 'SEQ_L12_LEGEND', initialLength: 7, maxGameLength: 25, buttonsCount: 10, roundTimeLimit: 8.0, presentationSpeed: 0.5, contentPoolType: SequenceContentPool.mixed),
|
||||
|
||||
// --- Phase 5: 신의 영역 (믹스 + 고속) ---
|
||||
const SequenceDifficulty(levelIndex: 13, name: 'Lv. 13: 갓모드 (10버튼)', contextId: 'SEQ_L13_GOD', initialLength: 7, maxGameLength: 30, buttonsCount: 10, roundTimeLimit: 6.0, presentationSpeed: 0.4, contentPoolType: SequenceContentPool.mixed),
|
||||
const SequenceDifficulty(levelIndex: 14, name: 'Lv. 14: 갓모드 (기억)', contextId: 'SEQ_L14_GOD_MEM', initialLength: 8, maxGameLength: 40, buttonsCount: 10, roundTimeLimit: 5.0, presentationSpeed: 0.4, contentPoolType: SequenceContentPool.mixed),
|
||||
const SequenceDifficulty(levelIndex: 15, name: 'Lv. 15: 엔드게임', contextId: 'SEQ_L15_END', initialLength: 8, maxGameLength: 50, buttonsCount: 10, roundTimeLimit: 5.0, presentationSpeed: 0.3, contentPoolType: SequenceContentPool.mixed),
|
||||
];
|
||||
|
||||
static SequenceDifficulty 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,290 @@
|
||||
// packages/feature_game_sequence/lib/screens/sequence_game_screen.dart
|
||||
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:feature_common/feature_common.dart';
|
||||
import 'package:service_api/service_api.dart';
|
||||
import '../controllers/sequence_game_controller.dart';
|
||||
import '../models/sequence_models.dart';
|
||||
import '../widgets/sequence_button_widget.dart';
|
||||
|
||||
class SequenceGameScreen extends StatefulWidget {
|
||||
const SequenceGameScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SequenceGameScreen> createState() => _SequenceGameScreenState();
|
||||
}
|
||||
|
||||
class _SequenceGameScreenState extends State<SequenceGameScreen> {
|
||||
bool _isDialogShowing = false;
|
||||
Timer? _presentationTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final controller = Provider.of<SequenceGameController>(context, listen: false);
|
||||
|
||||
controller.addListener(() {
|
||||
if (controller.currentState == SequenceGameState.presenting && _presentationTimer == null) {
|
||||
_runPresentationAnimation(controller);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_presentationTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _runPresentationAnimation(SequenceGameController controller) {
|
||||
_presentationTimer?.cancel();
|
||||
|
||||
final List<SequenceButtonId> sequence = controller.currentSequence;
|
||||
final double speed = controller.difficulty.presentationSpeed;
|
||||
final Duration stepDuration = Duration(milliseconds: (speed * 1000).round());
|
||||
|
||||
int step = 0;
|
||||
|
||||
_presentationTimer = Timer.periodic(stepDuration, (timer) {
|
||||
if (step < sequence.length) {
|
||||
controller.setActivePresentation(sequence[step]);
|
||||
controller.presentationStep = step + 1;
|
||||
step++;
|
||||
} else {
|
||||
timer.cancel();
|
||||
controller.setActivePresentation(null);
|
||||
_presentationTimer = null;
|
||||
|
||||
if (mounted) {
|
||||
controller.runPostPresentationDelay();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _showGameCompletion(SequenceGameController controller) async {
|
||||
String formatSequenceScore(int primary, int? secondary) {
|
||||
final roundsCompleted = primary;
|
||||
final roundsFailed = secondary ?? 0;
|
||||
return '성공 ${roundsCompleted}회 / 실패 ${roundsFailed}회';
|
||||
}
|
||||
|
||||
Future<void> saveSequenceProgress(String playerName) async {
|
||||
final bool isLevelClear = controller.maxAchievedLength >= controller.difficulty.maxGameLength;
|
||||
|
||||
if (!isLevelClear) {
|
||||
debugPrint("레벨 클리어 실패: 도달(${controller.maxAchievedLength}) < 목표(${controller.difficulty.maxGameLength})");
|
||||
return;
|
||||
}
|
||||
|
||||
final identityService = IdentityService();
|
||||
final int currentMaxLevel = await identityService.getMaxUnlockedLevel(gameType: 'SEQUENCE');
|
||||
if (currentMaxLevel < 99) {
|
||||
if (controller.difficulty.levelIndex >= currentMaxLevel) {
|
||||
int nextLevelIndex = controller.difficulty.levelIndex + 1;
|
||||
if (nextLevelIndex > SequenceDifficulties.allDifficulties.length) {
|
||||
await identityService.saveMaxUnlockedLevel(99, gameType: 'SEQUENCE');
|
||||
} else {
|
||||
await identityService.saveMaxUnlockedLevel(nextLevelIndex, gameType: 'SEQUENCE');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => GameCompletionScreen(
|
||||
args: GameResultArgs(
|
||||
gameType: 'SEQUENCE',
|
||||
contextId: controller.difficulty.contextId,
|
||||
primaryScore: controller.roundsCompleted,
|
||||
secondaryScore: controller.roundsFailed,
|
||||
userId: controller.userId,
|
||||
userName: controller.userName,
|
||||
scoreFormatter: formatSequenceScore,
|
||||
onProgressSave: saveSequenceProgress,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (mounted && Navigator.canPop(context)) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
|
||||
// 🔽 [🔥 수정] 순서 제시 상태 표시 바
|
||||
Widget _buildSequenceDisplay(SequenceGameController controller) {
|
||||
final bool isPresenting = controller.currentState == SequenceGameState.presenting;
|
||||
|
||||
// [🔥 핵심 수정] processing 상태도 입력 상태로 간주해야 점멸 현상이 사라짐
|
||||
final bool isInput = controller.currentState == SequenceGameState.input ||
|
||||
controller.currentState == SequenceGameState.processing;
|
||||
|
||||
final bool isDelay = controller.currentState == SequenceGameState.postDelay;
|
||||
|
||||
final int displayLength = controller.currentSequenceLength;
|
||||
final int stepsShown = controller.presentationStep;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10.0, horizontal: 16.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: List.generate(displayLength, (index) {
|
||||
|
||||
String symbol = "?";
|
||||
if (isInput || isDelay) {
|
||||
if (index < controller.userInput.length) {
|
||||
symbol = controller.activeSymbolPool[controller.userInput[index].index];
|
||||
}
|
||||
} else if (isPresenting) {
|
||||
if (index < stepsShown) {
|
||||
symbol = controller.activeSymbolPool[controller.currentSequence[index].index];
|
||||
}
|
||||
}
|
||||
|
||||
final bool isCurrentStep = isPresenting && index == stepsShown - 1;
|
||||
// [🔥 추가] 입력 중인 칸 강조
|
||||
final bool isCurrentInput = isInput && index == controller.userInput.length;
|
||||
|
||||
return Container(
|
||||
width: 30,
|
||||
height: 30,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: isCurrentStep ? Colors.orange.shade300 : Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
border: Border.all(
|
||||
// 입력 대기 커서 강조
|
||||
color: isCurrentInput ? Theme.of(context).primaryColor : Colors.transparent,
|
||||
width: 2,
|
||||
)
|
||||
),
|
||||
child: Center(
|
||||
child: Text(symbol, style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
color: (isInput && index >= controller.userInput.length) ? Colors.black38 : Colors.black,
|
||||
)),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFeedbackWidget(SequenceGameController controller, ThemeData theme) {
|
||||
if (controller.showFeedback) {
|
||||
final String text = controller.isLastInputCorrect ? "성공! 다음 순서로..." : "오답! 🚨";
|
||||
final Color color = controller.isLastInputCorrect ? Colors.green : theme.colorScheme.error;
|
||||
|
||||
return Text(text, style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: color));
|
||||
}
|
||||
|
||||
if (controller.currentState == SequenceGameState.presenting) {
|
||||
return const Text(" ", style: TextStyle(fontSize: 24));
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = context.watch<SequenceGameController>();
|
||||
final state = controller.currentState;
|
||||
final theme = Theme.of(context); // 👈 [수정] theme 변수 정의 확인
|
||||
|
||||
if (state == SequenceGameState.completed && !_isDialogShowing) {
|
||||
_isDialogShowing = true;
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
if (mounted) _showGameCompletion(controller);
|
||||
});
|
||||
}
|
||||
|
||||
String statusText;
|
||||
Color statusColor = Colors.grey;
|
||||
if (state == SequenceGameState.presenting) {
|
||||
statusText = "👀 순서 제시 중...";
|
||||
statusColor = Colors.orange;
|
||||
} else if (state == SequenceGameState.postDelay) {
|
||||
statusText = "준비! ☝️";
|
||||
statusColor = Colors.green;
|
||||
} else if (state == SequenceGameState.input || state == SequenceGameState.processing) {
|
||||
statusText = "👉 입력 대기 중 (${controller.remainingRoundTime.toStringAsFixed(1)}s)";
|
||||
statusColor = theme.primaryColor;
|
||||
} else if (state == SequenceGameState.completed) {
|
||||
bool isClear = controller.maxAchievedLength >= controller.difficulty.maxGameLength;
|
||||
statusText = isClear ? "목표 달성! 🎉" : "게임 종료 (실패)";
|
||||
statusColor = isClear ? Colors.green : Colors.red;
|
||||
} else {
|
||||
statusText = "시작 대기 중";
|
||||
}
|
||||
|
||||
final List<SequenceButtonId> availableButtons = SequenceButtonId.values.sublist(0, controller.difficulty.buttonsCount);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text("길이: ${controller.currentSequenceLength} / ${controller.difficulty.maxGameLength}"),
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16.0),
|
||||
child: Center(
|
||||
child: Text("시간: ${(controller.secondsElapsed ~/ 60).toString().padLeft(2, '0')}:${(controller.secondsElapsed % 60).toString().padLeft(2, '0')}", style: const TextStyle(fontSize: 18)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildSequenceDisplay(controller),
|
||||
|
||||
_buildFeedbackWidget(controller, theme),
|
||||
|
||||
const SizedBox(height: 10),
|
||||
Text(statusText, style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: statusColor)),
|
||||
Text("최고 기록: ${controller.maxAchievedLength} | 성공: ${controller.roundsCompleted}", style: const TextStyle(fontSize: 16, color: Colors.grey)),
|
||||
const SizedBox(height: 40),
|
||||
|
||||
SizedBox(
|
||||
width: 300,
|
||||
child: GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: controller.difficulty.buttonsCount > 4 ? 3 : 2,
|
||||
mainAxisSpacing: 16,
|
||||
crossAxisSpacing: 16,
|
||||
childAspectRatio: 1.5,
|
||||
),
|
||||
itemCount: availableButtons.length,
|
||||
itemBuilder: (context, index) {
|
||||
final buttonId = availableButtons[index];
|
||||
|
||||
return SequenceButtonWidget(
|
||||
id: buttonId,
|
||||
onTap: (state == SequenceGameState.input && !controller.showFeedback)
|
||||
? () => controller.recordUserInput(buttonId)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 80),
|
||||
|
||||
if (state == SequenceGameState.completed)
|
||||
ElevatedButton(
|
||||
onPressed: controller.restartGame,
|
||||
child: const Text('다시 시작', style: TextStyle(fontSize: 20)),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// packages/feature_game_sequence/lib/screens/sequence_lobby_screen.dart
|
||||
|
||||
import 'dart:developer';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
// [C] 공통 서비스
|
||||
import 'package:service_api/service_api.dart';
|
||||
// [A] 공통 UI 셸
|
||||
import 'package:feature_common/feature_common.dart';
|
||||
// [B] 순서 기억 게임 모델/컨트롤러
|
||||
import '../models/sequence_models.dart';
|
||||
import 'sequence_game_screen.dart';
|
||||
import '../controllers/sequence_game_controller.dart';
|
||||
|
||||
class SequenceLobbyScreen extends StatefulWidget {
|
||||
const SequenceLobbyScreen({ super.key });
|
||||
@override
|
||||
State<SequenceLobbyScreen> createState() => _SequenceLobbyScreenState();
|
||||
}
|
||||
|
||||
class _SequenceLobbyScreenState extends State<SequenceLobbyScreen> {
|
||||
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('SEQUENCE');
|
||||
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<SequenceDifficulty>(
|
||||
gameType: 'SEQUENCE',
|
||||
myName: myName,
|
||||
allLevels: SequenceDifficulties.allDifficulties,
|
||||
getLevelIndex: (level) => level.levelIndex,
|
||||
);
|
||||
if (mounted) { setState(() { _rankHistory = rankHistory; }); }
|
||||
log("순서 기억 랭킹 변동 확인 완료. (유저: $myName)");
|
||||
} catch (e) {
|
||||
log("SequenceLobbyScreen: 랭킹 확인 실패: $e");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 🔽 [게임 시작] 컨트롤러 생성 및 화면 이동
|
||||
Future<void> _startGame(SequenceDifficulty 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 = SequenceGameController();
|
||||
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 SequenceGameScreen(),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// 3. 게임 종료 후 레벨 잠금 상태만 새로고침
|
||||
_loadProgress(forceRefreshRanks: false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
context.watch<ThemeNotifier>();
|
||||
context.watch<SessionNotifier>();
|
||||
|
||||
final bool allLevelsUnlocked = _maxUnlockedLevel >= SequenceDifficulties.allDifficulties.length;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
// 🔽 [핵심] CommonGameShell 사용
|
||||
return CommonGameShell(
|
||||
title: '순서 기억 퀴즈 (Simon)',
|
||||
onRankingPressed: () {
|
||||
// 랭킹 화면 호출
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => RankingScreen(
|
||||
gameType: 'SEQUENCE',
|
||||
difficulties: SequenceDifficulties.allDifficulties, // 👈 [수정] 바로 전달
|
||||
initialDifficultyName: SequenceDifficulties.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: SequenceDifficulties.allDifficulties.length,
|
||||
itemBuilder: (context, index) {
|
||||
final SequenceDifficulty level = SequenceDifficulties.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,85 @@
|
||||
// packages/feature_game_sequence/lib/widgets/sequence_button_widget.dart
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/sequence_models.dart';
|
||||
import '../controllers/sequence_game_controller.dart';
|
||||
|
||||
class SequenceButtonWidget extends StatelessWidget {
|
||||
final SequenceButtonId id;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const SequenceButtonWidget({
|
||||
super.key,
|
||||
required this.id,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
Color _getButtonColor() {
|
||||
switch (id) {
|
||||
case SequenceButtonId.btn0: return Colors.green.shade400;
|
||||
case SequenceButtonId.btn1: return Colors.red.shade400;
|
||||
case SequenceButtonId.btn2: return Colors.blue.shade400;
|
||||
case SequenceButtonId.btn3: return Colors.yellow.shade400;
|
||||
case SequenceButtonId.btn4: return Colors.purple.shade400;
|
||||
case SequenceButtonId.btn5: return Colors.orange.shade400;
|
||||
case SequenceButtonId.btn6: return Colors.teal.shade400;
|
||||
case SequenceButtonId.btn7: return Colors.pink.shade400;
|
||||
case SequenceButtonId.btn8: return Colors.indigo.shade400;
|
||||
case SequenceButtonId.btn9: return Colors.cyan.shade400;
|
||||
default: return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = context.watch<SequenceGameController>();
|
||||
final isInputPhase = controller.currentState == SequenceGameState.input;
|
||||
|
||||
final bool isActive =
|
||||
controller.activePresentationId == id ||
|
||||
(isInputPhase && controller.userInput.lastOrNull == id);
|
||||
|
||||
final String buttonSymbol = controller.getSymbolForId(id);
|
||||
|
||||
final Color baseColor = _getButtonColor();
|
||||
final Color activeColor = baseColor.withOpacity(1.0).withAlpha(255);
|
||||
final Color inactiveColor = baseColor.withOpacity(onTap == null ? 0.4 : 0.8);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 100),
|
||||
curve: Curves.easeOut,
|
||||
decoration: BoxDecoration(
|
||||
color: isActive ? activeColor : inactiveColor,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: isActive ? Colors.white : Colors.transparent,
|
||||
width: isActive ? 4 : 0
|
||||
),
|
||||
boxShadow: isActive
|
||||
? [BoxShadow(color: activeColor.withOpacity(0.8), blurRadius: 10, offset: const Offset(0, 0))]
|
||||
: [const BoxShadow(color: Colors.black26, blurRadius: 4, offset: Offset(2, 2))],
|
||||
),
|
||||
child: Center(
|
||||
// 🔽 [🔥 수정] FittedBox를 사용하여 텍스트 크기 자동 조절 (단어 대응)
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(4.0),
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
buttonSymbol,
|
||||
style: const TextStyle(
|
||||
fontSize: 28, // 기본 폰트 크기
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
name: feature_game_sequence
|
||||
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_sequence/feature_game_sequence.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);
|
||||
});
|
||||
}
|
||||
@@ -12,9 +12,11 @@ class GameRankDto {
|
||||
});
|
||||
|
||||
factory GameRankDto.fromJson(Map<String, dynamic> json) {
|
||||
// [🔥 수정] playerName에 Null check 추가
|
||||
// [🔥 수정] primaryScore에 Null check 및 안전한 toInt() 추가
|
||||
return GameRankDto(
|
||||
playerName: json['playerName'],
|
||||
primaryScore: (json['primaryScore'] as num).toInt(),
|
||||
playerName: json['playerName'] ?? 'Unknown',
|
||||
primaryScore: (json['primaryScore'] as num?)?.toInt() ?? 0,
|
||||
secondaryScore: (json['secondaryScore'] as num?)?.toInt(),
|
||||
);
|
||||
}
|
||||
@@ -33,7 +35,8 @@ class GameRankWithRankNumber {
|
||||
factory GameRankWithRankNumber.fromJson(Map<String, dynamic> json) {
|
||||
return GameRankWithRankNumber(
|
||||
rankData: GameRankDto.fromJson(json['rankData']),
|
||||
rankNumber: (json['rankNumber'] as num).toInt(),
|
||||
// [🔥 수정] rankNumber에 Null check 및 안전한 toInt() 추가
|
||||
rankNumber: (json['rankNumber'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -65,4 +68,4 @@ class RankSubmissionResult {
|
||||
myRank: myRankData,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
// packages/service_api/lib/services/identity_service.dart
|
||||
|
||||
import 'dart:convert';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
// 🔽 [신규] 현재 로그인 세션을 담을 모델
|
||||
// 현재 로그인 세션을 담을 모델
|
||||
class UserSession {
|
||||
final String userId;
|
||||
final String? userName;
|
||||
@@ -26,21 +28,31 @@ class IdentityService {
|
||||
static const String _loginProviderKey = 'app_login_provider';
|
||||
static const String _userEmailKey = 'app_user_email';
|
||||
|
||||
// 게임별 저장 키 정의
|
||||
static const String _sudokuMaxLevelKey = 'max_unlocked_level';
|
||||
static const String _sudokuRankMapKey = 'last_checked_rank_map';
|
||||
|
||||
static const String _spiderMaxLevelKey = 'max_unlocked_spider_level';
|
||||
static const String _spiderRankMapKey = 'last_checked_spider_rank_map';
|
||||
|
||||
// 🔽 [신규] 수학 퀴즈 전용 키
|
||||
static const String _mathQuizMaxLevelKey = 'max_unlocked_mathquiz_level';
|
||||
static const String _mathQuizRankMapKey = 'last_checked_mathquiz_rank_map';
|
||||
|
||||
// [🔥 신규] 색상 일치 게임 키
|
||||
static const String _colorMatchMaxLevelKey = 'max_unlocked_colormatch_level';
|
||||
static const String _colorMatchRankMapKey = 'last_checked_colormatch_rank_map';
|
||||
|
||||
// [🔥 신규] 순서 기억 게임 키
|
||||
static const String _sequenceMaxLevelKey = 'max_unlocked_sequence_level';
|
||||
static const String _sequenceRankMapKey = 'last_checked_sequence_rank_map';
|
||||
|
||||
// [🔥 신규] 카드 뒤집기 게임 키
|
||||
static const String _cardFlipMaxLevelKey = 'max_unlocked_cardflip_level';
|
||||
static const String _cardFlipRankMapKey = 'last_checked_cardflip_rank_map';
|
||||
|
||||
final _storage = const FlutterSecureStorage();
|
||||
|
||||
IOSOptions _getIOSOptions() => const IOSOptions(
|
||||
// 🔽 [수정] Xcode 설정 전까지 'groupId'를 주석 처리하여 크래시 방지
|
||||
// groupId: 'group.com.lunaticbum.mygamecenter',
|
||||
);
|
||||
IOSOptions _getIOSOptions() => const IOSOptions();
|
||||
|
||||
AndroidOptions _getAndroidOptions() => const AndroidOptions(
|
||||
encryptedSharedPreferences: true,
|
||||
@@ -137,16 +149,25 @@ class IdentityService {
|
||||
return await getUserSession();
|
||||
}
|
||||
|
||||
// 🔽 [수정] 7. 최대 레벨 가져오기 (gameType 분기)
|
||||
// 7. [수정] 최대 레벨 가져오기 (모든 게임 타입 지원)
|
||||
Future<int> getMaxUnlockedLevel({String gameType = 'SUDOKU'}) async {
|
||||
String key;
|
||||
switch (gameType) {
|
||||
case 'SPIDER':
|
||||
key = _spiderMaxLevelKey;
|
||||
break;
|
||||
case 'MATH_QUIZ': // 👈 [추가]
|
||||
case 'MATH_QUIZ':
|
||||
key = _mathQuizMaxLevelKey;
|
||||
break;
|
||||
case 'COLOR_MATCH': // 👈 추가
|
||||
key = _colorMatchMaxLevelKey;
|
||||
break;
|
||||
case 'SEQUENCE': // 👈 추가
|
||||
key = _sequenceMaxLevelKey;
|
||||
break;
|
||||
case 'CARD_FLIP': // 👈 추가
|
||||
key = _cardFlipMaxLevelKey;
|
||||
break;
|
||||
default: // 'SUDOKU'
|
||||
key = _sudokuMaxLevelKey;
|
||||
}
|
||||
@@ -158,16 +179,25 @@ class IdentityService {
|
||||
return int.parse(levelString ?? '1');
|
||||
}
|
||||
|
||||
// 🔽 [수정] 8. 최대 레벨 저장하기 (gameType 분기)
|
||||
// 8. [수정] 최대 레벨 저장하기 (모든 게임 타입 지원)
|
||||
Future<void> saveMaxUnlockedLevel(int level, {String gameType = 'SUDOKU'}) async {
|
||||
String key;
|
||||
switch (gameType) {
|
||||
case 'SPIDER':
|
||||
key = _spiderMaxLevelKey;
|
||||
break;
|
||||
case 'MATH_QUIZ': // 👈 [추가]
|
||||
case 'MATH_QUIZ':
|
||||
key = _mathQuizMaxLevelKey;
|
||||
break;
|
||||
case 'COLOR_MATCH': // 👈 추가
|
||||
key = _colorMatchMaxLevelKey;
|
||||
break;
|
||||
case 'SEQUENCE': // 👈 추가
|
||||
key = _sequenceMaxLevelKey;
|
||||
break;
|
||||
case 'CARD_FLIP': // 👈 추가
|
||||
key = _cardFlipMaxLevelKey;
|
||||
break;
|
||||
default: // 'SUDOKU'
|
||||
key = _sudokuMaxLevelKey;
|
||||
}
|
||||
@@ -179,16 +209,25 @@ class IdentityService {
|
||||
);
|
||||
}
|
||||
|
||||
// 🔽 [수정] 9. 마지막 랭킹 맵 가져오기 (gameType 분기)
|
||||
// 9. [수정] 마지막 랭킹 맵 가져오기 (모든 게임 타입 지원)
|
||||
Future<Map<int, int>> getLastSavedRankMap({String gameType = 'SUDOKU'}) async {
|
||||
String key;
|
||||
switch (gameType) {
|
||||
case 'SPIDER':
|
||||
key = _spiderRankMapKey;
|
||||
break;
|
||||
case 'MATH_QUIZ': // 👈 [추가]
|
||||
case 'MATH_QUIZ':
|
||||
key = _mathQuizRankMapKey;
|
||||
break;
|
||||
case 'COLOR_MATCH': // 👈 추가
|
||||
key = _colorMatchRankMapKey;
|
||||
break;
|
||||
case 'SEQUENCE': // 👈 추가
|
||||
key = _sequenceRankMapKey;
|
||||
break;
|
||||
case 'CARD_FLIP': // 👈 추가
|
||||
key = _cardFlipRankMapKey;
|
||||
break;
|
||||
default: // 'SUDOKU'
|
||||
key = _sudokuRankMapKey;
|
||||
}
|
||||
@@ -207,16 +246,25 @@ class IdentityService {
|
||||
}
|
||||
}
|
||||
|
||||
// 🔽 [수정] 10. 마지막 랭킹 맵 저장하기 (gameType 분기)
|
||||
// 10. [수정] 마지막 랭킹 맵 저장하기 (모든 게임 타입 지원)
|
||||
Future<void> saveLastRankMap(Map<int, int> rankMap, {String gameType = 'SUDOKU'}) async {
|
||||
String key;
|
||||
switch (gameType) {
|
||||
case 'SPIDER':
|
||||
key = _spiderRankMapKey;
|
||||
break;
|
||||
case 'MATH_QUIZ': // 👈 [추가]
|
||||
case 'MATH_QUIZ':
|
||||
key = _mathQuizRankMapKey;
|
||||
break;
|
||||
case 'COLOR_MATCH': // 👈 추가
|
||||
key = _colorMatchRankMapKey;
|
||||
break;
|
||||
case 'SEQUENCE': // 👈 추가
|
||||
key = _sequenceRankMapKey;
|
||||
break;
|
||||
case 'CARD_FLIP': // 👈 추가
|
||||
key = _cardFlipRankMapKey;
|
||||
break;
|
||||
default: // 'SUDOKU'
|
||||
key = _sudokuRankMapKey;
|
||||
}
|
||||
|
||||
@@ -96,7 +96,9 @@ class PuzzleService {
|
||||
final List<dynamic> data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
return data.map((json) => GameRankDto.fromJson(json)).toList();
|
||||
} else {
|
||||
throw Exception('랭킹 로딩 실패');
|
||||
// [🔥 수정] 실패 시 HTTP 상태 코드 명시
|
||||
log("랭킹 로딩 실패: HTTP Status ${response.statusCode}");
|
||||
throw Exception('랭킹 로딩 실패: HTTP Status ${response.statusCode}');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user