...
This commit is contained in:
parent
3b053530f5
commit
2008c377f4
15
.vscode/launch.json
vendored
15
.vscode/launch.json
vendored
@ -4,6 +4,7 @@
|
|||||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
|
|
||||||
{
|
{
|
||||||
"name": "my_game_center",
|
"name": "my_game_center",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
@ -15,6 +16,20 @@
|
|||||||
"request": "launch",
|
"request": "launch",
|
||||||
"type": "dart"
|
"type": "dart"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "app_spider (profile mode)",
|
||||||
|
"cwd": "apps/app_spider",
|
||||||
|
"request": "launch",
|
||||||
|
"type": "dart",
|
||||||
|
"flutterMode": "profile"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "app_mathquiz (profile mode)",
|
||||||
|
"cwd": "apps/app_mathquiz",
|
||||||
|
"request": "launch",
|
||||||
|
"type": "dart",
|
||||||
|
"flutterMode": "profile"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "app_sudoku (profile mode)",
|
"name": "app_sudoku (profile mode)",
|
||||||
"cwd": "apps/app_sudoku",
|
"cwd": "apps/app_sudoku",
|
||||||
|
|||||||
3
apps/app_mathquiz/devtools_options.yaml
Normal file
3
apps/app_mathquiz/devtools_options.yaml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
description: This file stores settings for Dart & Flutter DevTools.
|
||||||
|
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
||||||
|
extensions:
|
||||||
@ -1,156 +0,0 @@
|
|||||||
// // packages/feature_common/lib/screens/home_screen.dart
|
|
||||||
|
|
||||||
// import 'dart:developer';
|
|
||||||
// import 'package:flutter/material.dart';
|
|
||||||
// import 'package:provider/provider.dart';
|
|
||||||
|
|
||||||
// // 🔽 [수정] 서비스만 import하고, 모델은 새로 만든 GameInfo를 사용
|
|
||||||
// import 'package:service_api/service_api.dart';
|
|
||||||
// import '../models/game_info.dart'; // 👈 GameInfo 모델 import
|
|
||||||
|
|
||||||
// // 🔽 [수정] 내부에서 사용하던 위젯/화면 import
|
|
||||||
// import 'ranking_screen.dart';
|
|
||||||
// import 'settings_screen.dart';
|
|
||||||
// import '../widgets/ad_banner_widget.dart';
|
|
||||||
|
|
||||||
// class HomeScreen extends StatefulWidget {
|
|
||||||
// // 🔽 [수정] 'onStartGame' 대신 'availableGames' 리스트를 주입받음
|
|
||||||
// final List<GameInfo> availableGames;
|
|
||||||
|
|
||||||
// const HomeScreen({
|
|
||||||
// super.key,
|
|
||||||
// required this.availableGames, // 👈 생성자 변경
|
|
||||||
// });
|
|
||||||
|
|
||||||
// @override
|
|
||||||
// State<HomeScreen> createState() => _HomeScreenState();
|
|
||||||
// }
|
|
||||||
|
|
||||||
// class _HomeScreenState extends State<HomeScreen> {
|
|
||||||
// // 🔽 [삭제] 스도쿠 전용 상태 변수들 모두 삭제
|
|
||||||
// // int _maxUnlockedLevel = 1;
|
|
||||||
// // Map<int, (int, int)> _rankHistory = {};
|
|
||||||
// // String? _userName;
|
|
||||||
// // late String _selectedThemeName;
|
|
||||||
// // bool _isLoading = false;
|
|
||||||
|
|
||||||
// // 🔽 [삭제] 스도쿠 전용 서비스들 삭제
|
|
||||||
// // final PuzzleService _puzzleService = PuzzleService();
|
|
||||||
// // final IdentityService _identityService = IdentityService();
|
|
||||||
|
|
||||||
// @override
|
|
||||||
// void initState() {
|
|
||||||
// super.initState();
|
|
||||||
// // 🔽 [삭제] _loadProgress() 등 스도쿠 전용 로직 삭제
|
|
||||||
// }
|
|
||||||
|
|
||||||
// // 🔽 [삭제] _loadProgress 메서드 전체 삭제
|
|
||||||
// // Future<void> _loadProgress() async { ... }
|
|
||||||
|
|
||||||
// @override
|
|
||||||
// Widget build(BuildContext context) {
|
|
||||||
// context.watch<ThemeNotifier>();
|
|
||||||
// final theme = Theme.of(context);
|
|
||||||
|
|
||||||
// return Scaffold(
|
|
||||||
// appBar: AppBar(
|
|
||||||
// // 🔽 [수정] 앱 이름은 main.dart에서 설정하므로 여기선 비움
|
|
||||||
// title: const Text('게임 센터'),
|
|
||||||
// actions: [
|
|
||||||
// IconButton(
|
|
||||||
// icon: const Icon(Icons.settings_outlined),
|
|
||||||
// onPressed: () {
|
|
||||||
// Navigator.push(
|
|
||||||
// context,
|
|
||||||
// MaterialPageRoute(
|
|
||||||
// builder: (context) => const SettingsScreen(),
|
|
||||||
// ),
|
|
||||||
// );
|
|
||||||
// },
|
|
||||||
// ),
|
|
||||||
// ],
|
|
||||||
// ),
|
|
||||||
// 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: [
|
|
||||||
// // 🔽 [삭제] 스도쿠 전용 '테마 선택' Dropdown 삭제
|
|
||||||
|
|
||||||
// // 2. 레벨 선택 리스트 (범용으로 변경)
|
|
||||||
// Expanded(
|
|
||||||
// // 🔽 [수정] ListView.builder가 주입받은 'widget.availableGames' 사용
|
|
||||||
// child: ListView.builder(
|
|
||||||
// itemCount: widget.availableGames.length,
|
|
||||||
// itemBuilder: (context, index) {
|
|
||||||
// final GameInfo game = widget.availableGames[index];
|
|
||||||
|
|
||||||
// return Card(
|
|
||||||
// margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0),
|
|
||||||
// child: ListTile(
|
|
||||||
// leading: Icon(
|
|
||||||
// game.icon, // 👈 GameInfo에서 아이콘 가져오기
|
|
||||||
// color: theme.primaryColor,
|
|
||||||
// ),
|
|
||||||
// title: Text(game.name, style: const TextStyle( // 👈 GameInfo에서 이름 가져오기
|
|
||||||
// fontSize: 18,
|
|
||||||
// fontWeight: FontWeight.bold,
|
|
||||||
// )),
|
|
||||||
// trailing: const Icon(Icons.play_arrow_rounded),
|
|
||||||
|
|
||||||
// // 🔽 [수정] onTap에 주입받은 game.onTap 함수 연결
|
|
||||||
// onTap: game.onTap,
|
|
||||||
// ),
|
|
||||||
// );
|
|
||||||
// },
|
|
||||||
// ),
|
|
||||||
// ),
|
|
||||||
|
|
||||||
// // 3. 랭킹 보기 버튼 (랭킹 스크린은 공통이므로 그대로 둠)
|
|
||||||
// Container(
|
|
||||||
// margin: const EdgeInsets.fromLTRB(16.0, 0, 16.0, 8.0),
|
|
||||||
// // ... (이하 랭킹 보기 버튼 스타일은 동일) ...
|
|
||||||
// child: InkWell(
|
|
||||||
// onTap: () {
|
|
||||||
// // 🔽 [수정] 스도쿠 레벨 대신 기본 랭킹 화면으로
|
|
||||||
// Navigator.push(
|
|
||||||
// context,
|
|
||||||
// MaterialPageRoute(
|
|
||||||
// builder: (context) => const RankingScreen(
|
|
||||||
// // initialDifficultyName: "중급 (9x9)", // 👈 필요시 하드코딩
|
|
||||||
// ),
|
|
||||||
// ),
|
|
||||||
// );
|
|
||||||
// },
|
|
||||||
// child: Container(
|
|
||||||
// width: double.infinity,
|
|
||||||
// padding: const EdgeInsets.symmetric(vertical: 14.0),
|
|
||||||
// child: Text(
|
|
||||||
// '🏆 전체 랭킹 보기',
|
|
||||||
// textAlign: TextAlign.center,
|
|
||||||
// style: TextStyle(
|
|
||||||
// fontSize: 16,
|
|
||||||
// fontWeight: FontWeight.bold,
|
|
||||||
// color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
// ),
|
|
||||||
// ),
|
|
||||||
// ),
|
|
||||||
// ),
|
|
||||||
// // ...
|
|
||||||
// ),
|
|
||||||
// ],
|
|
||||||
// ),
|
|
||||||
// ),
|
|
||||||
// );
|
|
||||||
// },
|
|
||||||
// ),
|
|
||||||
// bottomNavigationBar: const AdBannerWidget(),
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart'; // 👈 [핵심] 이 import 문이 누락되었습니다.
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// [수정] "SBSPACE"를 한 줄로 그리는 IntroView
|
/// [수정] "SBSPACE"를 한 줄로 그리는 IntroView
|
||||||
///
|
///
|
||||||
|
|||||||
@ -1,15 +1,17 @@
|
|||||||
import 'dart:async'; // 👈 [추가] Timer
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:service_api/service_api.dart';
|
import 'package:service_api/service_api.dart';
|
||||||
|
import 'package:function_tree/function_tree.dart';
|
||||||
import 'math_quiz_generator.dart';
|
import 'math_quiz_generator.dart';
|
||||||
import '../models/math_quiz_models.dart';
|
import '../models/math_quiz_models.dart';
|
||||||
|
import '../models/math_quiz_difficulty.dart'; // 👈 MathQuizDifficulty 정의 필요
|
||||||
|
|
||||||
class MathQuizController with ChangeNotifier {
|
class MathQuizController with ChangeNotifier {
|
||||||
late final MathQuizDifficulty difficulty;
|
late final MathQuizDifficulty difficulty;
|
||||||
late final MathQuizPuzzle puzzle;
|
|
||||||
late final String userId;
|
late final String userId;
|
||||||
late final String? userName;
|
late final String? userName;
|
||||||
|
|
||||||
|
late MathQuizPuzzle puzzle;
|
||||||
late List<String?> _userAnswers;
|
late List<String?> _userAnswers;
|
||||||
List<String?> get userAnswers => _userAnswers;
|
List<String?> get userAnswers => _userAnswers;
|
||||||
|
|
||||||
@ -19,78 +21,142 @@ class MathQuizController with ChangeNotifier {
|
|||||||
bool _isGameCompleted = false;
|
bool _isGameCompleted = false;
|
||||||
bool get isGameCompleted => _isGameCompleted;
|
bool get isGameCompleted => _isGameCompleted;
|
||||||
|
|
||||||
// 🔽 [추가] 타이머 및 시간
|
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
int _secondsElapsed = 0;
|
int _secondsElapsed = 0;
|
||||||
int get secondsElapsed => _secondsElapsed;
|
int get secondsElapsed => _secondsElapsed;
|
||||||
|
|
||||||
// 🔽 [추가] 컨트롤러가 제거될 때 타이머 해제
|
late final int _totalPuzzlesInLevel;
|
||||||
|
int _currentPuzzleIndex = 0;
|
||||||
|
int get totalPuzzlesInLevel => _totalPuzzlesInLevel;
|
||||||
|
int get currentPuzzleIndex => _currentPuzzleIndex;
|
||||||
|
|
||||||
|
bool _isWrongAnswer = false;
|
||||||
|
bool get isWrongAnswer => _isWrongAnswer;
|
||||||
|
|
||||||
|
int _totalBlanksFilled = 0;
|
||||||
|
int get totalBlanksFilled => _totalBlanksFilled;
|
||||||
|
|
||||||
|
int _remainingTries = 3;
|
||||||
|
int get remainingTries => _remainingTries;
|
||||||
|
|
||||||
|
bool _isRevealingAnswer = false;
|
||||||
|
bool get isRevealingAnswer => _isRevealingAnswer;
|
||||||
|
|
||||||
|
/// 현재 선택된 빈칸의 타입을 반환
|
||||||
|
PuzzleBlankType? get currentSelectedBlankType {
|
||||||
|
if (puzzle.blankTypes.isEmpty ||
|
||||||
|
_selectedBlankIndex < 0 ||
|
||||||
|
_selectedBlankIndex >= puzzle.blankTypes.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return puzzle.blankTypes[_selectedBlankIndex];
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔽 [추가] 타이머 시작
|
|
||||||
void _startTimer() {
|
void _startTimer() {
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
_secondsElapsed = 0;
|
_secondsElapsed = 0;
|
||||||
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||||
_secondsElapsed++;
|
_secondsElapsed++;
|
||||||
notifyListeners(); // 매초 UI 갱신 (시간 표시용)
|
notifyListeners();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔽 [추가] 타이머 정지
|
|
||||||
void _stopTimer() {
|
void _stopTimer() {
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 1. 로비에서 호출: 새 게임 시작
|
|
||||||
void startNewGame(MathQuizDifficulty level, String userId, String? userName) {
|
void startNewGame(MathQuizDifficulty level, String userId, String? userName) {
|
||||||
this.difficulty = level;
|
this.difficulty = level;
|
||||||
this.userId = userId;
|
this.userId = userId;
|
||||||
this.userName = userName;
|
this.userName = userName;
|
||||||
|
_totalPuzzlesInLevel = level.puzzleCount;
|
||||||
|
_currentPuzzleIndex = 0;
|
||||||
|
_isGameCompleted = false;
|
||||||
|
_totalBlanksFilled = 0;
|
||||||
|
_loadNextPuzzle();
|
||||||
|
_startTimer();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [🔥 수정] 문제 로드 시 로그 추가
|
||||||
|
void _loadNextPuzzle() {
|
||||||
final generator = MathQuizGenerator();
|
final generator = MathQuizGenerator();
|
||||||
this.puzzle = generator.generatePuzzle(level);
|
this.puzzle = generator.generatePuzzle(difficulty);
|
||||||
|
|
||||||
|
// --- [LOG: 문제 구조 확인] ---
|
||||||
|
debugPrint("--- MATH QUIZ PUZZLE LOADED (${difficulty.contextId}) ---");
|
||||||
|
debugPrint("Grid: ${puzzle.gridCells}");
|
||||||
|
debugPrint("Solutions (S): ${puzzle.solutions}");
|
||||||
|
debugPrint("Blank Types (T): ${puzzle.blankTypes}");
|
||||||
|
debugPrint("Total Blanks: ${puzzle.solutions.length}");
|
||||||
|
debugPrint("------------------------------------------");
|
||||||
|
// ----------------------------
|
||||||
|
|
||||||
_userAnswers = List.generate(puzzle.solutions.length, (_) => null);
|
_userAnswers = List.generate(puzzle.solutions.length, (_) => null);
|
||||||
_selectedBlankIndex = 0;
|
_selectedBlankIndex = 0;
|
||||||
_isGameCompleted = false;
|
_isWrongAnswer = false;
|
||||||
|
_remainingTries = 3;
|
||||||
_startTimer(); // 👈 [추가]
|
_isRevealingAnswer = false;
|
||||||
|
|
||||||
notifyListeners();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 2. UI(빈칸)에서 호출: 빈칸 선택
|
/// [🔥 수정] 빈칸 선택 시 로그 추가
|
||||||
void onBlankTapped(int index) {
|
void onBlankTapped(int index) {
|
||||||
if (_isGameCompleted) return;
|
if (_isGameCompleted || _isRevealingAnswer) return;
|
||||||
|
if (_selectedBlankIndex != index) {
|
||||||
_selectedBlankIndex = index;
|
_selectedBlankIndex = index;
|
||||||
notifyListeners();
|
|
||||||
|
// --- [LOG: 선택된 빈칸 타입 확인] ---
|
||||||
|
final selectedType = currentSelectedBlankType?.toString() ?? 'None/Invalid';
|
||||||
|
debugPrint("[LOG] BLANK TAPPED: Index $index selected.");
|
||||||
|
debugPrint("[LOG] BLANK TAPPED: Determined Type: $selectedType");
|
||||||
|
// ----------------------------------
|
||||||
|
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 3. UI(숫자 버튼)에서 호출: 답 입력
|
|
||||||
void onOptionTapped(String option) {
|
void onOptionTapped(String option) {
|
||||||
if (_isGameCompleted) return;
|
if (_isGameCompleted || _isRevealingAnswer) return;
|
||||||
|
_isWrongAnswer = false;
|
||||||
|
|
||||||
|
if (_selectedBlankIndex >= puzzle.blankTypes.length) return;
|
||||||
|
final PuzzleBlankType requiredType = puzzle.blankTypes[_selectedBlankIndex];
|
||||||
|
final bool isOptionNumber = int.tryParse(option) != null;
|
||||||
|
final bool isOptionOperator = ['+', '-', '*', '/'].contains(option);
|
||||||
|
|
||||||
|
if (requiredType == PuzzleBlankType.number && !isOptionNumber) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (requiredType == PuzzleBlankType.operator && !isOptionOperator) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_selectedBlankIndex < _userAnswers.length) {
|
||||||
|
_userAnswers[_selectedBlankIndex] = option;
|
||||||
|
}
|
||||||
|
|
||||||
_userAnswers[_selectedBlankIndex] = option;
|
|
||||||
_selectNextBlank();
|
_selectNextBlank();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
_checkCompletion();
|
_checkCompletion();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 4. UI(지우기 버튼)에서 호출: 답 지우기
|
|
||||||
void onClearTapped() {
|
void onClearTapped() {
|
||||||
if (_isGameCompleted) return;
|
if (_isGameCompleted || _isRevealingAnswer) return;
|
||||||
_userAnswers[_selectedBlankIndex] = null;
|
_isWrongAnswer = false;
|
||||||
|
if (_selectedBlankIndex < _userAnswers.length) {
|
||||||
|
_userAnswers[_selectedBlankIndex] = null;
|
||||||
|
}
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 다음 빈칸 (아직 답이 없는)으로 자동 이동
|
|
||||||
void _selectNextBlank() {
|
void _selectNextBlank() {
|
||||||
|
if (_userAnswers.isEmpty) return;
|
||||||
int nextIndex = (_selectedBlankIndex + 1) % _userAnswers.length;
|
int nextIndex = (_selectedBlankIndex + 1) % _userAnswers.length;
|
||||||
for (int i = 0; i < _userAnswers.length; i++) {
|
for (int i = 0; i < _userAnswers.length; i++) {
|
||||||
if (_userAnswers[nextIndex] == null) {
|
if (_userAnswers[nextIndex] == null) {
|
||||||
@ -101,27 +167,104 @@ class MathQuizController with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 모든 답이 채워졌는지, 그리고 정답인지 확인
|
|
||||||
void _checkCompletion() {
|
void _checkCompletion() {
|
||||||
if (_userAnswers.any((answer) => answer == null)) {
|
if (_userAnswers.any((answer) => answer == null)) {
|
||||||
return;
|
_isWrongAnswer = false;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
bool fastMatch = true;
|
||||||
bool allCorrect = true;
|
|
||||||
for (int i = 0; i < puzzle.solutions.length; i++) {
|
for (int i = 0; i < puzzle.solutions.length; i++) {
|
||||||
if (_userAnswers[i] != puzzle.solutions[i]) {
|
if (_userAnswers[i] != puzzle.solutions[i]) {
|
||||||
allCorrect = false;
|
fastMatch = false;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (fastMatch) {
|
||||||
if (allCorrect) {
|
_handleCorrectAnswer();
|
||||||
_isGameCompleted = true;
|
return;
|
||||||
_stopTimer(); // 👈 [추가]
|
}
|
||||||
notifyListeners();
|
bool slowMatch = _checkSlowPathValidation();
|
||||||
|
if (slowMatch) {
|
||||||
|
_handleCorrectAnswer();
|
||||||
} else {
|
} else {
|
||||||
// [TODO] 오답 처리 (예: 스낵바 표시)
|
_handleWrongAnswer();
|
||||||
debugPrint("오답입니다!");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _checkSlowPathValidation() {
|
||||||
|
List<String> rebuiltGrid = List.of(puzzle.gridCells);
|
||||||
|
int answerIndex = 0;
|
||||||
|
for (int i = 0; i < rebuiltGrid.length; i++) {
|
||||||
|
if (rebuiltGrid[i] == '?') {
|
||||||
|
if (answerIndex < _userAnswers.length) {
|
||||||
|
rebuiltGrid[i] = _userAnswers[answerIndex]!;
|
||||||
|
answerIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
for (final eq in puzzle.equations) {
|
||||||
|
final String expression =
|
||||||
|
eq.expressionIndices.map((i) => rebuiltGrid[i]).join(' ');
|
||||||
|
final String expectedResultStr = rebuiltGrid[eq.resultIndex];
|
||||||
|
if (expression.contains('=') ||
|
||||||
|
expectedResultStr.contains(RegExp(r'[+\-*/]'))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final num actualResult = expression.interpret();
|
||||||
|
final num expectedResult = num.parse(expectedResultStr);
|
||||||
|
if (actualResult != expectedResult) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("방정식 평가 실패: $e");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleCorrectAnswer() {
|
||||||
|
_isWrongAnswer = false;
|
||||||
|
_totalBlanksFilled += _userAnswers.length;
|
||||||
|
if (_currentPuzzleIndex + 1 < _totalPuzzlesInLevel) {
|
||||||
|
_currentPuzzleIndex++;
|
||||||
|
_loadNextPuzzle();
|
||||||
|
notifyListeners();
|
||||||
|
} else {
|
||||||
|
_isGameCompleted = true;
|
||||||
|
_stopTimer();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleWrongAnswer() {
|
||||||
|
_remainingTries--;
|
||||||
|
_isWrongAnswer = true;
|
||||||
|
if (_remainingTries <= 0) {
|
||||||
|
_handleFailedAnswer();
|
||||||
|
} else {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleFailedAnswer() {
|
||||||
|
_isWrongAnswer = false;
|
||||||
|
_isRevealingAnswer = true;
|
||||||
|
_userAnswers = List.of(puzzle.solutions);
|
||||||
|
notifyListeners();
|
||||||
|
Future.delayed(const Duration(seconds: 3), () {
|
||||||
|
if (!_isGameCompleted) {
|
||||||
|
if (_currentPuzzleIndex + 1 < _totalPuzzlesInLevel) {
|
||||||
|
_currentPuzzleIndex++;
|
||||||
|
_loadNextPuzzle();
|
||||||
|
notifyListeners();
|
||||||
|
} else {
|
||||||
|
_isGameCompleted = true;
|
||||||
|
_stopTimer();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -1,42 +1,54 @@
|
|||||||
|
// packages/feature_game_mathquiz/lib/controllers/math_quiz_generator.dart
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
import 'package:flutter/material.dart'; // debugPrint 사용을 위해 유지
|
||||||
import 'package:service_api/service_api.dart';
|
import 'package:service_api/service_api.dart';
|
||||||
|
import '../models/math_quiz_difficulty.dart';
|
||||||
import '../models/math_quiz_models.dart';
|
import '../models/math_quiz_models.dart';
|
||||||
import 'package:function_tree/function_tree.dart';
|
import 'package:function_tree/function_tree.dart';
|
||||||
|
|
||||||
class MathQuizGenerator {
|
class MathQuizGenerator {
|
||||||
final Random _random = Random();
|
final Random _random = Random();
|
||||||
|
|
||||||
|
/// 문자열이 연산자인지 확인
|
||||||
|
bool _isOperator(String value) {
|
||||||
|
return ['+', '-', '*', '/'].contains(value);
|
||||||
|
}
|
||||||
|
|
||||||
/// 난이도에 맞는 퍼즐을 생성합니다.
|
/// 난이도에 맞는 퍼즐을 생성합니다.
|
||||||
MathQuizPuzzle generatePuzzle(MathQuizDifficulty level) {
|
MathQuizPuzzle generatePuzzle(MathQuizDifficulty level) {
|
||||||
switch (level.layout) {
|
switch (level.layout) {
|
||||||
case MathQuizLayout.singleLine:
|
case MathQuizLayout.singleLine:
|
||||||
if (level.operationCount == 2) { // Lv 1-5
|
if (level.operationCount == 2) {
|
||||||
return _generateSimpleEquation(level); // A + B = C
|
return _generateSimpleEquation(level);
|
||||||
} else { // operationCount == 3 (Lv 6-10)
|
} else {
|
||||||
return _generateMultiOpEquation(level); // A + B * C = D
|
return _generateMultiOpEquation(level);
|
||||||
}
|
}
|
||||||
case MathQuizLayout.linkedL: // operationCount == 4 (Lv 11-15)
|
case MathQuizLayout.dualLine:
|
||||||
return _generateLinkedEquations(level); // 2x2 그리드
|
return _generateDualLineEquation(level);
|
||||||
case MathQuizLayout.gridSquare:
|
case MathQuizLayout.linkedL:
|
||||||
if (level.operationCount == 9) { // Lv 10-12 (3x3)
|
return _generateLinkedEquations(level);
|
||||||
return _generate3x3GridEquation(level);
|
case MathQuizLayout.gridSquare:
|
||||||
} else { // Lv 13-15 (4x4)
|
if (level.operationCount == 9) {
|
||||||
|
return _generate3x3GridEquation(level);
|
||||||
|
} else {
|
||||||
return _generate4x4GridEquation(level);
|
return _generate4x4GridEquation(level);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [Lv 1-5] 숫자 2개 연산: (A op B = C)
|
/// [Lv 1-3] 숫자 2개 연산: (A op B = C)
|
||||||
MathQuizPuzzle _generateSimpleEquation(MathQuizDifficulty level) {
|
MathQuizPuzzle _generateSimpleEquation(MathQuizDifficulty level) {
|
||||||
// (이전과 동일)
|
|
||||||
final (int a, int b, int c, String op) = _createEquation(level.operators);
|
final (int a, int b, int c, String op) = _createEquation(level.operators);
|
||||||
final List<String> allParts = (op == '+')
|
final List<String> allParts = (op == '+')
|
||||||
? [a.toString(), op, b.toString(), '=', c.toString()]
|
? [a.toString(), op, b.toString(), '=', c.toString()]
|
||||||
: (op == '-')
|
: (op == '-')
|
||||||
? [c.toString(), op, a.toString(), '=', b.toString()]
|
? [c.toString(), op, a.toString(), '=', b.toString()]
|
||||||
: (op == '*')
|
: (op == '*')
|
||||||
? [a.toString(), op, b.toString(), '=', c.toString()]
|
? [a.toString(), op, b.toString(), '=', c.toString()]
|
||||||
: [c.toString(), op, a.toString(), '=', b.toString()];
|
: [c.toString(), op, a.toString(), '=', b.toString()];
|
||||||
|
|
||||||
|
debugPrint("--- GEN LOG (Lv 1-3): ALL PARTS: $allParts"); // [LOG]
|
||||||
|
|
||||||
List<int> candidateIndices;
|
List<int> candidateIndices;
|
||||||
switch (level.blankType) {
|
switch (level.blankType) {
|
||||||
case MathQuizBlankType.numbersOnly: candidateIndices = [0, 2]; break;
|
case MathQuizBlankType.numbersOnly: candidateIndices = [0, 2]; break;
|
||||||
@ -45,35 +57,62 @@ class MathQuizGenerator {
|
|||||||
}
|
}
|
||||||
final int finalBlankCount = min(level.blankCount, candidateIndices.length);
|
final int finalBlankCount = min(level.blankCount, candidateIndices.length);
|
||||||
final List<int> blankIndices = (candidateIndices..shuffle(_random)).sublist(0, finalBlankCount);
|
final List<int> blankIndices = (candidateIndices..shuffle(_random)).sublist(0, finalBlankCount);
|
||||||
|
|
||||||
|
blankIndices.sort(); // 👈 FIX
|
||||||
|
|
||||||
final List<String> solutions = [];
|
final List<String> solutions = [];
|
||||||
|
final List<PuzzleBlankType> finalBlankTypes = [];
|
||||||
final List<String> gridCells = List.of(allParts);
|
final List<String> gridCells = List.of(allParts);
|
||||||
for (int index in blankIndices) {
|
for (int index in blankIndices) {
|
||||||
solutions.add(allParts[index]);
|
final String solutionValue = allParts[index];
|
||||||
gridCells[index] = '?';
|
solutions.add(solutionValue);
|
||||||
}
|
finalBlankTypes.add(_isOperator(solutionValue)
|
||||||
final Set<String> optionsSet = solutions.toSet();
|
? PuzzleBlankType.operator
|
||||||
while (optionsSet.length < 9) {
|
: PuzzleBlankType.number);
|
||||||
optionsSet.add((_random.nextInt(9) + 1).toString());
|
gridCells[index] = '?';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
debugPrint("--- GEN LOG (Lv 1-3): BLANK INDICES: $blankIndices"); // [LOG]
|
||||||
|
debugPrint("--- GEN LOG (Lv 1-3): SOLUTIONS: $solutions | TYPES: $finalBlankTypes"); // [LOG]
|
||||||
|
|
||||||
|
final Set<String> optionsSet = solutions.toSet();
|
||||||
|
while (optionsSet.length < 9) { optionsSet.add((_random.nextInt(9) + 1).toString()); }
|
||||||
optionsSet.addAll(level.operators.split(','));
|
optionsSet.addAll(level.operators.split(','));
|
||||||
if (level.blankType != MathQuizBlankType.operatorsOnly) {
|
if (level.blankType != MathQuizBlankType.operatorsOnly) { optionsSet.add('='); }
|
||||||
optionsSet.add('=');
|
|
||||||
}
|
|
||||||
final List<String> options = (optionsSet.toList()..shuffle(_random)).toList();
|
final List<String> options = (optionsSet.toList()..shuffle(_random)).toList();
|
||||||
|
|
||||||
return MathQuizPuzzle(
|
return MathQuizPuzzle(
|
||||||
gridCells: gridCells,
|
gridCells: gridCells,
|
||||||
gridCrossAxisCount: 5, // 5x1 그리드
|
gridCrossAxisCount: 5,
|
||||||
solutions: solutions,
|
|
||||||
options: options,
|
options: options,
|
||||||
|
solutions: solutions,
|
||||||
|
blankTypes: finalBlankTypes,
|
||||||
|
equations: [
|
||||||
|
MathQuizEquation(expressionIndices: [0, 1, 2], resultIndex: 4),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [Lv 6-10] 숫자 3개 연산: (A op1 B op2 C = D)
|
/// [Lv 4-6] 숫자 3개 연산: (A op1 B op2 C = D)
|
||||||
MathQuizPuzzle _generateMultiOpEquation(MathQuizDifficulty level) {
|
MathQuizPuzzle _generateMultiOpEquation(MathQuizDifficulty level) {
|
||||||
// 1. 식 생성 (연산자 우선순위 포함)
|
|
||||||
final (int a, int b, int c, String op1, String op2, int result) = _createMultiOpEquation(level.operators);
|
final (int a, int b, int c, String op1, String op2, int result) = _createMultiOpEquation(level.operators);
|
||||||
final List<String> allParts = [a.toString(), op1, b.toString(), op2, c.toString(), '=', result.toString()];
|
|
||||||
// 2. 빈칸 생성
|
// [LOG 1] 튜플 생성 직후 값 확인
|
||||||
|
debugPrint("--- GEN LOG: TUPLE: a=$a, op1=$op1, b=$b, op2=$op2, c=$c, R=$result");
|
||||||
|
|
||||||
|
final List<String> allParts = [
|
||||||
|
a.toString(),
|
||||||
|
op1,
|
||||||
|
b.toString(),
|
||||||
|
op2,
|
||||||
|
c.toString(),
|
||||||
|
'=',
|
||||||
|
result.toString()
|
||||||
|
];
|
||||||
|
|
||||||
|
// [LOG 2] allParts 리스트 완성 직후 값 확인
|
||||||
|
debugPrint("--- GEN LOG (Lv 4-6): ALL PARTS: $allParts");
|
||||||
|
|
||||||
final int blankCount = level.blankCount;
|
final int blankCount = level.blankCount;
|
||||||
List<int> candidateIndices;
|
List<int> candidateIndices;
|
||||||
switch (level.blankType) {
|
switch (level.blankType) {
|
||||||
@ -83,31 +122,110 @@ class MathQuizGenerator {
|
|||||||
}
|
}
|
||||||
final int finalBlankCount = min(blankCount, candidateIndices.length);
|
final int finalBlankCount = min(blankCount, candidateIndices.length);
|
||||||
final List<int> blankIndices = (candidateIndices..shuffle(_random)).sublist(0, finalBlankCount);
|
final List<int> blankIndices = (candidateIndices..shuffle(_random)).sublist(0, finalBlankCount);
|
||||||
|
|
||||||
|
blankIndices.sort(); // 👈 FIX
|
||||||
|
|
||||||
|
// [LOG 3] blankIndices 리스트 결정 직후 확인
|
||||||
|
debugPrint("--- GEN LOG (Lv 4-6): BLANK INDICES: $blankIndices");
|
||||||
|
|
||||||
final List<String> solutions = [];
|
final List<String> solutions = [];
|
||||||
|
final List<PuzzleBlankType> finalBlankTypes = [];
|
||||||
final List<String> gridCells = List.of(allParts);
|
final List<String> gridCells = List.of(allParts);
|
||||||
for (int index in blankIndices) {
|
for (int index in blankIndices) {
|
||||||
solutions.add(allParts[index]);
|
final String solutionValue = allParts[index];
|
||||||
gridCells[index] = '?';
|
solutions.add(solutionValue);
|
||||||
}
|
finalBlankTypes.add(_isOperator(solutionValue)
|
||||||
// 3. 옵션 생성
|
? PuzzleBlankType.operator
|
||||||
final Set<String> optionsSet = solutions.toSet();
|
: PuzzleBlankType.number);
|
||||||
while (optionsSet.length < 9) {
|
gridCells[index] = '?';
|
||||||
optionsSet.add((_random.nextInt(9) + 1).toString());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
debugPrint("--- GEN LOG (Lv 4-6): SOLUTIONS: $solutions | TYPES: $finalBlankTypes"); // [LOG]
|
||||||
|
|
||||||
|
|
||||||
|
final Set<String> optionsSet = solutions.toSet();
|
||||||
|
while (optionsSet.length < 9) { optionsSet.add((_random.nextInt(9) + 1).toString()); }
|
||||||
optionsSet.addAll(level.operators.split(','));
|
optionsSet.addAll(level.operators.split(','));
|
||||||
optionsSet.add('=');
|
optionsSet.add('=');
|
||||||
// 4. 그리드 모델로 반환
|
|
||||||
return MathQuizPuzzle(
|
return MathQuizPuzzle(
|
||||||
gridCells: gridCells,
|
gridCells: gridCells,
|
||||||
gridCrossAxisCount: 7, // 7x1 그리드
|
gridCrossAxisCount: 7,
|
||||||
solutions: solutions,
|
|
||||||
options: (optionsSet.toList()..shuffle(_random)).toList(),
|
options: (optionsSet.toList()..shuffle(_random)).toList(),
|
||||||
|
solutions: solutions,
|
||||||
|
blankTypes: finalBlankTypes,
|
||||||
|
equations: [
|
||||||
|
MathQuizEquation(expressionIndices: [0, 1, 2, 3, 4], resultIndex: 6),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [Lv 11-15] 숫자 4개 연산: (2x2 그리드 'ㄱ', 'ㄴ')
|
/// [Lv 7-9] 독립된 2줄 (A op B = C, D op E = F)
|
||||||
|
MathQuizPuzzle _generateDualLineEquation(MathQuizDifficulty level) {
|
||||||
|
final (int a, int b, int c, String op1) = _createEquation(level.operators);
|
||||||
|
final (int d, int e, int f, String op2) = _createEquation(level.operators);
|
||||||
|
|
||||||
|
final List<String> allParts = [
|
||||||
|
a.toString(), op1, b.toString(), "=", c.toString(),
|
||||||
|
" ", " ", " ", " ", " ",
|
||||||
|
d.toString(), op2, e.toString(), "=", f.toString(),
|
||||||
|
];
|
||||||
|
debugPrint("--- GEN LOG (Lv 7-9 DUAL): ALL PARTS: $allParts"); // [LOG]
|
||||||
|
|
||||||
|
|
||||||
|
List<int> numberIndices = [0, 2, 10, 12];
|
||||||
|
List<int> operatorIndices = [1, 11];
|
||||||
|
List<int> blankIndices = [];
|
||||||
|
switch (level.blankType) {
|
||||||
|
case MathQuizBlankType.numbersOnly:
|
||||||
|
blankIndices = (numberIndices..shuffle(_random)).sublist(0, min(level.blankCount, numberIndices.length));
|
||||||
|
break;
|
||||||
|
case MathQuizBlankType.operatorsOnly:
|
||||||
|
blankIndices = (operatorIndices..shuffle(_random)).sublist(0, min(level.blankCount, operatorIndices.length));
|
||||||
|
break;
|
||||||
|
case MathQuizBlankType.numbersAndOperators:
|
||||||
|
blankIndices = (numberIndices + operatorIndices..shuffle(_random)).sublist(0, level.blankCount);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
blankIndices.sort(); // 👈 FIX
|
||||||
|
|
||||||
|
final List<String> solutions = [];
|
||||||
|
final List<PuzzleBlankType> finalBlankTypes = [];
|
||||||
|
final List<String> gridCells = List.of(allParts);
|
||||||
|
for (int index in blankIndices) {
|
||||||
|
final String solutionValue = allParts[index];
|
||||||
|
solutions.add(solutionValue);
|
||||||
|
finalBlankTypes.add(_isOperator(solutionValue)
|
||||||
|
? PuzzleBlankType.operator
|
||||||
|
: PuzzleBlankType.number);
|
||||||
|
gridCells[index] = '?';
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint("--- GEN LOG (Lv 7-9 DUAL): BLANK INDICES: $blankIndices"); // [LOG]
|
||||||
|
debugPrint("--- GEN LOG (Lv 7-9 DUAL): SOLUTIONS: $solutions | TYPES: $finalBlankTypes"); // [LOG]
|
||||||
|
|
||||||
|
|
||||||
|
final Set<String> optionsSet = solutions.toSet();
|
||||||
|
while (optionsSet.length < 9) { optionsSet.add((_random.nextInt(9) + 1).toString()); }
|
||||||
|
optionsSet.addAll(level.operators.split(','));
|
||||||
|
optionsSet.add('=');
|
||||||
|
|
||||||
|
return MathQuizPuzzle(
|
||||||
|
gridCells: gridCells,
|
||||||
|
gridCrossAxisCount: 5,
|
||||||
|
options: (optionsSet.toList()..shuffle(_random)).toList(),
|
||||||
|
solutions: solutions,
|
||||||
|
blankTypes: finalBlankTypes,
|
||||||
|
equations: [
|
||||||
|
MathQuizEquation(expressionIndices: [0, 1, 2], resultIndex: 4),
|
||||||
|
MathQuizEquation(expressionIndices: [10, 11, 12], resultIndex: 14),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [Lv 10-12] 숫자 4개 연산: (2x2 그리드 'ㄱ', 'ㄴ')
|
||||||
MathQuizPuzzle _generateLinkedEquations(MathQuizDifficulty level) {
|
MathQuizPuzzle _generateLinkedEquations(MathQuizDifficulty level) {
|
||||||
// (동적 생성 로직)
|
|
||||||
while (true) {
|
while (true) {
|
||||||
final int a = _random.nextInt(9) + 1;
|
final int a = _random.nextInt(9) + 1;
|
||||||
final int b = _random.nextInt(9) + 1;
|
final int b = _random.nextInt(9) + 1;
|
||||||
@ -118,24 +236,23 @@ class MathQuizGenerator {
|
|||||||
final String op2 = (ops..shuffle(_random)).first;
|
final String op2 = (ops..shuffle(_random)).first;
|
||||||
final String op3 = (ops..shuffle(_random)).first;
|
final String op3 = (ops..shuffle(_random)).first;
|
||||||
final String op4 = (ops..shuffle(_random)).first;
|
final String op4 = (ops..shuffle(_random)).first;
|
||||||
final int? r1 = _calculate(a, b, op1);
|
final int? r1 = _calculate(a, b, op1);
|
||||||
final int? r2 = _calculate(c, d, op2);
|
final int? r2 = _calculate(c, d, op2);
|
||||||
final int? r3 = _calculate(a, c, op3);
|
final int? r3 = _calculate(a, c, op3);
|
||||||
final int? r4 = _calculate(b, d, op4);
|
final int? r4 = _calculate(b, d, op4);
|
||||||
if (r1 == null || r2 == null || r3 == null || r4 == null) continue;
|
if (r1 == null || r2 == null || r3 == null || r4 == null) continue;
|
||||||
if (r1 < -99 || r2 < -99 || r3 < -99 || r4 < -99 || r1 > 999 || r2 > 999 || r3 > 999 || r4 > 999) continue;
|
if (r1 < -99 || r2 < -99 || r3 < -99 || r4 < -99 || r1 > 999 || r2 > 999 || r3 > 999 || r4 > 999) continue;
|
||||||
|
|
||||||
final List<String> allParts = [
|
final List<String> allParts = [
|
||||||
a.toString(), op1, b.toString(), "=", r1.toString(),
|
a.toString(), op1, b.toString(), "=", r1.toString(),
|
||||||
op3, " ", op4, " ", "=",
|
op3, " ", op4, " ", "=",
|
||||||
c.toString(), op2, d.toString(), "=", r2.toString(),
|
c.toString(), op2, d.toString(), "=", r2.toString(),
|
||||||
"=", " ", "=", " ", "=",
|
"=", " ", "=", " ", "=",
|
||||||
r3.toString(), " ", r4.toString(), " ", " ",
|
r3.toString(), " ", r4.toString(), " ", " ",
|
||||||
];
|
];
|
||||||
final List<String> solutions = [];
|
debugPrint("--- GEN LOG (Lv 10-12 LINKED): ALL PARTS: $allParts"); // [LOG]
|
||||||
final List<String> gridCells = List.of(allParts);
|
|
||||||
List<int> numberIndices = [0, 2, 10, 12]; // A,B,C,D
|
List<int> numberIndices = [0, 2, 10, 12];
|
||||||
List<int> operatorIndices = [1, 5, 7, 11]; // op1,op3,op4,op2
|
List<int> operatorIndices = [1, 5, 7, 11];
|
||||||
List<int> blankIndices = [];
|
List<int> blankIndices = [];
|
||||||
switch (level.blankType) {
|
switch (level.blankType) {
|
||||||
case MathQuizBlankType.numbersOnly:
|
case MathQuizBlankType.numbersOnly:
|
||||||
@ -148,165 +265,244 @@ class MathQuizGenerator {
|
|||||||
blankIndices = (numberIndices + operatorIndices..shuffle(_random)).sublist(0, level.blankCount);
|
blankIndices = (numberIndices + operatorIndices..shuffle(_random)).sublist(0, level.blankCount);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
blankIndices.sort();
|
|
||||||
|
blankIndices.sort(); // 👈 FIX
|
||||||
|
|
||||||
|
final List<String> solutions = [];
|
||||||
|
final List<PuzzleBlankType> finalBlankTypes = [];
|
||||||
|
final List<String> gridCells = List.of(allParts);
|
||||||
for (int index in blankIndices) {
|
for (int index in blankIndices) {
|
||||||
solutions.add(allParts[index]);
|
final String solutionValue = allParts[index];
|
||||||
gridCells[index] = '?';
|
solutions.add(solutionValue);
|
||||||
}
|
finalBlankTypes.add(_isOperator(solutionValue)
|
||||||
final Set<String> optionsSet = solutions.toSet();
|
? PuzzleBlankType.operator
|
||||||
while (optionsSet.length < 9) {
|
: PuzzleBlankType.number);
|
||||||
optionsSet.add((_random.nextInt(9) + 1).toString());
|
gridCells[index] = '?';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
debugPrint("--- GEN LOG (Lv 10-12 LINKED): BLANK INDICES: $blankIndices"); // [LOG]
|
||||||
|
debugPrint("--- GEN LOG (Lv 10-12 LINKED): SOLUTIONS: $solutions | TYPES: $finalBlankTypes"); // [LOG]
|
||||||
|
|
||||||
|
final Set<String> optionsSet = solutions.toSet();
|
||||||
|
while (optionsSet.length < 9) { optionsSet.add((_random.nextInt(9) + 1).toString()); }
|
||||||
optionsSet.addAll(level.operators.split(','));
|
optionsSet.addAll(level.operators.split(','));
|
||||||
optionsSet.add('=');
|
optionsSet.add('=');
|
||||||
|
|
||||||
return MathQuizPuzzle(
|
return MathQuizPuzzle(
|
||||||
gridCells: gridCells,
|
gridCells: gridCells,
|
||||||
gridCrossAxisCount: 5, // 5x5 그리드
|
gridCrossAxisCount: 5,
|
||||||
solutions: solutions,
|
|
||||||
options: (optionsSet.toList()..shuffle(_random)).toList(),
|
options: (optionsSet.toList()..shuffle(_random)).toList(),
|
||||||
|
solutions: solutions,
|
||||||
|
blankTypes: finalBlankTypes,
|
||||||
|
equations: [
|
||||||
|
MathQuizEquation(expressionIndices: [0, 1, 2], resultIndex: 4),
|
||||||
|
MathQuizEquation(expressionIndices: [10, 11, 12], resultIndex: 14),
|
||||||
|
MathQuizEquation(expressionIndices: [0, 5, 10], resultIndex: 20),
|
||||||
|
MathQuizEquation(expressionIndices: [2, 7, 12], resultIndex: 22),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
} // end while(true)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [수정됨] 레벨 10-12 (3x3 Grid) - 템플릿 목록 사용
|
/// [Lv 13-15] 3x3 Grid - 동적 생성
|
||||||
MathQuizPuzzle _generate3x3GridEquation(MathQuizDifficulty level) {
|
MathQuizPuzzle _generate3x3GridEquation(MathQuizDifficulty level) {
|
||||||
// 1. 3x3 템플릿 목록에서 랜덤 선택
|
while (true) {
|
||||||
final template = _get3x3Templates()..shuffle();
|
try {
|
||||||
final selectedTemplate = template.first;
|
final ops = level.operators.split(',');
|
||||||
|
final n = List.generate(9, (_) => _random.nextInt(9) + 1);
|
||||||
final List<String> allParts = selectedTemplate.gridCells;
|
final o = List.generate(12, (_) => (ops..shuffle(_random)).first);
|
||||||
final List<int> numberIndices = selectedTemplate.numberIndices;
|
final expr = [
|
||||||
final List<int> operatorIndices = selectedTemplate.operatorIndices;
|
"${n[0]} ${o[0]} ${n[1]} ${o[1]} ${n[2]}", // R0
|
||||||
|
"${n[3]} ${o[5]} ${n[4]} ${o[6]} ${n[5]}", // R1
|
||||||
|
"${n[6]} ${o[10]} ${n[7]} ${o[11]} ${n[8]}", // R2
|
||||||
|
"${n[0]} ${o[2]} ${n[3]} ${o[7]} ${n[6]}", // R3
|
||||||
|
"${n[1]} ${o[3]} ${n[4]} ${o[8]} ${n[7]}", // R4
|
||||||
|
"${n[2]} ${o[4]} ${n[5]} ${o[9]} ${n[8]}", // R5
|
||||||
|
];
|
||||||
|
final List<num> r = expr.map((e) => e.interpret()).toList();
|
||||||
|
if (r.any((res) => res.toInt() != res || res < -99 || res > 999)) { continue; }
|
||||||
|
final List<int> rInt = r.map((res) => res.toInt()).toList();
|
||||||
|
final List<String> allParts = [
|
||||||
|
n[0].toString(), o[0], n[1].toString(), o[1], n[2].toString(), "=", rInt[0].toString(),
|
||||||
|
o[2], " ", o[3], " ", o[4], " ", "=",
|
||||||
|
n[3].toString(), o[5], n[4].toString(), o[6], n[5].toString(), "=", rInt[1].toString(),
|
||||||
|
o[7], " ", o[8], " ", o[9], " ", "=",
|
||||||
|
n[6].toString(), o[10], n[7].toString(), o[11], n[8].toString(), "=", rInt[2].toString(),
|
||||||
|
"=", " ", "=", " ", "=", " ", "=",
|
||||||
|
rInt[3].toString(), " ", rInt[4].toString(), " ", rInt[5].toString(), " ", " ",
|
||||||
|
];
|
||||||
|
debugPrint("--- GEN LOG (Lv 13-15 Grid): ALL PARTS: $allParts"); // [LOG]
|
||||||
|
|
||||||
// 2. 난이도(level.blankType)에 따라 빈칸('?') 생성
|
|
||||||
final List<String> finalSolutions = [];
|
|
||||||
final List<String> gridCells = List.of(allParts);
|
|
||||||
|
|
||||||
List<int> blankIndices = [];
|
|
||||||
switch (level.blankType) {
|
|
||||||
case MathQuizBlankType.numbersOnly:
|
|
||||||
blankIndices = (numberIndices..shuffle(_random)).sublist(0, min(level.blankCount, numberIndices.length));
|
|
||||||
break;
|
|
||||||
case MathQuizBlankType.operatorsOnly:
|
|
||||||
blankIndices = (operatorIndices..shuffle(_random)).sublist(0, min(level.blankCount, operatorIndices.length));
|
|
||||||
break;
|
|
||||||
case MathQuizBlankType.numbersAndOperators:
|
|
||||||
blankIndices = (numberIndices + operatorIndices..shuffle(_random)).sublist(0, level.blankCount);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 빈칸 적용
|
|
||||||
blankIndices.sort();
|
|
||||||
for (int index in blankIndices) {
|
|
||||||
finalSolutions.add(allParts[index]);
|
|
||||||
gridCells[index] = '?';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 옵션 생성
|
|
||||||
final Set<String> optionsSet = finalSolutions.toSet(); // 👈 [오류 수정]
|
|
||||||
while (optionsSet.length < 9) {
|
|
||||||
optionsSet.add((_random.nextInt(9) + 1).toString());
|
|
||||||
}
|
|
||||||
optionsSet.addAll(level.operators.split(','));
|
|
||||||
optionsSet.add('=');
|
|
||||||
|
|
||||||
final List<String> options = (optionsSet.toList()..shuffle(_random)).toList();
|
|
||||||
|
|
||||||
return MathQuizPuzzle(
|
final List<int> numberIndices = [0, 2, 4, 14, 16, 18, 28, 30, 32];
|
||||||
gridCells: gridCells,
|
final List<int> operatorIndices = [1, 3, 7, 9, 11, 15, 17, 21, 23, 25, 29, 31];
|
||||||
gridCrossAxisCount: 7, // 3x3 퍼즐 (7x7 그리드)
|
List<int> blankIndices = [];
|
||||||
solutions: finalSolutions,
|
switch (level.blankType) {
|
||||||
options: options, // 👈 [오류 수정]
|
case MathQuizBlankType.numbersOnly:
|
||||||
);
|
blankIndices = (numberIndices..shuffle(_random)).sublist(0, min(level.blankCount, numberIndices.length));
|
||||||
|
break;
|
||||||
|
case MathQuizBlankType.operatorsOnly:
|
||||||
|
blankIndices = (operatorIndices..shuffle(_random)).sublist(0, min(level.blankCount, operatorIndices.length));
|
||||||
|
break;
|
||||||
|
case MathQuizBlankType.numbersAndOperators:
|
||||||
|
blankIndices = (numberIndices + operatorIndices..shuffle(_random)).sublist(0, level.blankCount);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
blankIndices.sort(); // 👈 FIX
|
||||||
|
|
||||||
|
final List<String> solutions = [];
|
||||||
|
final List<PuzzleBlankType> finalBlankTypes = [];
|
||||||
|
final List<String> gridCells = List.of(allParts);
|
||||||
|
for (int index in blankIndices) {
|
||||||
|
final String solutionValue = allParts[index];
|
||||||
|
solutions.add(solutionValue);
|
||||||
|
finalBlankTypes.add(_isOperator(solutionValue)
|
||||||
|
? PuzzleBlankType.operator
|
||||||
|
: PuzzleBlankType.number);
|
||||||
|
gridCells[index] = '?';
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint("--- GEN LOG (Lv 13-15 Grid): BLANK INDICES: $blankIndices"); // [LOG]
|
||||||
|
debugPrint("--- GEN LOG (Lv 13-15 Grid): SOLUTIONS: $solutions | TYPES: $finalBlankTypes"); // [LOG]
|
||||||
|
|
||||||
|
|
||||||
|
final Set<String> optionsSet = solutions.toSet();
|
||||||
|
while (optionsSet.length < 9) { optionsSet.add((_random.nextInt(9) + 1).toString()); }
|
||||||
|
optionsSet.addAll(level.operators.split(','));
|
||||||
|
optionsSet.add('=');
|
||||||
|
final List<String> options = (optionsSet.toList()..shuffle(_random)).toList();
|
||||||
|
|
||||||
|
return MathQuizPuzzle(
|
||||||
|
gridCells: gridCells,
|
||||||
|
gridCrossAxisCount: 7,
|
||||||
|
options: options,
|
||||||
|
solutions: solutions,
|
||||||
|
blankTypes: finalBlankTypes,
|
||||||
|
equations: [
|
||||||
|
MathQuizEquation(expressionIndices: [0, 1, 2, 3, 4], resultIndex: 6),
|
||||||
|
MathQuizEquation(expressionIndices: [14, 15, 16, 17, 18], resultIndex: 20),
|
||||||
|
MathQuizEquation(expressionIndices: [28, 29, 30, 31, 32], resultIndex: 34),
|
||||||
|
MathQuizEquation(expressionIndices: [0, 7, 14, 21, 28], resultIndex: 42),
|
||||||
|
MathQuizEquation(expressionIndices: [2, 9, 16, 23, 30], resultIndex: 44),
|
||||||
|
MathQuizEquation(expressionIndices: [4, 11, 18, 25, 32], resultIndex: 46),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [수정됨] 레벨 13-15 (4x4 Grid) - 템플릿 목록 사용
|
/// [Lv 16-19] 4x4 Grid - 동적 생성
|
||||||
MathQuizPuzzle _generate4x4GridEquation(MathQuizDifficulty level) {
|
MathQuizPuzzle _generate4x4GridEquation(MathQuizDifficulty level) {
|
||||||
// 1. 4x4 템플릿 목록에서 랜덤 선택
|
while (true) {
|
||||||
final template = _get4x4Templates()..shuffle();
|
try {
|
||||||
final selectedTemplate = template.first;
|
final ops = level.operators.split(',');
|
||||||
|
final n = List.generate(16, (_) => _random.nextInt(9) + 1);
|
||||||
final List<String> allParts = selectedTemplate.gridCells;
|
final o = List.generate(24, (_) => (ops..shuffle(_random)).first);
|
||||||
final List<int> numberIndices = selectedTemplate.numberIndices;
|
final expr = [
|
||||||
final List<int> operatorIndices = selectedTemplate.operatorIndices;
|
"${n[0]} ${o[0]} ${n[1]} ${o[1]} ${n[2]} ${o[2]} ${n[3]}", // R0
|
||||||
|
"${n[4]} ${o[3]} ${n[5]} ${o[4]} ${n[6]} ${o[5]} ${n[7]}", // R1
|
||||||
|
"${n[8]} ${o[6]} ${n[9]} ${o[7]} ${n[10]} ${o[8]} ${n[11]}", // R2
|
||||||
|
"${n[12]} ${o[9]} ${n[13]} ${o[10]} ${n[14]} ${o[11]} ${n[15]}", // R3
|
||||||
|
"${n[0]} ${o[12]} ${n[4]} ${o[13]} ${n[8]} ${o[14]} ${n[12]}", // R4
|
||||||
|
"${n[1]} ${o[15]} ${n[5]} ${o[16]} ${n[9]} ${o[17]} ${n[13]}", // R5
|
||||||
|
"${n[2]} ${o[18]} ${n[6]} ${o[19]} ${n[10]} ${o[20]} ${n[14]}", // R6
|
||||||
|
"${n[3]} ${o[21]} ${n[7]} ${o[22]} ${n[11]} ${o[23]} ${n[15]}", // R7
|
||||||
|
];
|
||||||
|
final List<num> r = expr.map((e) => e.interpret()).toList();
|
||||||
|
if (r.any((res) => res.toInt() != res || res < -999 || res > 9999)) { continue; }
|
||||||
|
final List<int> rInt = r.map((res) => res.toInt()).toList();
|
||||||
|
final List<String> allParts = [
|
||||||
|
n[0].toString(), o[0], n[1].toString(), o[1], n[2].toString(), o[2], n[3].toString(), "=", rInt[0].toString(),
|
||||||
|
o[12], " ", o[15], " ", o[18], " ", o[21], " ", "=",
|
||||||
|
n[4].toString(), o[3], n[5].toString(), o[4], n[6].toString(), o[5], n[7].toString(), "=", rInt[1].toString(),
|
||||||
|
o[13], " ", o[16], " ", o[19], " ", o[22], " ", "=",
|
||||||
|
n[8].toString(), o[6], n[9].toString(), o[7], n[10].toString(), o[8], n[11].toString(), "=", rInt[2].toString(),
|
||||||
|
o[14], " ", o[17], " ", o[20], " ", o[23], " ", "=",
|
||||||
|
n[12].toString(), o[9], n[13].toString(), o[10], n[14].toString(), o[11], n[15].toString(), "=", rInt[3].toString(),
|
||||||
|
"=", " ", "=", " ", "=", " ", "=", " ", "=",
|
||||||
|
rInt[4].toString(), " ", rInt[5].toString(), " ", rInt[6].toString(), " ", rInt[7].toString(), " ", " ",
|
||||||
|
];
|
||||||
|
debugPrint("--- GEN LOG (Lv 16-19 Grid): ALL PARTS: $allParts"); // [LOG]
|
||||||
|
|
||||||
// 2. 난이도(level.blankType)에 따라 빈칸('?') 생성
|
|
||||||
final List<String> finalSolutions = [];
|
|
||||||
final List<String> gridCells = List.of(allParts);
|
|
||||||
|
|
||||||
List<int> blankIndices = [];
|
|
||||||
switch (level.blankType) {
|
|
||||||
case MathQuizBlankType.numbersOnly:
|
|
||||||
blankIndices = (numberIndices..shuffle(_random)).sublist(0, min(level.blankCount, numberIndices.length));
|
|
||||||
break;
|
|
||||||
case MathQuizBlankType.operatorsOnly:
|
|
||||||
blankIndices = (operatorIndices..shuffle(_random)).sublist(0, min(level.blankCount, operatorIndices.length));
|
|
||||||
break;
|
|
||||||
case MathQuizBlankType.numbersAndOperators:
|
|
||||||
blankIndices = (numberIndices + operatorIndices..shuffle(_random)).sublist(0, level.blankCount);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 빈칸 적용
|
|
||||||
blankIndices.sort();
|
|
||||||
for (int index in blankIndices) {
|
|
||||||
finalSolutions.add(allParts[index]);
|
|
||||||
gridCells[index] = '?';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 옵션 생성
|
|
||||||
final Set<String> optionsSet = finalSolutions.toSet(); // 👈 [오류 수정]
|
|
||||||
while (optionsSet.length < 9) {
|
|
||||||
optionsSet.add((_random.nextInt(9) + 1).toString());
|
|
||||||
}
|
|
||||||
optionsSet.addAll(level.operators.split(','));
|
|
||||||
optionsSet.add('=');
|
|
||||||
|
|
||||||
final List<String> options = (optionsSet.toList()..shuffle(_random)).toList();
|
|
||||||
|
|
||||||
return MathQuizPuzzle(
|
final List<int> numberIndices = [0, 2, 4, 6, 18, 20, 22, 24, 36, 38, 40, 42, 54, 56, 58, 60];
|
||||||
gridCells: gridCells,
|
final List<int> operatorIndices = [ 1, 3, 5, 9, 11, 13, 15, 19, 21, 23, 27, 29, 31, 33, 37, 39, 41, 45, 47, 49, 51, 55, 57, 59 ];
|
||||||
gridCrossAxisCount: 9, // 4x4 퍼즐 (9x9 그리드)
|
List<int> blankIndices = [];
|
||||||
solutions: finalSolutions,
|
switch (level.blankType) {
|
||||||
options: options, // 👈 [오류 수정]
|
case MathQuizBlankType.numbersOnly:
|
||||||
);
|
blankIndices = (numberIndices..shuffle(_random)).sublist(0, min(level.blankCount, numberIndices.length));
|
||||||
|
break;
|
||||||
|
case MathQuizBlankType.operatorsOnly:
|
||||||
|
blankIndices = (operatorIndices..shuffle(_random)).sublist(0, min(level.blankCount, operatorIndices.length));
|
||||||
|
break;
|
||||||
|
case MathQuizBlankType.numbersAndOperators:
|
||||||
|
blankIndices = (numberIndices + operatorIndices..shuffle(_random)).sublist(0, level.blankCount);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
blankIndices.sort(); // 👈 FIX
|
||||||
|
|
||||||
|
final List<String> solutions = [];
|
||||||
|
final List<PuzzleBlankType> finalBlankTypes = [];
|
||||||
|
final List<String> gridCells = List.of(allParts);
|
||||||
|
for (int index in blankIndices) {
|
||||||
|
final String solutionValue = allParts[index];
|
||||||
|
solutions.add(solutionValue);
|
||||||
|
finalBlankTypes.add(_isOperator(solutionValue)
|
||||||
|
? PuzzleBlankType.operator
|
||||||
|
: PuzzleBlankType.number);
|
||||||
|
gridCells[index] = '?';
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint("--- GEN LOG (Lv 16-19 Grid): BLANK INDICES: $blankIndices"); // [LOG]
|
||||||
|
debugPrint("--- GEN LOG (Lv 16-19 Grid): SOLUTIONS: $solutions | TYPES: $finalBlankTypes"); // [LOG]
|
||||||
|
|
||||||
|
|
||||||
|
final Set<String> optionsSet = solutions.toSet();
|
||||||
|
while (optionsSet.length < 9) { optionsSet.add((_random.nextInt(9) + 1).toString()); }
|
||||||
|
optionsSet.addAll(level.operators.split(','));
|
||||||
|
optionsSet.add('=');
|
||||||
|
final List<String> options = (optionsSet.toList()..shuffle(_random)).toList();
|
||||||
|
|
||||||
|
return MathQuizPuzzle(
|
||||||
|
gridCells: gridCells,
|
||||||
|
gridCrossAxisCount: 9,
|
||||||
|
options: options,
|
||||||
|
solutions: solutions,
|
||||||
|
blankTypes: finalBlankTypes,
|
||||||
|
equations: [
|
||||||
|
MathQuizEquation(expressionIndices: [0, 1, 2, 3, 4, 5, 6], resultIndex: 8),
|
||||||
|
MathQuizEquation(expressionIndices: [18, 19, 20, 21, 22, 23, 24], resultIndex: 26),
|
||||||
|
MathQuizEquation(expressionIndices: [36, 37, 38, 39, 40, 41, 42], resultIndex: 44),
|
||||||
|
MathQuizEquation(expressionIndices: [54, 55, 56, 57, 58, 59, 60], resultIndex: 62),
|
||||||
|
MathQuizEquation(expressionIndices: [0, 9, 18, 27, 36, 45, 54], resultIndex: 72),
|
||||||
|
MathQuizEquation(expressionIndices: [2, 11, 20, 29, 38, 47, 56], resultIndex: 74),
|
||||||
|
MathQuizEquation(expressionIndices: [4, 13, 22, 31, 40, 49, 58], resultIndex: 76),
|
||||||
|
MathQuizEquation(expressionIndices: [6, 15, 24, 33, 42, 51, 60], resultIndex: 78),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [HELPER] (A, B, C, op) 튜플 생성 (사칙연산 지원)
|
// ( ... _createEquation, _createMultiOpEquation, _calculate는 이전과 동일 ... )
|
||||||
(int, int, int, String) _createEquation(String operators, {int? firstTerm, int maxResult = 9}) {
|
(int, int, int, String) _createEquation(String operators, {int? firstTerm, int maxResult = 9}) {
|
||||||
// (이전과 동일)
|
|
||||||
final List<String> ops = operators.split(',');
|
final List<String> ops = operators.split(',');
|
||||||
final String op = ops[_random.nextInt(ops.length)];
|
final String op = ops[_random.nextInt(ops.length)];
|
||||||
int a, b, c;
|
int a, b, c;
|
||||||
switch (op) {
|
switch (op) {
|
||||||
case '+':
|
case '+': a = firstTerm ?? _random.nextInt(maxResult - 1) + 1; b = _random.nextInt(maxResult - a) + 1; c = a + b; return (a, b, c, op);
|
||||||
a = firstTerm ?? _random.nextInt(maxResult - 1) + 1;
|
case '-': c = firstTerm ?? _random.nextInt(maxResult - 1) + 2; a = _random.nextInt(c - 1) + 1; b = c - a; return (a, b, c, op);
|
||||||
b = _random.nextInt(maxResult - a) + 1;
|
case '*': a = firstTerm ?? _random.nextInt(maxResult ~/ 2) + 2; b = _random.nextInt(maxResult ~/ a) + 1; c = a * b; return (a, b, c, op);
|
||||||
c = a + b;
|
case '/': b = _random.nextInt(maxResult ~/ 2) + 1; a = _random.nextInt(maxResult ~/ b) + 1; c = a * b; if (c == 0 || a == 0) return _createEquation(operators, firstTerm: firstTerm, maxResult: maxResult); return (a, b, c, op);
|
||||||
return (a, b, c, op);
|
default: return (1, 1, 2, '+');
|
||||||
case '-':
|
|
||||||
c = firstTerm ?? _random.nextInt(maxResult - 1) + 2;
|
|
||||||
a = _random.nextInt(c - 1) + 1;
|
|
||||||
b = c - a;
|
|
||||||
return (a, b, c, op);
|
|
||||||
case '*':
|
|
||||||
a = firstTerm ?? _random.nextInt(maxResult ~/ 2) + 2;
|
|
||||||
b = _random.nextInt(maxResult ~/ a) + 1;
|
|
||||||
c = a * b;
|
|
||||||
return (a, b, c, op);
|
|
||||||
case '/':
|
|
||||||
b = _random.nextInt(maxResult ~/ 2) + 1;
|
|
||||||
a = _random.nextInt(maxResult ~/ b) + 1;
|
|
||||||
c = a * b;
|
|
||||||
if (c == 0 || a == 0) return _createEquation(operators, firstTerm: firstTerm, maxResult: maxResult);
|
|
||||||
return (a, b, c, op);
|
|
||||||
default:
|
|
||||||
return (1, 1, 2, '+');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [HELPER] 숫자 3개 + 연산자 2개 (우선순위 적용!)
|
|
||||||
(int, int, int, String, String, int) _createMultiOpEquation(String operators) {
|
(int, int, int, String, String, int) _createMultiOpEquation(String operators) {
|
||||||
while(true) {
|
while(true) {
|
||||||
final int a = _random.nextInt(9) + 1;
|
final int a = _random.nextInt(9) + 1;
|
||||||
@ -315,89 +511,25 @@ class MathQuizGenerator {
|
|||||||
final List<String> ops = operators.split(',');
|
final List<String> ops = operators.split(',');
|
||||||
final String op1 = (ops..shuffle(_random)).first;
|
final String op1 = (ops..shuffle(_random)).first;
|
||||||
final String op2 = (ops..shuffle(_random)).first;
|
final String op2 = (ops..shuffle(_random)).first;
|
||||||
|
|
||||||
final String expression = "$a $op1 $b $op2 $c";
|
final String expression = "$a $op1 $b $op2 $c";
|
||||||
if (expression.contains('/')) continue;
|
try {
|
||||||
|
final num resultNum = expression.interpret();
|
||||||
final num resultNum = expression.interpret();
|
final int result = resultNum.toInt();
|
||||||
final int result = resultNum.toInt();
|
if (result == resultNum && result >= -99 && result <= 999) {
|
||||||
|
return (a, b, c, op1, op2, result);
|
||||||
if (result == resultNum && result >= -99 && result <= 999) { // [수정] 범위 확장
|
}
|
||||||
return (a, b, c, op1, op2, result);
|
} catch (e) {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [HELPER] 두 숫자와 연산자로 계산 (실패 시 null 반환)
|
|
||||||
int? _calculate(int a, int b, String op) {
|
int? _calculate(int a, int b, String op) {
|
||||||
switch (op) {
|
switch (op) {
|
||||||
case '+': return a + b;
|
case '+': return a + b;
|
||||||
case '-': return a - b;
|
case '-': return a - b;
|
||||||
case '*': return a * b;
|
case '*': return a * b;
|
||||||
case '/':
|
case '/': if (b == 0) return null; if (a % b != 0) return null; return a ~/ b;
|
||||||
if (b == 0) return null; // 0으로 나누기
|
|
||||||
if (a % b != 0) return null; // 나누어떨어지지 않음
|
|
||||||
return a ~/ b;
|
|
||||||
}
|
}
|
||||||
return 0; // 알 수 없는 연산자
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [신규] 3x3 그리드 템플릿 목록 (8개)
|
|
||||||
List<_GridTemplate> _get3x3Templates() {
|
|
||||||
// [!] 템플릿의 수학적 유효성은 이미지 원본을 따르며, 보장되지 않습니다.
|
|
||||||
return [
|
|
||||||
// 5.53.19.png (top-left)
|
|
||||||
_GridTemplate(gridCells:["5","*","3","-","1","=","17","+"," ","*"," ","-"," ","=","2","*","6","-","7","=","5","*"," ","+"," ","+"," ","=","4","+","1","+","1","=","18","="," ","="," ","="," ","=","21"," ","33"," ","22"," "," "], numberIndices:[0,2,4,14,16,18,28,30,32], operatorIndices:[1,3,7,9,11,15,17,21,23,25,29,31]),
|
|
||||||
// 5.53.19.png (top-right)
|
|
||||||
_GridTemplate(gridCells:["1","+","1","+","5","=","17","/"," ","-"," ","+"," ","=","1","*","3","+","2","=","15","*"," ","+"," ","-"," ","=","6","-","4","+","2","=","0","="," ","="," ","="," ","=","12"," ","12"," ","13"," "," "], numberIndices:[0,2,4,14,16,18,28,30,32], operatorIndices:[1,3,7,9,11,15,17,21,23,25,29,31]),
|
|
||||||
// 5.53.19.png (bottom-left)
|
|
||||||
_GridTemplate(gridCells:["9","+","5","+","5","=","19","/"," ","-"," ","+"," ","=","2","*","3","+","9","=","15","*"," ","+"," ","-"," ","=","6","-","7","+","1","=","0","="," ","="," ","="," ","=","12"," ","9"," ","16"," "," "], numberIndices:[0,2,4,14,16,18,28,30,32], operatorIndices:[1,3,7,9,11,15,17,21,23,25,29,31]),
|
|
||||||
// 5.53.19.png (bottom-right)
|
|
||||||
_GridTemplate(gridCells:["7","*","4","-","9","=","39","*"," ","+"," ","*"," ","=","1","*","1","+","8","=","9","+"," ","*"," ","+"," ","=","3","-","4","+","5","=","4","="," ","="," ","="," ","=","11"," ","34"," ","23"," "," "], numberIndices:[0,2,4,14,16,18,28,30,32], operatorIndices:[1,3,7,9,11,15,17,21,23,25,29,31]),
|
|
||||||
// 5.53.33.png (top-left)
|
|
||||||
_GridTemplate(gridCells:["9","-","5","/","1","=","7","*"," ","-"," ","+"," ","=","8","*","5","-","7","=","27","+"," ","+"," ","+"," ","=","4","+","3","-","7","=","2","="," ","="," ","="," ","=","62"," ","0"," ","12"," "," "], numberIndices:[0,2,4,14,16,18,28,30,32], operatorIndices:[1,3,7,9,11,15,17,21,23,25,29,31]),
|
|
||||||
// 5.53.33.png (top-right)
|
|
||||||
_GridTemplate(gridCells:["8","*","2","+","6","=","16","*"," ","+"," ","/"," ","=","5","*","9","/","3","=","3","+"," ","+"," ","*"," ","=","7","+","1","+","1","=","19","="," ","="," ","="," ","=","9"," ","18"," ","16"," "," "], numberIndices:[0,2,4,14,16,18,28,30,32], operatorIndices:[1,3,7,9,11,15,17,21,23,25,29,31]),
|
|
||||||
// 5.53.33.png (bottom-left)
|
|
||||||
_GridTemplate(gridCells:["1","+","7","+","9","=","17","*"," ","+"," ","-"," ","=","8","/","4","+","4","=","8","+"," ","*"," ","/"," ","=","7","*","3","+","3","=","17","="," ","="," ","="," ","=","13"," ","19"," ","6"," "," "], numberIndices:[0,2,4,14,16,18,28,30,32], operatorIndices:[1,3,7,9,11,15,17,21,23,25,29,31]),
|
|
||||||
// 5.53.33.png (bottom-right)
|
|
||||||
_GridTemplate(gridCells:["1","+","6","-","9","=","6","/"," ","-"," ","/"," ","=","2","-","7","/","1","=","0","-"," ","/"," ","*"," ","=","9","-","5","+","4","=","0","="," ","="," ","="," ","=","0"," ","6"," ","3"," "," "], numberIndices:[0,2,4,14,16,18,28,30,32], operatorIndices:[1,3,7,9,11,15,17,21,23,25,29,31]),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [신규] 4x4 그리드 템플릿 목록 (8개)
|
|
||||||
List<_GridTemplate> _get4x4Templates() {
|
|
||||||
// [!] 템플릿의 수학적 유효성은 이미지 원본을 따르며, 보장되지 않습니다.
|
|
||||||
return [
|
|
||||||
// 5.54.51.png (top-left)
|
|
||||||
_GridTemplate(gridCells:["1","*","4","-","2","+","14","=","16","+"," ","*"," ","/"," ","-"," ","=","15","/","5","*","16","+","13","=","61","+"," ","-"," ","+"," ","+"," ","=","3","/","2","+","4","*","7","=","29","*"," ","+"," ","/"," ","*"," ","=","15","+","11","-","8","*","8","=","-38","="," ","="," ","="," ","="," ","=","102"," ","51"," ","54"," ","95"," "," "], numberIndices:[0,2,4,6,18,20,22,24,36,38,40,42,54,56,58,60], operatorIndices:[1,3,5,7,9,11,13,15,17,19,21,23,25,27,29,31,33,35,45,47,49,51,53,55,57,59,61,63,65,67,69,71]),
|
|
||||||
// 5.54.51.png (bottom-left)
|
|
||||||
_GridTemplate(gridCells:["8","+","4","*","2","+","15","=","114","-"," ","+"," ","-"," ","-"," ","=","6","/","4","+","3","/","2","=","11","-"," ","-"," ","+"," ","-"," ","=","7","*","3","/","5","-","1","=","0","+"," ","-"," ","*"," ","+"," ","=","12","-","1","*","6","+","11","=","14","="," ","="," ","="," ","="," ","=","1"," ","0"," ","44"," ","18"," "," "], numberIndices:[0,2,4,6,18,20,22,24,36,38,40,42,54,56,58,60], operatorIndices:[1,3,5,7,9,11,13,15,17,19,21,23,25,27,29,31,33,35,45,47,49,51,53,55,57,59,61,63,65,67,69,71]),
|
|
||||||
// 5.54.37.png (top-left)
|
|
||||||
_GridTemplate(gridCells:["10","/","2","*","3","+","9","=","24","*"," ","-"," ","*"," ","+"," ","=","2","+","13","*","5","-","7","=","74","+"," ","+"," ","-"," ","+"," ","=","5","*","6","+","1","-","7","=","5","+"," ","-"," ","+"," ","+"," ","=","12","-","11","+","2","*","8","=","49","="," ","="," ","="," ","="," ","=","173"," ","15"," ","17"," ","85"," "," "], numberIndices:[0,2,4,6,18,20,22,24,36,38,40,42,54,56,58,60], operatorIndices:[1,3,5,7,9,11,13,15,17,19,21,23,25,27,29,31,33,35,45,47,49,51,53,55,57,59,61,63,65,67,69,71]),
|
|
||||||
// 5.54.37.png (bottom-left)
|
|
||||||
_GridTemplate(gridCells:["9","*","2","+","8","-","1","=","40","/"," ","/"," ","-"," ","*"," ","=","5","+","2","-","13","+","3","=","3","-"," ","*"," ","+"," ","-"," ","=","7","/","1","-","6","+","4","=","4","+"," ","+"," ","*"," ","+"," ","=","16","+","9","-","7","-","10","=","9","="," ","="," ","="," ","="," ","=","5"," ","29"," ","64"," ","13"," "," "], numberIndices:[0,2,4,6,18,20,22,24,36,38,40,42,54,56,58,60], operatorIndices:[1,3,5,7,9,11,13,15,17,19,21,23,25,27,29,31,33,35,45,47,49,51,53,55,57,59,61,63,65,67,69,71]),
|
|
||||||
// 5.54.29.png (top-left)
|
|
||||||
_GridTemplate(gridCells:["1","-","5","-","4","+","14","=","8","*"," ","/"," ","*"," ","-"," ","=","7","+","2","*","1","-","11","=","14","-"," ","+"," ","+"," ","-"," ","=","2","/","1","*","7","-","6","=","21","+"," ","+"," ","-"," ","+"," ","=","15","*","10","+","1","+","7","=","173","="," ","="," ","="," ","="," ","=","12"," ","18"," ","21"," ","5"," "," "], numberIndices:[0,2,4,6,18,20,22,24,36,38,40,42,54,56,58,60], operatorIndices:[1,3,5,7,9,11,13,15,17,19,21,23,25,27,29,31,33,35,45,47,49,51,53,55,57,59,61,63,65,67,69,71]),
|
|
||||||
// 5.54.29.png (bottom-left)
|
|
||||||
_GridTemplate(gridCells:["9","-","3","-","1","+","13","=","11","*"," ","*"," ","+"," ","+"," ","=","8","*","7","-","9","-","5","=","49","+"," ","+"," ","*"," ","+"," ","=","6","*","5","+","11","-","2","=","41","-"," ","-"," ","-"," ","/"," ","=","15","+","2","-","1","-","4","=","17","="," ","="," ","="," ","="," ","=","71"," ","67"," ","40"," ","18"," "," "], numberIndices:[0,2,4,6,18,20,22,24,36,38,40,42,54,56,58,60], operatorIndices:[1,3,5,7,9,11,13,15,17,19,21,23,25,27,29,31,33,35,45,47,49,51,53,55,57,59,61,63,65,67,69,71]),
|
|
||||||
// 5.55.00.png (top-left)
|
|
||||||
_GridTemplate(gridCells:["9","*","8","-","6","+","7","=","141","*"," ","*"," ","/"," ","-"," ","=","2","+","6","*","5","-","10","=","27","-"," ","-"," ","+"," ","-"," ","=","3","+","1","-","3","*","1","=","3","*"," ","+"," ","+"," ","+"," ","=","11","+","7","*","2","+","8","=","25","="," ","="," ","="," ","="," ","=","19"," ","85"," ","20"," ","9"," "," "], numberIndices:[0,2,4,6,18,20,22,24,36,38,40,42,54,56,58,60], operatorIndices:[1,3,5,7,9,11,13,15,17,19,21,23,25,27,29,31,33,35,45,47,49,51,53,55,57,59,61,63,65,67,69,71]),
|
|
||||||
// 5.55.00.png (bottom-left)
|
|
||||||
_GridTemplate(gridCells:["4","-","1","-","2","+","14","=","3","+"," ","*"," ","*"," ","-"," ","=","9","-","1","+","3","-","4","=","7","*"," ","/"," ","-"," ","+"," ","=","6","-","1","+","7","+","1","=","17","+"," ","-"," ","+"," ","*"," ","=","8","*","2","+","1","+","16","=","65","="," ","="," ","="," ","="," ","=","38"," ","9"," ","144"," ","178"," "," "], numberIndices:[0,2,4,6,18,20,22,24,36,38,40,42,54,56,58,60], operatorIndices:[1,3,5,7,9,11,13,15,17,19,21,23,25,27,29,31,33,35,45,47,49,51,53,55,57,59,61,63,65,67,69,71]),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [신규] 3x3/4x4 템플릿의 내부 구조
|
|
||||||
class _GridTemplate {
|
|
||||||
final List<String> gridCells;
|
|
||||||
final List<int> numberIndices;
|
|
||||||
final List<int> operatorIndices;
|
|
||||||
|
|
||||||
_GridTemplate({
|
|
||||||
required this.gridCells,
|
|
||||||
required this.numberIndices,
|
|
||||||
required this.operatorIndices,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import 'game_difficulty.dart';
|
// packages/feature_game_mathquiz/lib/models/math_quiz_difficulty.dart
|
||||||
|
import 'package:service_api/service_api.dart';
|
||||||
|
|
||||||
/// 빈칸의 유형을 정의
|
/// 빈칸의 유형을 정의
|
||||||
enum MathQuizBlankType {
|
enum MathQuizBlankType {
|
||||||
@ -9,24 +10,21 @@ enum MathQuizBlankType {
|
|||||||
|
|
||||||
/// 퍼즐의 레이아웃 형태를 정의
|
/// 퍼즐의 레이아웃 형태를 정의
|
||||||
enum MathQuizLayout {
|
enum MathQuizLayout {
|
||||||
/// A + B = C 또는 A + B * C = D
|
|
||||||
singleLine,
|
singleLine,
|
||||||
/// 'ㄱ', 'ㄴ' 모양 (변수 4개)
|
dualLine,
|
||||||
linkedL,
|
linkedL,
|
||||||
/// 3x3 이상 그리드 (변수 8개 이상)
|
|
||||||
gridSquare,
|
gridSquare,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 수학 퀴즈 게임의 난이도 정의
|
/// 'extends GameDifficulty' 추가
|
||||||
class MathQuizDifficulty extends GameDifficulty {
|
class MathQuizDifficulty extends GameDifficulty {
|
||||||
final int levelIndex;
|
final int levelIndex;
|
||||||
final MathQuizLayout layout;
|
final MathQuizLayout layout;
|
||||||
final String operators;
|
final String operators;
|
||||||
final int blankCount;
|
final int blankCount;
|
||||||
final MathQuizBlankType blankType;
|
final MathQuizBlankType blankType;
|
||||||
|
|
||||||
/// [수정] 연산 복잡도 (예: 2=A+B, 3=A+B*C, 4=2x2그리드)
|
|
||||||
final int operationCount;
|
final int operationCount;
|
||||||
|
final int puzzleCount;
|
||||||
|
|
||||||
const MathQuizDifficulty({
|
const MathQuizDifficulty({
|
||||||
required this.levelIndex,
|
required this.levelIndex,
|
||||||
@ -37,14 +35,14 @@ class MathQuizDifficulty extends GameDifficulty {
|
|||||||
required this.blankCount,
|
required this.blankCount,
|
||||||
required this.blankType,
|
required this.blankType,
|
||||||
required this.operationCount,
|
required this.operationCount,
|
||||||
|
required this.puzzleCount,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [수정됨] 앱 전역에서 사용할 수학 퀴즈 난이도 목록 (15단계)
|
/// 앱 전역에서 사용할 수학 퀴즈 난이도 목록 (19단계)
|
||||||
class MathQuizDifficulties {
|
class MathQuizDifficulties {
|
||||||
static final List<MathQuizDifficulty> allDifficulties = [
|
static final List<MathQuizDifficulty> allDifficulties = [
|
||||||
// --- 패턴 1: 숫자 2개 (A op B = C) [Lv 1-3] ---
|
// --- 패턴 1: 숫자 2개 (A op B = C) [Lv 1-3] ---
|
||||||
// (연산자 우선순위 없음)
|
|
||||||
const MathQuizDifficulty(
|
const MathQuizDifficulty(
|
||||||
levelIndex: 1,
|
levelIndex: 1,
|
||||||
name: 'Lv. 1: 숫자 2개 (숫자 빈칸)',
|
name: 'Lv. 1: 숫자 2개 (숫자 빈칸)',
|
||||||
@ -53,7 +51,8 @@ class MathQuizDifficulties {
|
|||||||
operators: '+,-',
|
operators: '+,-',
|
||||||
blankCount: 1,
|
blankCount: 1,
|
||||||
blankType: MathQuizBlankType.numbersOnly,
|
blankType: MathQuizBlankType.numbersOnly,
|
||||||
operationCount: 2, // A, B
|
operationCount: 2,
|
||||||
|
puzzleCount: 10,
|
||||||
),
|
),
|
||||||
const MathQuizDifficulty(
|
const MathQuizDifficulty(
|
||||||
levelIndex: 2,
|
levelIndex: 2,
|
||||||
@ -64,6 +63,7 @@ class MathQuizDifficulties {
|
|||||||
blankCount: 1,
|
blankCount: 1,
|
||||||
blankType: MathQuizBlankType.operatorsOnly,
|
blankType: MathQuizBlankType.operatorsOnly,
|
||||||
operationCount: 2,
|
operationCount: 2,
|
||||||
|
puzzleCount: 10,
|
||||||
),
|
),
|
||||||
const MathQuizDifficulty(
|
const MathQuizDifficulty(
|
||||||
levelIndex: 3,
|
levelIndex: 3,
|
||||||
@ -74,10 +74,9 @@ class MathQuizDifficulties {
|
|||||||
blankCount: 2,
|
blankCount: 2,
|
||||||
blankType: MathQuizBlankType.numbersAndOperators,
|
blankType: MathQuizBlankType.numbersAndOperators,
|
||||||
operationCount: 2,
|
operationCount: 2,
|
||||||
|
puzzleCount: 10,
|
||||||
),
|
),
|
||||||
|
|
||||||
// --- 패턴 2: 숫자 3개 (A op1 B op2 C = D) [Lv 4-6] ---
|
// --- 패턴 2: 숫자 3개 (A op1 B op2 C = D) [Lv 4-6] ---
|
||||||
// (연산자 우선순위 *적용*)
|
|
||||||
const MathQuizDifficulty(
|
const MathQuizDifficulty(
|
||||||
levelIndex: 4,
|
levelIndex: 4,
|
||||||
name: 'Lv. 4: 숫자 3개 (숫자 빈칸)',
|
name: 'Lv. 4: 숫자 3개 (숫자 빈칸)',
|
||||||
@ -86,7 +85,8 @@ class MathQuizDifficulties {
|
|||||||
operators: '+,-,*,/',
|
operators: '+,-,*,/',
|
||||||
blankCount: 2,
|
blankCount: 2,
|
||||||
blankType: MathQuizBlankType.numbersOnly,
|
blankType: MathQuizBlankType.numbersOnly,
|
||||||
operationCount: 3, // A, B, C
|
operationCount: 3,
|
||||||
|
puzzleCount: 8,
|
||||||
),
|
),
|
||||||
const MathQuizDifficulty(
|
const MathQuizDifficulty(
|
||||||
levelIndex: 5,
|
levelIndex: 5,
|
||||||
@ -97,6 +97,7 @@ class MathQuizDifficulties {
|
|||||||
blankCount: 2,
|
blankCount: 2,
|
||||||
blankType: MathQuizBlankType.operatorsOnly,
|
blankType: MathQuizBlankType.operatorsOnly,
|
||||||
operationCount: 3,
|
operationCount: 3,
|
||||||
|
puzzleCount: 8,
|
||||||
),
|
),
|
||||||
const MathQuizDifficulty(
|
const MathQuizDifficulty(
|
||||||
levelIndex: 6,
|
levelIndex: 6,
|
||||||
@ -107,102 +108,155 @@ class MathQuizDifficulties {
|
|||||||
blankCount: 3,
|
blankCount: 3,
|
||||||
blankType: MathQuizBlankType.numbersAndOperators,
|
blankType: MathQuizBlankType.numbersAndOperators,
|
||||||
operationCount: 3,
|
operationCount: 3,
|
||||||
|
puzzleCount: 8,
|
||||||
),
|
),
|
||||||
|
// --- 패턴 3: 독립된 2줄 (숫자 4개) [Lv 7-9] ---
|
||||||
// --- 패턴 3: 숫자 4개 (2x2 그리드 'ㄱ', 'ㄴ') [Lv 7-9] ---
|
|
||||||
const MathQuizDifficulty(
|
const MathQuizDifficulty(
|
||||||
levelIndex: 7,
|
levelIndex: 7,
|
||||||
name: 'Lv. 7: 숫자 4개 (숫자 빈칸)',
|
name: 'Lv. 7: 독립 2줄 (숫자)',
|
||||||
contextId: 'MATH_L7_OP4_NUM',
|
contextId: 'MATH_L7_OP4_DUAL_NUM',
|
||||||
|
layout: MathQuizLayout.dualLine,
|
||||||
|
operators: '+,-',
|
||||||
|
blankCount: 2,
|
||||||
|
blankType: MathQuizBlankType.numbersOnly,
|
||||||
|
operationCount: 4,
|
||||||
|
puzzleCount: 6,
|
||||||
|
),
|
||||||
|
const MathQuizDifficulty(
|
||||||
|
levelIndex: 8,
|
||||||
|
name: 'Lv. 8: 독립 2줄 (연산자)',
|
||||||
|
contextId: 'MATH_L8_OP4_DUAL_OP',
|
||||||
|
layout: MathQuizLayout.dualLine,
|
||||||
|
operators: '+,-',
|
||||||
|
blankCount: 2,
|
||||||
|
blankType: MathQuizBlankType.operatorsOnly,
|
||||||
|
operationCount: 4,
|
||||||
|
puzzleCount: 6,
|
||||||
|
),
|
||||||
|
const MathQuizDifficulty(
|
||||||
|
levelIndex: 9,
|
||||||
|
name: 'Lv. 9: 독립 2줄 (사칙연산)',
|
||||||
|
contextId: 'MATH_L9_OP4_DUAL_ANY',
|
||||||
|
layout: MathQuizLayout.dualLine,
|
||||||
|
operators: '+,-,*,/',
|
||||||
|
blankCount: 3,
|
||||||
|
blankType: MathQuizBlankType.numbersAndOperators,
|
||||||
|
operationCount: 4,
|
||||||
|
puzzleCount: 6,
|
||||||
|
),
|
||||||
|
// --- 패턴 4: 2x2 그리드 (숫자 4개) [Lv 10-12] ---
|
||||||
|
const MathQuizDifficulty(
|
||||||
|
levelIndex: 10,
|
||||||
|
name: 'Lv. 10: 2x2 그리드 (숫자)',
|
||||||
|
contextId: 'MATH_L10_OP4_L_NUM',
|
||||||
layout: MathQuizLayout.linkedL,
|
layout: MathQuizLayout.linkedL,
|
||||||
operators: '+,-',
|
operators: '+,-',
|
||||||
blankCount: 2,
|
blankCount: 2,
|
||||||
blankType: MathQuizBlankType.numbersOnly,
|
blankType: MathQuizBlankType.numbersOnly,
|
||||||
operationCount: 4, // A, B, C, D
|
operationCount: 4,
|
||||||
|
puzzleCount: 4,
|
||||||
),
|
),
|
||||||
const MathQuizDifficulty(
|
const MathQuizDifficulty(
|
||||||
levelIndex: 8,
|
levelIndex: 11,
|
||||||
name: 'Lv. 8: 숫자 4개 (연산자 빈칸)',
|
name: 'Lv. 11: 2x2 그리드 (연산자)',
|
||||||
contextId: 'MATH_L8_OP4_OP',
|
contextId: 'MATH_L11_OP4_L_OP',
|
||||||
layout: MathQuizLayout.linkedL,
|
layout: MathQuizLayout.linkedL,
|
||||||
operators: '+,-',
|
operators: '+,-',
|
||||||
blankCount: 2,
|
blankCount: 2,
|
||||||
blankType: MathQuizBlankType.operatorsOnly,
|
blankType: MathQuizBlankType.operatorsOnly,
|
||||||
operationCount: 4,
|
operationCount: 4,
|
||||||
|
puzzleCount: 4,
|
||||||
),
|
),
|
||||||
const MathQuizDifficulty(
|
const MathQuizDifficulty(
|
||||||
levelIndex: 9,
|
levelIndex: 12,
|
||||||
name: 'Lv. 9: 숫자 4개 (사칙연산)',
|
name: 'Lv. 12: 2x2 그리드 (사칙연산)',
|
||||||
contextId: 'MATH_L9_OP4_ANY',
|
contextId: 'MATH_L12_OP4_L_ANY',
|
||||||
layout: MathQuizLayout.linkedL,
|
layout: MathQuizLayout.linkedL,
|
||||||
operators: '+,-,*,/',
|
operators: '+,-,*,/',
|
||||||
blankCount: 3,
|
blankCount: 3,
|
||||||
blankType: MathQuizBlankType.numbersAndOperators,
|
blankType: MathQuizBlankType.numbersAndOperators,
|
||||||
operationCount: 4,
|
operationCount: 4,
|
||||||
|
puzzleCount: 4,
|
||||||
),
|
),
|
||||||
|
// --- 패턴 5: 3x3 그리드 (숫자 9개) [Lv 13-15] ---
|
||||||
// --- 패턴 4: 3x3 그리드 (숫자 9개) [Lv 10-12] ---
|
|
||||||
const MathQuizDifficulty(
|
const MathQuizDifficulty(
|
||||||
levelIndex: 10,
|
levelIndex: 13,
|
||||||
name: 'Lv. 10: 3x3 그리드 (숫자)',
|
name: 'Lv. 13: 3x3 그리드 (숫자)',
|
||||||
contextId: 'MATH_L10_OP9_NUM',
|
contextId: 'MATH_L13_OP9_NUM',
|
||||||
layout: MathQuizLayout.gridSquare,
|
layout: MathQuizLayout.gridSquare,
|
||||||
operators: '+,-',
|
operators: '+,-',
|
||||||
blankCount: 3,
|
blankCount: 3,
|
||||||
blankType: MathQuizBlankType.numbersOnly,
|
blankType: MathQuizBlankType.numbersOnly,
|
||||||
operationCount: 9, // 9 Variables
|
operationCount: 9,
|
||||||
|
puzzleCount: 3,
|
||||||
),
|
),
|
||||||
const MathQuizDifficulty(
|
const MathQuizDifficulty(
|
||||||
levelIndex: 11,
|
levelIndex: 14,
|
||||||
name: 'Lv. 11: 3x3 그리드 (연산자)',
|
name: 'Lv. 14: 3x3 그리드 (연산자)',
|
||||||
contextId: 'MATH_L11_OP9_OP',
|
contextId: 'MATH_L14_OP9_OP',
|
||||||
layout: MathQuizLayout.gridSquare,
|
layout: MathQuizLayout.gridSquare,
|
||||||
operators: '+,-',
|
operators: '+,-',
|
||||||
blankCount: 3,
|
blankCount: 3,
|
||||||
blankType: MathQuizBlankType.operatorsOnly,
|
blankType: MathQuizBlankType.operatorsOnly,
|
||||||
operationCount: 9,
|
operationCount: 9,
|
||||||
|
puzzleCount: 3,
|
||||||
),
|
),
|
||||||
const MathQuizDifficulty(
|
const MathQuizDifficulty(
|
||||||
levelIndex: 12,
|
levelIndex: 15,
|
||||||
name: 'Lv. 12: 3x3 그리드 (사칙연산)',
|
name: 'Lv. 15: 3x3 그리드 (사칙연산)',
|
||||||
contextId: 'MATH_L12_OP9_ANY',
|
contextId: 'MATH_L15_OP9_ANY',
|
||||||
layout: MathQuizLayout.gridSquare,
|
layout: MathQuizLayout.gridSquare,
|
||||||
operators: '+,-,*,/',
|
operators: '+,-,*,/',
|
||||||
blankCount: 4,
|
blankCount: 4,
|
||||||
blankType: MathQuizBlankType.numbersAndOperators,
|
blankType: MathQuizBlankType.numbersAndOperators,
|
||||||
operationCount: 9,
|
operationCount: 9,
|
||||||
|
puzzleCount: 3,
|
||||||
),
|
),
|
||||||
|
// --- 패턴 6: 4x4 그리드 (숫자 16개) [Lv 16-18] ---
|
||||||
// --- 패턴 5: 4x4 그리드 (숫자 16개) [Lv 13-15] ---
|
|
||||||
const MathQuizDifficulty(
|
const MathQuizDifficulty(
|
||||||
levelIndex: 13,
|
levelIndex: 16,
|
||||||
name: 'Lv. 13: 4x4 그리드 (숫자)',
|
name: 'Lv. 16: 4x4 그리드 (숫자)',
|
||||||
contextId: 'MATH_L13_OP16_NUM',
|
contextId: 'MATH_L16_OP16_NUM',
|
||||||
layout: MathQuizLayout.gridSquare,
|
layout: MathQuizLayout.gridSquare,
|
||||||
operators: '+,-,*,/',
|
operators: '+,-,*,/',
|
||||||
blankCount: 5,
|
blankCount: 5,
|
||||||
blankType: MathQuizBlankType.numbersOnly,
|
blankType: MathQuizBlankType.numbersOnly,
|
||||||
operationCount: 16,
|
operationCount: 16,
|
||||||
|
puzzleCount: 2,
|
||||||
),
|
),
|
||||||
const MathQuizDifficulty(
|
const MathQuizDifficulty(
|
||||||
levelIndex: 14,
|
levelIndex: 17,
|
||||||
name: 'Lv. 14: 4x4 그리드 (연산자)',
|
name: 'Lv. 17: 4x4 그리드 (연산자)',
|
||||||
contextId: 'MATH_L14_OP16_OP',
|
contextId: 'MATH_L17_OP16_OP',
|
||||||
layout: MathQuizLayout.gridSquare,
|
layout: MathQuizLayout.gridSquare,
|
||||||
operators: '+,-,*,/',
|
operators: '+,-,*,/',
|
||||||
blankCount: 5,
|
blankCount: 5,
|
||||||
blankType: MathQuizBlankType.operatorsOnly,
|
blankType: MathQuizBlankType.operatorsOnly,
|
||||||
operationCount: 16,
|
operationCount: 16,
|
||||||
|
puzzleCount: 2,
|
||||||
),
|
),
|
||||||
const MathQuizDifficulty(
|
const MathQuizDifficulty(
|
||||||
levelIndex: 15,
|
levelIndex: 18,
|
||||||
name: 'Lv. 15: 4x4 그리드 (랜덤)',
|
name: 'Lv. 18: 4x4 그리드 (랜덤)',
|
||||||
contextId: 'MATH_L15_OP16_ANY',
|
contextId: 'MATH_L18_OP16_ANY',
|
||||||
layout: MathQuizLayout.gridSquare,
|
layout: MathQuizLayout.gridSquare,
|
||||||
operators: '+,-,*,/',
|
operators: '+,-,*,/',
|
||||||
blankCount: 6,
|
blankCount: 6,
|
||||||
blankType: MathQuizBlankType.numbersAndOperators,
|
blankType: MathQuizBlankType.numbersAndOperators,
|
||||||
operationCount: 16,
|
operationCount: 16,
|
||||||
|
puzzleCount: 2,
|
||||||
|
),
|
||||||
|
// --- [신규] 패턴 7: 4x4 그리드 (고난도) [Lv 19] ---
|
||||||
|
const MathQuizDifficulty(
|
||||||
|
levelIndex: 19,
|
||||||
|
name: 'Lv. 19: 4x4 그리드 (최상)',
|
||||||
|
contextId: 'MATH_L19_OP16_HARD',
|
||||||
|
layout: MathQuizLayout.gridSquare,
|
||||||
|
operators: '+,-,*,/',
|
||||||
|
blankCount: 8,
|
||||||
|
blankType: MathQuizBlankType.numbersAndOperators,
|
||||||
|
operationCount: 16,
|
||||||
|
puzzleCount: 1,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -211,12 +265,11 @@ class MathQuizDifficulties {
|
|||||||
if (levelIndex < 1) levelIndex = 1;
|
if (levelIndex < 1) levelIndex = 1;
|
||||||
if (levelIndex > allDifficulties.length) levelIndex = allDifficulties.length;
|
if (levelIndex > allDifficulties.length) levelIndex = allDifficulties.length;
|
||||||
return allDifficulties.firstWhere((level) => level.levelIndex == levelIndex,
|
return allDifficulties.firstWhere((level) => level.levelIndex == levelIndex,
|
||||||
orElse: () => allDifficulties[0]
|
orElse: () => allDifficulties[0]);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 랭킹 화면용 맵 (ContextId -> 이름)
|
/// 랭킹 화면용 맵 (ContextId -> 이름)
|
||||||
static Map<String, String> get contextIdToNameMap {
|
static Map<String, String> get contextIdToNameMap {
|
||||||
return { for (var level in allDifficulties) level.contextId : level.name };
|
return {for (var level in allDifficulties) level.contextId: level.name};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,29 +1,36 @@
|
|||||||
// packages/feature_game_mathquiz/lib/models/math_quiz_models.dart
|
// packages/feature_game_mathquiz/lib/models/math_quiz_models.dart
|
||||||
|
|
||||||
|
/// 개별 빈칸의 타입을 정의
|
||||||
|
enum PuzzleBlankType { number, operator }
|
||||||
|
|
||||||
|
/// 검증해야 할 단일 방정식을 정의
|
||||||
|
class MathQuizEquation {
|
||||||
|
final List<int> expressionIndices;
|
||||||
|
final int resultIndex;
|
||||||
|
|
||||||
|
MathQuizEquation({
|
||||||
|
required this.expressionIndices,
|
||||||
|
required this.resultIndex,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// 퍼즐 한 판의 데이터 구조
|
/// 퍼즐 한 판의 데이터 구조
|
||||||
class MathQuizPuzzle {
|
class MathQuizPuzzle {
|
||||||
/// [수정] 그리드 셀 데이터
|
|
||||||
///
|
|
||||||
/// '?'는 빈칸, ' '는 공백(빈 셀)입니다.
|
|
||||||
///
|
|
||||||
/// 예: ["?", "+", "3", "=", "8"] (5x1 그리드)
|
|
||||||
/// 예: ["?", "+", "2", "=", "5", "+", " ", "+", ... ] (5x5 그리드)
|
|
||||||
final List<String> gridCells;
|
final List<String> gridCells;
|
||||||
|
|
||||||
/// 그리드의 가로 칸 수 (예: 5)
|
|
||||||
final int gridCrossAxisCount;
|
final int gridCrossAxisCount;
|
||||||
|
|
||||||
/// 유저가 채워야 할 정답 목록 (순서대로)
|
|
||||||
final List<String> solutions;
|
|
||||||
|
|
||||||
/// 유저에게 제공될 숫자/기호 버튼 옵션
|
|
||||||
final List<String> options;
|
final List<String> options;
|
||||||
|
final List<MathQuizEquation> equations;
|
||||||
|
final List<String> solutions;
|
||||||
|
|
||||||
|
/// 빈칸의 타입 목록 (solutions와 1:1 매칭)
|
||||||
|
final List<PuzzleBlankType> blankTypes;
|
||||||
|
|
||||||
MathQuizPuzzle({
|
MathQuizPuzzle({
|
||||||
// ❌ puzzleType 삭제
|
|
||||||
required this.gridCells,
|
required this.gridCells,
|
||||||
required this.gridCrossAxisCount,
|
required this.gridCrossAxisCount,
|
||||||
required this.solutions,
|
|
||||||
required this.options,
|
required this.options,
|
||||||
|
required this.equations,
|
||||||
|
required this.solutions,
|
||||||
|
required this.blankTypes, // 👈 [추가]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
// packages/feature_game_mathquiz/lib/screens/math_quiz_lobby_screen.dart
|
||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@ -5,24 +6,27 @@ import 'package:provider/provider.dart';
|
|||||||
// [C] 공통 서비스 (service_api)
|
// [C] 공통 서비스 (service_api)
|
||||||
import 'package:service_api/service_api.dart';
|
import 'package:service_api/service_api.dart';
|
||||||
// [A] 공통 UI (feature_common)
|
// [A] 공통 UI (feature_common)
|
||||||
import 'package:feature_common/feature_common.dart';
|
import 'package:feature_common/feature_common.dart';
|
||||||
// [B] 이 패키지 (mathquiz)
|
// [B] 이 패키지 (mathquiz)
|
||||||
import 'math_quiz_screen.dart';
|
import 'math_quiz_screen.dart';
|
||||||
import '../controllers/math_quiz_controller.dart'; // 👈 [추가] 컨트롤러 임포트
|
import '../controllers/math_quiz_controller.dart';
|
||||||
|
import '../models/math_quiz_difficulty.dart'; // 👈 [추가]
|
||||||
|
|
||||||
class MathQuizLobbyScreen extends StatefulWidget {
|
class MathQuizLobbyScreen extends StatefulWidget {
|
||||||
const MathQuizLobbyScreen({ super.key });
|
const MathQuizLobbyScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<MathQuizLobbyScreen> createState() => _MathQuizLobbyScreenState();
|
State<MathQuizLobbyScreen> createState() => _MathQuizLobbyScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MathQuizLobbyScreenState extends State<MathQuizLobbyScreen> {
|
class _MathQuizLobbyScreenState extends State<MathQuizLobbyScreen> {
|
||||||
int _maxUnlockedLevel = 1;
|
int _maxUnlockedLevel = 1;
|
||||||
Map<int, (int, int)> _rankHistory = {};
|
Map<int, (int, int)> _rankHistory = {};
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
|
|
||||||
late final SessionNotifier _sessionNotifier;
|
late final SessionNotifier _sessionNotifier;
|
||||||
|
late final LobbyHelperService _lobbyHelper;
|
||||||
|
// [🔥 수정] 서비스를 직접 생성 (Provider로 읽지 않음)
|
||||||
final PuzzleService _puzzleService = PuzzleService();
|
final PuzzleService _puzzleService = PuzzleService();
|
||||||
final IdentityService _identityService = IdentityService();
|
final IdentityService _identityService = IdentityService();
|
||||||
|
|
||||||
@ -30,61 +34,61 @@ class _MathQuizLobbyScreenState extends State<MathQuizLobbyScreen> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_sessionNotifier = context.read<SessionNotifier>();
|
_sessionNotifier = context.read<SessionNotifier>();
|
||||||
|
|
||||||
|
// [🔥 수정] 헬퍼 서비스 초기화 (직접 생성한 서비스 주입)
|
||||||
|
_lobbyHelper = LobbyHelperService(
|
||||||
|
identityService: _identityService,
|
||||||
|
puzzleService: _puzzleService,
|
||||||
|
);
|
||||||
|
|
||||||
_loadProgress(forceRefreshRanks: true);
|
_loadProgress(forceRefreshRanks: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 랭킹 및 레벨 진행 상황 로드
|
/// [수정됨] 공통 헬퍼를 사용
|
||||||
Future<void> _loadProgress({bool forceRefreshRanks = false}) async {
|
Future<void> _loadProgress({bool forceRefreshRanks = false}) async {
|
||||||
// 1. (가벼움) 레벨 정보 새로고침
|
// 1. (가벼움) 레벨 정보 새로고침
|
||||||
final maxLevel = await _identityService.getMaxUnlockedLevel(gameType: 'MATH_QUIZ');
|
final maxLevel = await _lobbyHelper.loadMaxLevel('MATH_QUIZ');
|
||||||
if (mounted) { setState(() { _maxUnlockedLevel = maxLevel; }); }
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_maxUnlockedLevel = maxLevel;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 2. (무거움) 랭킹 정보 새로고침 (필요할 때만)
|
// 2. (무거움) 랭킹 정보 새로고침 (필요할 때만)
|
||||||
if (!forceRefreshRanks) return;
|
if (!forceRefreshRanks) return;
|
||||||
|
|
||||||
final String? myName = _sessionNotifier.session?.userName;
|
final String? myName = _sessionNotifier.session?.userName;
|
||||||
if (myName == null) return;
|
if (myName == null) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final Map<int, int> oldRankMap = await _identityService.getLastSavedRankMap(gameType: 'MATH_QUIZ');
|
final rankHistory = await _lobbyHelper.loadRankHistory<MathQuizDifficulty>(
|
||||||
List<Future<List<GameRankDto>>> rankFutures = [];
|
gameType: 'MATH_QUIZ',
|
||||||
for (final level in MathQuizDifficulties.allDifficulties) {
|
myName: myName,
|
||||||
rankFutures.add(_puzzleService.fetchRanks('MATH_QUIZ', level.contextId));
|
allLevels: MathQuizDifficulties.allDifficulties,
|
||||||
|
getLevelIndex: (level) => level.levelIndex,
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_rankHistory = rankHistory;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
final List<List<GameRankDto>> allRankResults = await Future.wait(rankFutures);
|
|
||||||
Map<int, int> newRankMapForStorage = {};
|
|
||||||
Map<int, (int, int)> newRankHistoryForState = {};
|
|
||||||
|
|
||||||
for (int i = 0; i < MathQuizDifficulties.allDifficulties.length; i++) {
|
|
||||||
final level = MathQuizDifficulties.allDifficulties[i];
|
|
||||||
final currentRanks = allRankResults[i];
|
|
||||||
final int levelIndex = level.levelIndex;
|
|
||||||
final int oldRank = oldRankMap[levelIndex] ?? 0;
|
|
||||||
int currentRank = 0;
|
|
||||||
int myRankIndex = currentRanks.indexWhere((r) => r.playerName == myName);
|
|
||||||
if (myRankIndex != -1) { currentRank = myRankIndex + 1; }
|
|
||||||
newRankMapForStorage[levelIndex] = currentRank;
|
|
||||||
newRankHistoryForState[levelIndex] = (oldRank, currentRank);
|
|
||||||
}
|
|
||||||
|
|
||||||
await _identityService.saveLastRankMap(newRankMapForStorage, gameType: 'MATH_QUIZ');
|
|
||||||
if (mounted) { setState(() { _rankHistory = newRankHistoryForState; }); }
|
|
||||||
log("수학 퀴즈 랭킹 변동 확인 완료. (유저: $myName)");
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log("MathQuizLobbyScreen: 랭킹 확인 실패: $e");
|
log("MathQuizLobbyScreen: 랭킹 확인 실패: $e");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 🔽 [수정] 게임 시작 함수
|
/// 🔽 [수정 없음] 게임 시작 함수
|
||||||
Future<void> _startGame(MathQuizDifficulty level) async {
|
Future<void> _startGame(MathQuizDifficulty level) async {
|
||||||
setState(() { _isLoading = true; });
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final session = _sessionNotifier.session;
|
final session = _sessionNotifier.session;
|
||||||
if (session == null) {
|
if (session == null) {
|
||||||
throw Exception("세션이 로드되지 않았습니다.");
|
throw Exception("세션이 로드되지 않았습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. 컨트롤러 생성 및 새 게임 시작 (제너레이터 호출)
|
// 1. 컨트롤러 생성 및 새 게임 시작 (제너레이터 호출)
|
||||||
final controller = MathQuizController();
|
final controller = MathQuizController();
|
||||||
controller.startNewGame(level, session.userId, session.userName);
|
controller.startNewGame(level, session.userId, session.userName);
|
||||||
@ -100,7 +104,7 @@ class _MathQuizLobbyScreenState extends State<MathQuizLobbyScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 3. 게임에서 돌아오면 레벨 잠금 상태만 새로고침
|
// 3. 게임에서 돌아오면 레벨 잠금 상태만 새로고침
|
||||||
_loadProgress(forceRefreshRanks: false);
|
_loadProgress(forceRefreshRanks: false);
|
||||||
}
|
}
|
||||||
@ -112,48 +116,44 @@ class _MathQuizLobbyScreenState extends State<MathQuizLobbyScreen> {
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() { _isLoading = false; });
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// ... (이하 build 메서드는 이전과 동일) ...
|
|
||||||
context.watch<ThemeNotifier>();
|
context.watch<ThemeNotifier>();
|
||||||
context.watch<SessionNotifier>();
|
context.watch<SessionNotifier>();
|
||||||
|
|
||||||
final bool allLevelsUnlocked = _maxUnlockedLevel >= MathQuizDifficulties.allDifficulties.length;
|
final bool allLevelsUnlocked =
|
||||||
|
_maxUnlockedLevel >= MathQuizDifficulties.allDifficulties.length;
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
return CommonGameShell(
|
return CommonGameShell(
|
||||||
title: '계산 퀴즈',
|
title: '계산 퀴즈',
|
||||||
|
|
||||||
onRankingPressed: () {
|
onRankingPressed: () {
|
||||||
final List<GameDifficulty> difficulties = MathQuizDifficulties.allDifficulties
|
|
||||||
.map((level) => GameDifficulty(
|
|
||||||
name: level.name,
|
|
||||||
contextId: level.contextId,
|
|
||||||
))
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => RankingScreen(
|
builder: (context) => RankingScreen(
|
||||||
gameType: 'MATH_QUIZ',
|
gameType: 'MATH_QUIZ',
|
||||||
difficulties: difficulties,
|
difficulties: MathQuizDifficulties.allDifficulties,
|
||||||
initialDifficultyName: MathQuizDifficulties.getLevel(_maxUnlockedLevel).name,
|
initialDifficultyName:
|
||||||
|
MathQuizDifficulties.getLevel(_maxUnlockedLevel).name,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
body: LayoutBuilder(
|
body: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
const double maxContentRatio = 0.6;
|
const double maxContentRatio = 0.6;
|
||||||
final double constrainedWidth = (constraints.maxHeight * maxContentRatio) > 500
|
final double constrainedWidth =
|
||||||
? 500 : (constraints.maxHeight * maxContentRatio);
|
(constraints.maxHeight * maxContentRatio) > 500
|
||||||
|
? 500
|
||||||
|
: (constraints.maxHeight * maxContentRatio);
|
||||||
return Center(
|
return Center(
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: BoxConstraints(maxWidth: constrainedWidth),
|
constraints: BoxConstraints(maxWidth: constrainedWidth),
|
||||||
@ -165,14 +165,19 @@ class _MathQuizLobbyScreenState extends State<MathQuizLobbyScreen> {
|
|||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
itemCount: MathQuizDifficulties.allDifficulties.length,
|
itemCount: MathQuizDifficulties.allDifficulties.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final MathQuizDifficulty level = MathQuizDifficulties.allDifficulties[index];
|
final MathQuizDifficulty level =
|
||||||
final bool isUnlocked = allLevelsUnlocked || level.levelIndex <= _maxUnlockedLevel;
|
MathQuizDifficulties.allDifficulties[index];
|
||||||
final (int oldRank, int currentRank) = _rankHistory[level.levelIndex] ?? (0, 0);
|
final bool isUnlocked = allLevelsUnlocked ||
|
||||||
|
level.levelIndex <= _maxUnlockedLevel;
|
||||||
Widget? trailingWidget = isUnlocked ? const Icon(Icons.play_arrow_rounded) : null;
|
final (int oldRank, int currentRank) =
|
||||||
|
_rankHistory[level.levelIndex] ?? (0, 0);
|
||||||
|
|
||||||
|
Widget? trailingWidget = isUnlocked
|
||||||
|
? const Icon(Icons.play_arrow_rounded)
|
||||||
|
: null;
|
||||||
String? subtitleText;
|
String? subtitleText;
|
||||||
Color? subtitleColor;
|
Color? subtitleColor;
|
||||||
|
|
||||||
if (currentRank > 0) {
|
if (currentRank > 0) {
|
||||||
String rankStr = "${currentRank}위";
|
String rankStr = "${currentRank}위";
|
||||||
if (oldRank > 0) {
|
if (oldRank > 0) {
|
||||||
@ -180,45 +185,71 @@ class _MathQuizLobbyScreenState extends State<MathQuizLobbyScreen> {
|
|||||||
if (change > 0) {
|
if (change > 0) {
|
||||||
subtitleText = "$rankStr (▲ $change)";
|
subtitleText = "$rankStr (▲ $change)";
|
||||||
subtitleColor = Colors.green;
|
subtitleColor = Colors.green;
|
||||||
trailingWidget = const Icon(Icons.arrow_circle_up_rounded, color: Colors.green, size: 28);
|
trailingWidget = const Icon(
|
||||||
|
Icons.arrow_circle_up_rounded,
|
||||||
|
color: Colors.green,
|
||||||
|
size: 28);
|
||||||
} else if (change < 0) {
|
} else if (change < 0) {
|
||||||
subtitleText = "$rankStr (▼ ${change.abs()})";
|
subtitleText = "$rankStr (▼ ${change.abs()})";
|
||||||
subtitleColor = Colors.red;
|
subtitleColor = Colors.red;
|
||||||
trailingWidget = const Icon(Icons.arrow_circle_down_rounded, color: Colors.red, size: 28);
|
trailingWidget = const Icon(
|
||||||
|
Icons.arrow_circle_down_rounded,
|
||||||
|
color: Colors.red,
|
||||||
|
size: 28);
|
||||||
} else {
|
} else {
|
||||||
subtitleText = "$rankStr (유지)";
|
subtitleText = "$rankStr (유지)";
|
||||||
subtitleColor = Colors.grey;
|
subtitleColor = Colors.grey;
|
||||||
trailingWidget = const Icon(Icons.check_circle_outline_rounded, color: Colors.grey, size: 28);
|
trailingWidget = const Icon(
|
||||||
|
Icons.check_circle_outline_rounded,
|
||||||
|
color: Colors.grey,
|
||||||
|
size: 28);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
subtitleText = "$rankStr (신규 진입)";
|
subtitleText = "$rankStr (신규 진입)";
|
||||||
subtitleColor = Colors.blue;
|
subtitleColor = Colors.blue;
|
||||||
trailingWidget = const Icon(Icons.new_releases_rounded, color: Colors.blue, size: 28);
|
trailingWidget = const Icon(
|
||||||
|
Icons.new_releases_rounded,
|
||||||
|
color: Colors.blue,
|
||||||
|
size: 28);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (oldRank > 0) {
|
if (oldRank > 0) {
|
||||||
subtitleText = "랭킹 이탈 (이전 ${oldRank}위)";
|
subtitleText = "랭킹 이탈 (이전 ${oldRank}위)";
|
||||||
subtitleColor = Colors.orange;
|
subtitleColor = Colors.orange;
|
||||||
trailingWidget = const Icon(Icons.warning_amber_rounded, color: Colors.orange, size: 28);
|
trailingWidget = const Icon(
|
||||||
|
Icons.warning_amber_rounded,
|
||||||
|
color: Colors.orange,
|
||||||
|
size: 28);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0),
|
margin: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16.0, vertical: 4.0),
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: Icon(
|
leading: Icon(
|
||||||
isUnlocked ? Icons.lock_open_rounded : Icons.lock_rounded,
|
isUnlocked
|
||||||
|
? Icons.lock_open_rounded
|
||||||
|
: Icons.lock_rounded,
|
||||||
color: isUnlocked ? theme.primaryColor : Colors.grey,
|
color: isUnlocked ? theme.primaryColor : Colors.grey,
|
||||||
),
|
),
|
||||||
title: Text(level.name, style: TextStyle(
|
title: Text(level.name,
|
||||||
fontSize: 18,
|
style: TextStyle(
|
||||||
fontWeight: isUnlocked ? FontWeight.bold : FontWeight.normal,
|
fontSize: 18,
|
||||||
color: isUnlocked ? theme.textTheme.bodyLarge?.color : Colors.grey,
|
fontWeight: isUnlocked
|
||||||
)),
|
? FontWeight.bold
|
||||||
|
: FontWeight.normal,
|
||||||
|
color: isUnlocked
|
||||||
|
? theme.textTheme.bodyLarge?.color
|
||||||
|
: Colors.grey,
|
||||||
|
)),
|
||||||
subtitle: subtitleText != null
|
subtitle: subtitleText != null
|
||||||
? Text(subtitleText, style: TextStyle(color: subtitleColor, fontWeight: FontWeight.bold))
|
? Text(subtitleText,
|
||||||
: null,
|
style: TextStyle(
|
||||||
trailing: trailingWidget,
|
color: subtitleColor,
|
||||||
|
fontWeight: FontWeight.bold))
|
||||||
|
: null,
|
||||||
|
trailing: trailingWidget,
|
||||||
onTap: isUnlocked && !_isLoading
|
onTap: isUnlocked && !_isLoading
|
||||||
? () => _startGame(level)
|
? () => _startGame(level)
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
|
|||||||
import 'package:service_api/service_api.dart';
|
import 'package:service_api/service_api.dart';
|
||||||
import 'package:feature_common/feature_common.dart';
|
import 'package:feature_common/feature_common.dart';
|
||||||
import '../controllers/math_quiz_controller.dart';
|
import '../controllers/math_quiz_controller.dart';
|
||||||
|
import '../models/math_quiz_difficulty.dart';
|
||||||
import '../models/math_quiz_models.dart';
|
import '../models/math_quiz_models.dart';
|
||||||
|
|
||||||
class MathQuizScreen extends StatefulWidget {
|
class MathQuizScreen extends StatefulWidget {
|
||||||
@ -14,24 +15,28 @@ class MathQuizScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _MathQuizScreenState extends State<MathQuizScreen> {
|
class _MathQuizScreenState extends State<MathQuizScreen> {
|
||||||
bool _isDialogShowing = false;
|
bool _isDialogShowing = false;
|
||||||
|
|
||||||
// ( ... _showGameCompletion 메서드는 이전과 동일 ... )
|
// ... ( _showGameCompletion 메서드는 이전과 동일 ... )
|
||||||
void _showGameCompletion(MathQuizController controller) async {
|
void _showGameCompletion(MathQuizController controller) async {
|
||||||
String formatMathQuizScore(int primary, int? secondary) {
|
String formatMathQuizScore(int primary, int? secondary) {
|
||||||
final problemCount = primary;
|
final blanksCount = primary;
|
||||||
final time = (secondary ?? 0).toString();
|
final time = (secondary ?? 0).toString();
|
||||||
return '${problemCount}개 (${time}초)';
|
return '총 ${blanksCount}칸 (${time}초)';
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> saveMathQuizProgress(String playerName) async {
|
Future<void> saveMathQuizProgress(String playerName) async {
|
||||||
final identityService = IdentityService();
|
final identityService = IdentityService();
|
||||||
final int currentMaxLevel = await identityService.getMaxUnlockedLevel(gameType: 'MATH_QUIZ');
|
final int currentMaxLevel =
|
||||||
|
await identityService.getMaxUnlockedLevel(gameType: 'MATH_QUIZ');
|
||||||
if (currentMaxLevel < 99) {
|
if (currentMaxLevel < 99) {
|
||||||
if (controller.difficulty.levelIndex >= currentMaxLevel) {
|
if (controller.difficulty.levelIndex >= currentMaxLevel) {
|
||||||
int nextLevel = controller.difficulty.levelIndex + 1;
|
int nextLevel = controller.difficulty.levelIndex + 1;
|
||||||
if (nextLevel > MathQuizDifficulties.allDifficulties.length) {
|
if (nextLevel > MathQuizDifficulties.allDifficulties.length) {
|
||||||
await identityService.saveMaxUnlockedLevel(99, gameType: 'MATH_QUIZ');
|
await identityService.saveMaxUnlockedLevel(99,
|
||||||
|
gameType: 'MATH_QUIZ');
|
||||||
} else {
|
} else {
|
||||||
await identityService.saveMaxUnlockedLevel(nextLevel, gameType: 'MATH_QUIZ');
|
await identityService.saveMaxUnlockedLevel(nextLevel,
|
||||||
|
gameType: 'MATH_QUIZ');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -41,11 +46,11 @@ class _MathQuizScreenState extends State<MathQuizScreen> {
|
|||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => GameCompletionScreen(
|
builder: (context) => GameCompletionScreen(
|
||||||
args: GameResultArgs(
|
args: GameResultArgs(
|
||||||
gameType: 'MATH_QUIZ',
|
gameType: 'MATH_QUIZ',
|
||||||
contextId: controller.difficulty.contextId,
|
contextId: controller.difficulty.contextId,
|
||||||
primaryScore: controller.puzzle.solutions.length,
|
primaryScore: controller.totalBlanksFilled,
|
||||||
secondaryScore: controller.secondsElapsed,
|
secondaryScore: controller.secondsElapsed,
|
||||||
userId: controller.userId,
|
userId: controller.userId,
|
||||||
userName: controller.userName,
|
userName: controller.userName,
|
||||||
scoreFormatter: formatMathQuizScore,
|
scoreFormatter: formatMathQuizScore,
|
||||||
@ -73,14 +78,30 @@ class _MathQuizScreenState extends State<MathQuizScreen> {
|
|||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(controller.difficulty.name),
|
title: Text('Lv. ${controller.difficulty.levelIndex}'),
|
||||||
actions: [
|
actions: [
|
||||||
Padding(
|
Center(
|
||||||
padding: const EdgeInsets.only(right: 20.0),
|
child: Padding(
|
||||||
child: Center(
|
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||||
|
child: _buildTriesWidget(controller.remainingTries),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||||
|
child: Text(
|
||||||
|
'${controller.currentPuzzleIndex + 1} / ${controller.totalPuzzlesInLevel}',
|
||||||
|
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 20.0),
|
||||||
child: Text(
|
child: Text(
|
||||||
'${(controller.secondsElapsed ~/ 60).toString().padLeft(2, '0')}:${(controller.secondsElapsed % 60).toString().padLeft(2, '0')}',
|
'${(controller.secondsElapsed ~/ 60).toString().padLeft(2, '0')}:${(controller.secondsElapsed % 60).toString().padLeft(2, '0')}',
|
||||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
style:
|
||||||
|
const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -99,15 +120,27 @@ class _MathQuizScreenState extends State<MathQuizScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 🔽 세로 모드 레이아웃
|
Widget _buildTriesWidget(int tries) {
|
||||||
Widget _buildPortraitLayout(BuildContext context, MathQuizController controller) {
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: List.generate(3, (index) {
|
||||||
|
return Icon(
|
||||||
|
index < tries ? Icons.favorite : Icons.favorite_border,
|
||||||
|
color: Colors.redAccent,
|
||||||
|
size: 24,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPortraitLayout(
|
||||||
|
BuildContext context, MathQuizController controller) {
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Center(
|
child: Center(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
// [수정] _buildPuzzleGrid를 항상 호출
|
|
||||||
child: _buildPuzzleGrid(context, controller),
|
child: _buildPuzzleGrid(context, controller),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -117,38 +150,33 @@ class _MathQuizScreenState extends State<MathQuizScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 🔽 가로 모드 레이아웃
|
Widget _buildLandscapeLayout(
|
||||||
Widget _buildLandscapeLayout(BuildContext context, MathQuizController controller) {
|
BuildContext context, MathQuizController controller) {
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 6, // 방정식 영역
|
flex: 6,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
// [수정] _buildPuzzleGrid를 항상 호출
|
|
||||||
child: _buildPuzzleGrid(context, controller),
|
child: _buildPuzzleGrid(context, controller),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 4, // 키패드 영역
|
flex: 4,
|
||||||
child: _buildKeypad(context, controller),
|
child: _buildKeypad(context, controller),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ❌ [삭제] _buildEquationList 메서드 삭제
|
|
||||||
|
|
||||||
/// [수정] _buildEquationGrid -> _buildPuzzleGrid
|
|
||||||
/// (모든 퍼즐을 그리는 유일한 빌더)
|
|
||||||
Widget _buildPuzzleGrid(BuildContext context, MathQuizController controller) {
|
Widget _buildPuzzleGrid(BuildContext context, MathQuizController controller) {
|
||||||
final puzzle = controller.puzzle;
|
final puzzle = controller.puzzle;
|
||||||
final userAnswers = controller.userAnswers;
|
final userAnswers = controller.userAnswers;
|
||||||
final int crossAxisCount = puzzle.gridCrossAxisCount;
|
final int crossAxisCount = puzzle.gridCrossAxisCount;
|
||||||
|
|
||||||
int blankIndex = 0;
|
int blankIndex = 0;
|
||||||
|
|
||||||
return GridView.builder(
|
return GridView.builder(
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
@ -159,26 +187,32 @@ class _MathQuizScreenState extends State<MathQuizScreen> {
|
|||||||
),
|
),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final String part = puzzle.gridCells[index];
|
final String part = puzzle.gridCells[index];
|
||||||
|
|
||||||
if (part == "?") {
|
if (part == "?") {
|
||||||
// 빈칸일 경우
|
|
||||||
final int currentBlankIndex = blankIndex;
|
final int currentBlankIndex = blankIndex;
|
||||||
blankIndex++;
|
blankIndex++;
|
||||||
return _buildBlankBox(
|
return _buildBlankBox(
|
||||||
context,
|
context,
|
||||||
controller,
|
controller,
|
||||||
currentBlankIndex,
|
currentBlankIndex,
|
||||||
userAnswers[currentBlankIndex],
|
(currentBlankIndex < userAnswers.length)
|
||||||
|
? userAnswers[currentBlankIndex]
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
} else if (part == " ") {
|
} else if (part == " ") {
|
||||||
// " " (빈 공간)
|
|
||||||
return Container();
|
return Container();
|
||||||
} else {
|
} else {
|
||||||
// 숫자나 기호일 경우
|
|
||||||
return Center(
|
return Center(
|
||||||
child: Text(
|
child: FittedBox(
|
||||||
part,
|
fit: BoxFit.contain,
|
||||||
style: const TextStyle(fontSize: 32, fontWeight: FontWeight.bold),
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(4.0),
|
||||||
|
child: Text(
|
||||||
|
part,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 32, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -186,38 +220,55 @@ class _MathQuizScreenState extends State<MathQuizScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [수정] _buildBlankBox (isGrid 파라미터 삭제)
|
Widget _buildBlankBox(BuildContext context, MathQuizController controller,
|
||||||
Widget _buildBlankBox(BuildContext context, MathQuizController controller, int index, String? value) {
|
int index, String? value) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final bool isSelected = (controller.selectedBlankIndex == index);
|
final bool isSelected = (controller.selectedBlankIndex == index);
|
||||||
|
final bool isWrong = controller.isWrongAnswer;
|
||||||
|
final bool isRevealing = controller.isRevealingAnswer;
|
||||||
|
|
||||||
|
Color borderColor;
|
||||||
|
Color textColor;
|
||||||
|
|
||||||
|
if (isRevealing) {
|
||||||
|
borderColor = theme.colorScheme.primary;
|
||||||
|
textColor = theme.colorScheme.primary;
|
||||||
|
} else if (isWrong) {
|
||||||
|
borderColor = theme.colorScheme.error;
|
||||||
|
textColor = theme.colorScheme.onSurfaceVariant;
|
||||||
|
} else if (isSelected) {
|
||||||
|
borderColor = theme.colorScheme.primary;
|
||||||
|
textColor = theme.colorScheme.onSurfaceVariant;
|
||||||
|
} else {
|
||||||
|
borderColor = Colors.transparent;
|
||||||
|
textColor = theme.colorScheme.onSurfaceVariant;
|
||||||
|
}
|
||||||
|
|
||||||
// [수정] 그리드 셀의 크기는 GridView가 결정하므로
|
|
||||||
// AspectRatio를 사용해 1:1 비율 유지
|
|
||||||
return AspectRatio(
|
return AspectRatio(
|
||||||
aspectRatio: 1 / 1,
|
aspectRatio: 1 / 1,
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () => controller.onBlankTapped(index),
|
onTap: () => controller.onBlankTapped(index),
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: const EdgeInsets.all(4.0), // 그리드/리스트 공통 여백
|
margin: const EdgeInsets.all(4.0),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: theme.colorScheme.surfaceVariant,
|
color: theme.colorScheme.surfaceVariant,
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: isSelected ? theme.colorScheme.primary : Colors.transparent,
|
color: borderColor,
|
||||||
width: 3,
|
width: 3,
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
// [수정] 폰트 크기를 FittedBox로 자동 조절
|
|
||||||
child: FittedBox(
|
child: FittedBox(
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(4.0),
|
padding: const EdgeInsets.all(4.0),
|
||||||
child: Text(
|
child: Text(
|
||||||
value ?? '',
|
value ?? '',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
|
fontSize: 32,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: textColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -228,13 +279,32 @@ class _MathQuizScreenState extends State<MathQuizScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 2. 키패드 UI 빌더
|
/// [🔥 수정됨] 키패드 UI 빌더 (동적 필터링)
|
||||||
Widget _buildKeypad(BuildContext context, MathQuizController controller) {
|
Widget _buildKeypad(BuildContext context, MathQuizController controller) {
|
||||||
// ... (이하 키패드 로직은 동일) ...
|
|
||||||
final puzzle = controller.puzzle;
|
final puzzle = controller.puzzle;
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
final bool isDisabled = controller.isRevealingAnswer;
|
||||||
|
|
||||||
|
final PuzzleBlankType? selectedType = controller.currentSelectedBlankType;
|
||||||
|
|
||||||
|
// [🔥 최종 확인] 헬퍼 함수
|
||||||
|
bool isOperator(String val) => ['/', '*', '-', '+'].contains(val);
|
||||||
|
bool isNumber(String val) => int.tryParse(val) != null;
|
||||||
|
|
||||||
|
// [🔥 최종 수정] 타입에 따라 옵션 필터링
|
||||||
|
List<String> availableOptions = [];
|
||||||
|
if (selectedType == PuzzleBlankType.number) {
|
||||||
|
// 숫자가 필요하면 숫자만 필터링
|
||||||
|
availableOptions = puzzle.options.where((opt) => isNumber(opt)).toList();
|
||||||
|
} else if (selectedType == PuzzleBlankType.operator) {
|
||||||
|
// 연산자가 필요하면 연산자만 필터링
|
||||||
|
availableOptions = puzzle.options.where((opt) => isOperator(opt)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
final int totalButtonCount = availableOptions.length + 1; // 필터링된 옵션 + 지우기
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
|
// ... (GridView.builder 이하 로직은 동일) ...
|
||||||
padding: const EdgeInsets.all(8.0),
|
padding: const EdgeInsets.all(8.0),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: theme.scaffoldBackgroundColor,
|
color: theme.scaffoldBackgroundColor,
|
||||||
@ -255,19 +325,18 @@ class _MathQuizScreenState extends State<MathQuizScreen> {
|
|||||||
mainAxisSpacing: 8,
|
mainAxisSpacing: 8,
|
||||||
crossAxisSpacing: 8,
|
crossAxisSpacing: 8,
|
||||||
),
|
),
|
||||||
itemCount: puzzle.options.length + 1, // 옵션 + 지우기 버튼
|
itemCount: totalButtonCount,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
|
if (index == availableOptions.length) {
|
||||||
if (index == puzzle.options.length) {
|
|
||||||
return FilledButton.tonal(
|
return FilledButton.tonal(
|
||||||
onPressed: () => controller.onClearTapped(),
|
onPressed: isDisabled ? null : () => controller.onClearTapped(),
|
||||||
child: const Icon(Icons.backspace_outlined),
|
child: const Icon(Icons.backspace_outlined),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final String option = puzzle.options[index];
|
final String option = availableOptions[index];
|
||||||
return FilledButton(
|
return FilledButton(
|
||||||
onPressed: () => controller.onOptionTapped(option),
|
onPressed: isDisabled ? null : () => controller.onOptionTapped(option),
|
||||||
child: Text(
|
child: Text(
|
||||||
option,
|
option,
|
||||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||||
|
|||||||
@ -32,12 +32,13 @@ class SpiderGameController with ChangeNotifier {
|
|||||||
|
|
||||||
List<SpiderCard> _cardsToDealAnimate = [];
|
List<SpiderCard> _cardsToDealAnimate = [];
|
||||||
List<SpiderCard> get cardsToDealAnimate => _cardsToDealAnimate;
|
List<SpiderCard> get cardsToDealAnimate => _cardsToDealAnimate;
|
||||||
|
|
||||||
void clearDealAnimationTrigger() {
|
void clearDealAnimationTrigger() {
|
||||||
debugPrint("[LOG] clearDealAnimationTrigger: CALLED. Clearing animation queue (Current count: ${_cardsToDealAnimate.length}).");
|
debugPrint(
|
||||||
|
"[LOG] clearDealAnimationTrigger: CALLED. Clearing animation queue (Current count: ${_cardsToDealAnimate.length}).");
|
||||||
_cardsToDealAnimate.clear();
|
_cardsToDealAnimate.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
List<SpiderCard> _cardsToAnimateStack = [];
|
List<SpiderCard> _cardsToAnimateStack = [];
|
||||||
List<SpiderCard> get cardsToAnimateStack => _cardsToAnimateStack;
|
List<SpiderCard> get cardsToAnimateStack => _cardsToAnimateStack;
|
||||||
int _animationSourcePileIndex = -1;
|
int _animationSourcePileIndex = -1;
|
||||||
@ -47,8 +48,8 @@ class SpiderGameController with ChangeNotifier {
|
|||||||
|
|
||||||
bool get canUndo {
|
bool get canUndo {
|
||||||
return _undoHistory.isNotEmpty &&
|
return _undoHistory.isNotEmpty &&
|
||||||
!_isGameCompleted &&
|
!_isGameCompleted &&
|
||||||
_undoCount < maxUndoCount;
|
_undoCount < maxUndoCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
void setUserInfo(String userId, String? userName) {
|
void setUserInfo(String userId, String? userName) {
|
||||||
@ -99,6 +100,7 @@ class SpiderGameController with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
return deck;
|
return deck;
|
||||||
}
|
}
|
||||||
|
|
||||||
(List<List<SpiderCard>>, List<SpiderCard>) _dealCards(
|
(List<List<SpiderCard>>, List<SpiderCard>) _dealCards(
|
||||||
List<SpiderCard> shuffledDeck, String distribution) {
|
List<SpiderCard> shuffledDeck, String distribution) {
|
||||||
final List<List<SpiderCard>> tableau = List.generate(10, (_) => []);
|
final List<List<SpiderCard>> tableau = List.generate(10, (_) => []);
|
||||||
@ -118,6 +120,7 @@ class SpiderGameController with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
return (tableau, stock);
|
return (tableau, stock);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _startTimer() {
|
void _startTimer() {
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
_secondsElapsed = 0;
|
_secondsElapsed = 0;
|
||||||
@ -126,6 +129,7 @@ class SpiderGameController with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void stopTimer() {
|
void stopTimer() {
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
}
|
}
|
||||||
@ -133,7 +137,7 @@ class SpiderGameController with ChangeNotifier {
|
|||||||
/// 🔽 덱 분배 (애니메이션 트리거)
|
/// 🔽 덱 분배 (애니메이션 트리거)
|
||||||
void dealFromStock() {
|
void dealFromStock() {
|
||||||
debugPrint("[LOG] dealFromStock: CALLED. Checking conditions...");
|
debugPrint("[LOG] dealFromStock: CALLED. Checking conditions...");
|
||||||
|
|
||||||
if (_currentState.stock.isEmpty) {
|
if (_currentState.stock.isEmpty) {
|
||||||
debugPrint("[LOG] dealFromStock: FAILED (Stock is empty)");
|
debugPrint("[LOG] dealFromStock: FAILED (Stock is empty)");
|
||||||
return;
|
return;
|
||||||
@ -152,8 +156,9 @@ class SpiderGameController with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final bool hasEmptyPile = _currentState.tableau.any((pile) => pile.isEmpty);
|
final bool hasEmptyPile = _currentState.tableau.any((pile) => pile.isEmpty);
|
||||||
debugPrint("[LOG] dealFromStock: Checking for empty piles... Result: $hasEmptyPile");
|
debugPrint(
|
||||||
|
"[LOG] dealFromStock: Checking for empty piles... Result: $hasEmptyPile");
|
||||||
|
|
||||||
if (hasEmptyPile) {
|
if (hasEmptyPile) {
|
||||||
debugPrint("[LOG] dealFromStock: FAILED (Empty pile found)");
|
debugPrint("[LOG] dealFromStock: FAILED (Empty pile found)");
|
||||||
return;
|
return;
|
||||||
@ -163,52 +168,66 @@ class SpiderGameController with ChangeNotifier {
|
|||||||
_saveUndoState();
|
_saveUndoState();
|
||||||
|
|
||||||
final int cardsToDealCount = min(10, _currentState.stock.length);
|
final int cardsToDealCount = min(10, _currentState.stock.length);
|
||||||
debugPrint("[LOG] dealFromStock: Preparing ${cardsToDealCount} cards for animation.");
|
debugPrint(
|
||||||
|
"[LOG] dealFromStock: Preparing ${cardsToDealCount} cards for animation.");
|
||||||
|
|
||||||
for (int i = 0; i < cardsToDealCount; i++) {
|
for (int i = 0; i < cardsToDealCount; i++) {
|
||||||
_cardsToDealAnimate.add(_currentState.stock.removeLast());
|
_cardsToDealAnimate.add(_currentState.stock.removeLast());
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint("[LOG] dealFromStock: Cards moved to _cardsToDealAnimate queue (Total: ${_cardsToDealAnimate.length}). Notifying listeners...");
|
debugPrint(
|
||||||
|
"[LOG] dealFromStock: Cards moved to _cardsToDealAnimate queue (Total: ${_cardsToDealAnimate.length}). Notifying listeners...");
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 🔽 덱 분배 애니메이션이 끝난 후 UI가 호출
|
/// 🔽 덱 분배 애니메이션이 끝난 후 UI가 호출
|
||||||
void finalizeDealFromStock(List<SpiderCard> dealtCards) {
|
void finalizeDealFromStock(List<SpiderCard> dealtCards) {
|
||||||
debugPrint("[LOG] finalizeDealFromStock: CALLED. Finalizing ${dealtCards.length} cards.");
|
debugPrint(
|
||||||
|
"[LOG] finalizeDealFromStock: CALLED. Finalizing ${dealtCards.length} cards.");
|
||||||
|
|
||||||
for (int i = 0; i < dealtCards.length; i++) {
|
for (int i = 0; i < dealtCards.length; i++) {
|
||||||
final card = dealtCards[i];
|
final card = dealtCards[i];
|
||||||
card.isFaceUp = true;
|
card.isFaceUp = true;
|
||||||
_currentState.tableau[i].add(card);
|
_currentState.tableau[i].add(card);
|
||||||
}
|
}
|
||||||
|
|
||||||
_currentState = _currentState.copyWith(moves: _currentState.moves + 1);
|
_currentState = _currentState.copyWith(moves: _currentState.moves + 1);
|
||||||
|
|
||||||
debugPrint("[LOG] finalizeDealFromStock: FINISHED. Calling _checkCompletedStacks...");
|
debugPrint(
|
||||||
|
"[LOG] finalizeDealFromStock: FINISHED. Calling _checkCompletedStacks...");
|
||||||
_checkCompletedStacks();
|
_checkCompletedStacks();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ( onDragStarted, onDragCancelled, onCardsDropped, _moveCards 는 동일 )
|
// ( onDragStarted, onDragCancelled, onCardsDropped, _moveCards 는 동일 )
|
||||||
void onDragStarted(List<SpiderCard> cards) {
|
void onDragStarted(List<SpiderCard> cards) {
|
||||||
_draggedCards = cards;
|
_draggedCards = cards;
|
||||||
for (var card in cards) { card.isBeingDragged = true; }
|
for (var card in cards) {
|
||||||
|
card.isBeingDragged = true;
|
||||||
|
}
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void onDragCancelled() {
|
void onDragCancelled() {
|
||||||
for (var card in _draggedCards) { card.isBeingDragged = false; }
|
for (var card in _draggedCards) {
|
||||||
|
card.isBeingDragged = false;
|
||||||
|
}
|
||||||
_draggedCards = [];
|
_draggedCards = [];
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void onCardsDropped(List<SpiderCard> cards, int targetPileIndex) {
|
void onCardsDropped(List<SpiderCard> cards, int targetPileIndex) {
|
||||||
final int sourcePileIndex = _findPileIndexForCard(cards.first);
|
final int sourcePileIndex = _findPileIndexForCard(cards.first);
|
||||||
for (var card in cards) { card.isBeingDragged = false; }
|
for (var card in cards) {
|
||||||
|
card.isBeingDragged = false;
|
||||||
|
}
|
||||||
_draggedCards = [];
|
_draggedCards = [];
|
||||||
_moveCards(cards, sourcePileIndex, targetPileIndex);
|
_moveCards(cards, sourcePileIndex, targetPileIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _moveCards(List<SpiderCard> cards, int fromIndex, int toIndex) {
|
void _moveCards(List<SpiderCard> cards, int fromIndex, int toIndex) {
|
||||||
if (fromIndex == toIndex) {
|
if (fromIndex == toIndex) {
|
||||||
notifyListeners(); return;
|
notifyListeners();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
_saveUndoState();
|
_saveUndoState();
|
||||||
final sourcePile = _currentState.tableau[fromIndex];
|
final sourcePile = _currentState.tableau[fromIndex];
|
||||||
@ -221,17 +240,17 @@ class SpiderGameController with ChangeNotifier {
|
|||||||
_currentState = _currentState.copyWith(moves: _currentState.moves + 1);
|
_currentState = _currentState.copyWith(moves: _currentState.moves + 1);
|
||||||
_checkCompletedStacks();
|
_checkCompletedStacks();
|
||||||
}
|
}
|
||||||
|
|
||||||
int undo() {
|
int undo() {
|
||||||
if (!canUndo) return _undoCount;
|
if (!canUndo) return _undoCount;
|
||||||
|
|
||||||
final prevState = _undoHistory.removeLast();
|
final prevState = _undoHistory.removeLast();
|
||||||
_currentState = SpiderGameState.fromHistory(prevState);
|
_currentState = SpiderGameState.fromHistory(prevState);
|
||||||
_undoCount++;
|
_undoCount++;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
return _undoCount;
|
return _undoCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ( canPickUpCard, getDraggableStack, isValidMove 는 동일 )
|
// ( canPickUpCard, getDraggableStack, isValidMove 는 동일 )
|
||||||
bool canPickUpCard(SpiderCard card) {
|
bool canPickUpCard(SpiderCard card) {
|
||||||
for (final pile in _currentState.tableau) {
|
for (final pile in _currentState.tableau) {
|
||||||
@ -239,6 +258,7 @@ class SpiderGameController with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<SpiderCard> getDraggableStack(SpiderCard tappedCard) {
|
List<SpiderCard> getDraggableStack(SpiderCard tappedCard) {
|
||||||
final int pileIndex = _findPileIndexForCard(tappedCard);
|
final int pileIndex = _findPileIndexForCard(tappedCard);
|
||||||
if (pileIndex == -1) return [];
|
if (pileIndex == -1) return [];
|
||||||
@ -251,15 +271,15 @@ class SpiderGameController with ChangeNotifier {
|
|||||||
final currentCard = pile[i];
|
final currentCard = pile[i];
|
||||||
if (currentCard.isFaceUp &&
|
if (currentCard.isFaceUp &&
|
||||||
prevCard.rank == currentCard.rank + 1 &&
|
prevCard.rank == currentCard.rank + 1 &&
|
||||||
prevCard.suit == currentCard.suit)
|
prevCard.suit == currentCard.suit) {
|
||||||
{
|
|
||||||
draggableStack.add(currentCard);
|
draggableStack.add(currentCard);
|
||||||
} else {
|
} else {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return draggableStack;
|
return draggableStack;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool isValidMove(List<SpiderCard> cardsToMove, int targetPileIndex) {
|
bool isValidMove(List<SpiderCard> cardsToMove, int targetPileIndex) {
|
||||||
if (cardsToMove.isEmpty) return false;
|
if (cardsToMove.isEmpty) return false;
|
||||||
final targetPile = _currentState.tableau[targetPileIndex];
|
final targetPile = _currentState.tableau[targetPileIndex];
|
||||||
@ -272,7 +292,7 @@ class SpiderGameController with ChangeNotifier {
|
|||||||
/// 🔽 _checkCompletedStacks (애니메이션 트리거)
|
/// 🔽 _checkCompletedStacks (애니메이션 트리거)
|
||||||
void _checkCompletedStacks() {
|
void _checkCompletedStacks() {
|
||||||
// 🔽 [수정] 애니메이션이 실행 중이면 중복 검사 방지
|
// 🔽 [수정] 애니메이션이 실행 중이면 중복 검사 방지
|
||||||
if (_cardsToAnimateStack.isNotEmpty) return;
|
if (_cardsToAnimateStack.isNotEmpty) return;
|
||||||
|
|
||||||
bool stackCompleted = false;
|
bool stackCompleted = false;
|
||||||
for (int i = 0; i < _currentState.tableau.length; i++) {
|
for (int i = 0; i < _currentState.tableau.length; i++) {
|
||||||
@ -295,12 +315,12 @@ class SpiderGameController with ChangeNotifier {
|
|||||||
_cardsToAnimateStack = last13Cards;
|
_cardsToAnimateStack = last13Cards;
|
||||||
_animationSourcePileIndex = i;
|
_animationSourcePileIndex = i;
|
||||||
_animationTargetFoundationIndex = _currentState.foundation.length;
|
_animationTargetFoundationIndex = _currentState.foundation.length;
|
||||||
|
|
||||||
stackCompleted = true;
|
stackCompleted = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stackCompleted) {
|
if (stackCompleted) {
|
||||||
notifyListeners(); // 👈 UI에 애니메이션을 그리라고 알림
|
notifyListeners(); // 👈 UI에 애니메이션을 그리라고 알림
|
||||||
} else {
|
} else {
|
||||||
@ -309,38 +329,43 @@ class SpiderGameController with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 🔽 [수정] 스택 완성 애니메이션이 끝난 후 UI가 호출 (인자 받도록 변경)
|
/// 🔽 [수정] 스택 완성 애니메이션이 끝난 후 UI가 호출 (인자 받도록 변경)
|
||||||
void finalizeStackCompletion(List<SpiderCard> cardsToAnimate, int sourceIndex) {
|
void finalizeStackCompletion(
|
||||||
debugPrint("[LOG] finalizeStackCompletion: CALLED. Source Index: $sourceIndex");
|
List<SpiderCard> cardsToAnimate, int sourceIndex) {
|
||||||
|
debugPrint(
|
||||||
|
"[LOG] finalizeStackCompletion: CALLED. Source Index: $sourceIndex");
|
||||||
|
|
||||||
// 🔽 [수정] 크래시 방지
|
// 🔽 [수정] 크래시 방지
|
||||||
if (sourceIndex < 0 || sourceIndex >= _currentState.tableau.length) {
|
if (sourceIndex < 0 || sourceIndex >= _currentState.tableau.length) {
|
||||||
debugPrint("[LOG] finalizeStackCompletion: FAILED. Invalid Source Index: $sourceIndex");
|
debugPrint(
|
||||||
|
"[LOG] finalizeStackCompletion: FAILED. Invalid Source Index: $sourceIndex");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_currentState.foundation.add(cardsToAnimate);
|
_currentState.foundation.add(cardsToAnimate);
|
||||||
final pile = _currentState.tableau[sourceIndex];
|
final pile = _currentState.tableau[sourceIndex];
|
||||||
|
|
||||||
if (pile.length >= cardsToAnimate.length) {
|
if (pile.length >= cardsToAnimate.length) {
|
||||||
pile.removeRange(pile.length - cardsToAnimate.length, pile.length);
|
pile.removeRange(pile.length - cardsToAnimate.length, pile.length);
|
||||||
} else {
|
} else {
|
||||||
debugPrint("[LOG] finalizeStackCompletion: WARNING. Pile length was ${pile.length}, expected >= ${cardsToAnimate.length}.");
|
debugPrint(
|
||||||
|
"[LOG] finalizeStackCompletion: WARNING. Pile length was ${pile.length}, expected >= ${cardsToAnimate.length}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pile.isNotEmpty && !pile.last.isFaceUp) {
|
if (pile.isNotEmpty && !pile.last.isFaceUp) {
|
||||||
pile.last.isFaceUp = true;
|
pile.last.isFaceUp = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔽 [삭제] 인덱스 리셋 불필요 (지역 변수로 처리됨)
|
// 🔽 [삭제] 인덱스 리셋 불필요 (지역 변수로 처리됨)
|
||||||
// _animationSourcePileIndex = -1;
|
// _animationSourcePileIndex = -1;
|
||||||
// _animationTargetFoundationIndex = -1;
|
// _animationTargetFoundationIndex = -1;
|
||||||
|
|
||||||
_checkGameCompletion(); // 👈 [핵심] 게임 완료 검사
|
_checkGameCompletion(); // 👈 [핵심] 게임 완료 검사
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔽 [복원됨]
|
// 🔽 [복원됨]
|
||||||
void clearStackAnimationTrigger() {
|
void clearStackAnimationTrigger() {
|
||||||
debugPrint("[LOG] clearStackAnimationTrigger: CALLED. Clearing animation queue (Current count: ${_cardsToAnimateStack.length}).");
|
debugPrint(
|
||||||
|
"[LOG] clearStackAnimationTrigger: CALLED. Clearing animation queue (Current count: ${_cardsToAnimateStack.length}).");
|
||||||
_cardsToAnimateStack.clear();
|
_cardsToAnimateStack.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -348,7 +373,8 @@ class SpiderGameController with ChangeNotifier {
|
|||||||
if (_currentState.foundation.length == 8 && !_isGameCompleted) {
|
if (_currentState.foundation.length == 8 && !_isGameCompleted) {
|
||||||
_isGameCompleted = true;
|
_isGameCompleted = true;
|
||||||
stopTimer();
|
stopTimer();
|
||||||
debugPrint("게임 완료! 이동: ${_currentState.moves}, 시간: $_secondsElapsed");
|
debugPrint(
|
||||||
|
"게임 완료! 이동: ${_currentState.moves}, 시간: $_secondsElapsed");
|
||||||
notifyListeners(); // 👈 [수정] 게임이 '완료'되었을 때만 notify
|
notifyListeners(); // 👈 [수정] 게임이 '완료'되었을 때만 notify
|
||||||
} else if (!_isGameCompleted) {
|
} else if (!_isGameCompleted) {
|
||||||
// 🔽 [수정] 게임이 완료되지 '않았을' 때도 notify (카드 이동 등을 반영하기 위해)
|
// 🔽 [수정] 게임이 완료되지 '않았을' 때도 notify (카드 이동 등을 반영하기 위해)
|
||||||
@ -357,42 +383,21 @@ class SpiderGameController with ChangeNotifier {
|
|||||||
// (게임이 완료된 후에는 더 이상 notify하지 않음)
|
// (게임이 완료된 후에는 더 이상 notify하지 않음)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ( _saveUndoState, _findPileIndexForCard, submitRank, dispose 는 동일 )
|
// ( _saveUndoState, _findPileIndexForCard 는 동일 )
|
||||||
void _saveUndoState() {
|
void _saveUndoState() {
|
||||||
_undoHistory.add(SpiderGameHistory.fromState(_currentState));
|
_undoHistory.add(SpiderGameHistory.fromState(_currentState));
|
||||||
if (_undoHistory.length > 20) {
|
if (_undoHistory.length > 20) {
|
||||||
_undoHistory.removeAt(0);
|
_undoHistory.removeAt(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
int _findPileIndexForCard(SpiderCard card) {
|
int _findPileIndexForCard(SpiderCard card) {
|
||||||
return _currentState.tableau.indexWhere((pile) => pile.contains(card));
|
return _currentState.tableau.indexWhere((pile) => pile.contains(card));
|
||||||
}
|
}
|
||||||
Future<RankSubmissionResult> submitRank(String playerName) async {
|
|
||||||
final puzzleService = PuzzleService();
|
// ❌ [삭제] submitRank 메서드 (약 20줄) 삭제
|
||||||
final identityService = IdentityService();
|
// Future<RankSubmissionResult> submitRank(String playerName) async { ... }
|
||||||
final rankDto = UnifiedRankDto(
|
|
||||||
userId: userId,
|
|
||||||
gameType: 'SPIDER',
|
|
||||||
contextId: difficulty.contextId,
|
|
||||||
playerName: playerName,
|
|
||||||
primaryScore: _currentState.moves,
|
|
||||||
secondaryScore: _secondsElapsed,
|
|
||||||
);
|
|
||||||
final result = await puzzleService.submitRank(rankDto);
|
|
||||||
await identityService.saveUserName(playerName);
|
|
||||||
final int currentMaxLevel = await identityService.getMaxUnlockedLevel(gameType: 'SPIDER');
|
|
||||||
if (currentMaxLevel < 99) {
|
|
||||||
if (difficulty.levelIndex >= currentMaxLevel) {
|
|
||||||
int nextLevel = difficulty.levelIndex + 1;
|
|
||||||
if (nextLevel > SpiderDifficulties.allDifficulties.length) {
|
|
||||||
await identityService.saveMaxUnlockedLevel(99, gameType: 'SPIDER');
|
|
||||||
} else {
|
|
||||||
await identityService.saveMaxUnlockedLevel(nextLevel, gameType: 'SPIDER');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import 'dart:math';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:service_api/service_api.dart';
|
import 'package:service_api/service_api.dart';
|
||||||
import 'package:feature_common/feature_common.dart';
|
import 'package:feature_common/feature_common.dart';
|
||||||
import '../controllers/spider_game_controller.dart';
|
import '../controllers/spider_game_controller.dart';
|
||||||
import '../models/spider_difficulty.dart';
|
import '../models/spider_difficulty.dart';
|
||||||
import '../models/spider_card.dart';
|
import '../models/spider_card.dart';
|
||||||
|
|||||||
@ -2,146 +2,147 @@
|
|||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:service_api/service_api.dart'; // 👈 SessionNotifier 포함
|
import 'package:service_api/service_api.dart';
|
||||||
import 'package:feature_common/feature_common.dart';
|
import 'package:feature_common/feature_common.dart';
|
||||||
import 'spider_game_screen.dart';
|
import 'spider_game_screen.dart';
|
||||||
import '../models/spider_difficulty.dart';
|
import '../models/spider_difficulty.dart';
|
||||||
import '../controllers/spider_game_controller.dart';
|
import '../controllers/spider_game_controller.dart';
|
||||||
|
|
||||||
class SpiderLobbyScreen extends StatefulWidget {
|
class SpiderLobbyScreen extends StatefulWidget {
|
||||||
const SpiderLobbyScreen({ super.key });
|
const SpiderLobbyScreen({super.key});
|
||||||
@override
|
@override
|
||||||
State<SpiderLobbyScreen> createState() => _SpiderLobbyScreenState();
|
State<SpiderLobbyScreen> createState() => _SpiderLobbyScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SpiderLobbyScreenState extends State<SpiderLobbyScreen> {
|
class _SpiderLobbyScreenState extends State<SpiderLobbyScreen> {
|
||||||
int _maxUnlockedLevel = 1;
|
int _maxUnlockedLevel = 1;
|
||||||
Map<int, (int, int)> _rankHistory = {};
|
Map<int, (int, int)> _rankHistory = {};
|
||||||
// ❌ String? _userName; (SessionNotifier가 관리)
|
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
|
|
||||||
// 🔽 [수정] SessionNotifier를 사용하기 위해 IdentityService 대신 추가
|
|
||||||
late final SessionNotifier _sessionNotifier;
|
late final SessionNotifier _sessionNotifier;
|
||||||
|
late final LobbyHelperService _lobbyHelper;
|
||||||
|
// [🔥 수정] 서비스를 직접 생성 (Provider로 읽지 않음)
|
||||||
final PuzzleService _puzzleService = PuzzleService();
|
final PuzzleService _puzzleService = PuzzleService();
|
||||||
final IdentityService _identityService = IdentityService(); // 👈 (save/load progress를 위해 유지)
|
final IdentityService _identityService = IdentityService();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// 🔽 [수정] initState에서 SessionNotifier를 read
|
|
||||||
// SessionNotifier의 loadSession()이 먼저 완료되었다고 가정
|
|
||||||
_sessionNotifier = context.read<SessionNotifier>();
|
_sessionNotifier = context.read<SessionNotifier>();
|
||||||
|
|
||||||
// 🔽 [수정] _loadProgress가 랭킹까지 모두 새로고침 (최초 1회)
|
// [🔥 수정] 헬퍼 서비스 초기화 (직접 생성한 서비스 주입)
|
||||||
|
_lobbyHelper = LobbyHelperService(
|
||||||
|
identityService: _identityService,
|
||||||
|
puzzleService: _puzzleService,
|
||||||
|
);
|
||||||
|
|
||||||
_loadProgress(forceRefreshRanks: true);
|
_loadProgress(forceRefreshRanks: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 🔽 [수정] _loadProgress 메서드 (SessionNotifier 사용 및 로직 분리)
|
/// [수정됨] 공통 헬퍼를 사용
|
||||||
Future<void> _loadProgress({bool forceRefreshRanks = false}) async {
|
Future<void> _loadProgress({bool forceRefreshRanks = false}) async {
|
||||||
// 1. (가벼움) 레벨 정보 새로고침
|
// 1. (가벼움) 레벨 정보 새로고침
|
||||||
final maxLevel = await _identityService.getMaxUnlockedLevel(gameType: 'SPIDER');
|
final maxLevel = await _lobbyHelper.loadMaxLevel('SPIDER');
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_maxUnlockedLevel = maxLevel;
|
_maxUnlockedLevel = maxLevel;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. (무거움) 랭킹 정보 새로고침 (필요할 때만)
|
// 2. (무거움) 랭킹 정보 새로고침 (필요할 때만)
|
||||||
if (!forceRefreshRanks) return;
|
if (!forceRefreshRanks) return;
|
||||||
|
|
||||||
// 🔽 [수정] _sessionNotifier에서 유저 이름을 가져옴
|
|
||||||
final String? myName = _sessionNotifier.session?.userName;
|
final String? myName = _sessionNotifier.session?.userName;
|
||||||
if (myName == null) return; // 게스트이거나 아직 이름 저장을 안 함
|
if (myName == null) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final Map<int, int> oldRankMap = await _identityService.getLastSavedRankMap(gameType: 'SPIDER');
|
final rankHistory = await _lobbyHelper.loadRankHistory<SpiderDifficulty>(
|
||||||
List<Future<List<GameRankDto>>> rankFutures = [];
|
gameType: 'SPIDER',
|
||||||
for (final level in SpiderDifficulties.allDifficulties) {
|
myName: myName,
|
||||||
rankFutures.add(_puzzleService.fetchRanks('SPIDER', level.contextId));
|
allLevels: SpiderDifficulties.allDifficulties,
|
||||||
|
getLevelIndex: (level) => level.levelIndex,
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_rankHistory = rankHistory;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
final List<List<GameRankDto>> allRankResults = await Future.wait(rankFutures);
|
|
||||||
Map<int, int> newRankMapForStorage = {};
|
|
||||||
Map<int, (int, int)> newRankHistoryForState = {};
|
|
||||||
for (int i = 0; i < SpiderDifficulties.allDifficulties.length; i++) {
|
|
||||||
final level = SpiderDifficulties.allDifficulties[i];
|
|
||||||
final currentRanks = allRankResults[i];
|
|
||||||
final int levelIndex = level.levelIndex;
|
|
||||||
final int oldRank = oldRankMap[levelIndex] ?? 0;
|
|
||||||
int currentRank = 0;
|
|
||||||
int myRankIndex = currentRanks.indexWhere((r) => r.playerName == myName);
|
|
||||||
if (myRankIndex != -1) { currentRank = myRankIndex + 1; }
|
|
||||||
newRankMapForStorage[levelIndex] = currentRank;
|
|
||||||
newRankHistoryForState[levelIndex] = (oldRank, currentRank);
|
|
||||||
}
|
|
||||||
await _identityService.saveLastRankMap(newRankMapForStorage, gameType: 'SPIDER');
|
|
||||||
if (mounted) { setState(() { _rankHistory = newRankHistoryForState; }); }
|
|
||||||
log("스파이더 랭킹 변동 확인 완료. (유저: $myName)");
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log("SpiderLobbyScreen: 랭킹 확인 실패: $e");
|
log("SpiderLobbyScreen: 랭킹 확인 실패: $e");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 🔽 [수정 없음] _startGame 메서드
|
||||||
/// 🔽 [수정] _startGame 메서드 (SessionNotifier 사용)
|
|
||||||
Future<void> _startGame(SpiderDifficulty level) async {
|
Future<void> _startGame(SpiderDifficulty level) async {
|
||||||
setState(() { _isLoading = true; });
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
});
|
||||||
|
|
||||||
// 1. [수정] SessionNotifier에서 유저 정보 가져오기
|
|
||||||
final session = _sessionNotifier.session;
|
final session = _sessionNotifier.session;
|
||||||
if (session == null) {
|
if (session == null) {
|
||||||
log("세션이 로드되지 않아 게임을 시작할 수 없습니다.");
|
log("세션이 로드되지 않아 게임을 시작할 수 없습니다.");
|
||||||
setState(() { _isLoading = false; });
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final String userId = session.userId;
|
final String userId = session.userId;
|
||||||
final String? userName = session.userName;
|
final String? userName = session.userName;
|
||||||
|
|
||||||
// 2. 컨트롤러 생성 및 새 게임 시작
|
|
||||||
final gameController = SpiderGameController();
|
final gameController = SpiderGameController();
|
||||||
gameController.setUserInfo(userId, userName); // 👈 유저 정보 주입
|
gameController.setUserInfo(userId, userName);
|
||||||
gameController.startNewGame(level);
|
gameController.startNewGame(level);
|
||||||
|
|
||||||
setState(() { _isLoading = false; });
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
await Navigator.push(
|
await Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) =>
|
builder: (context) => ChangeNotifierProvider.value(
|
||||||
ChangeNotifierProvider.value(
|
value: gameController,
|
||||||
value: gameController,
|
child: const SpiderGameScreen(),
|
||||||
child: const SpiderGameScreen(),
|
),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 🔽 [핵심 수정]
|
|
||||||
// 랭킹(forceRefreshRanks: true)은 새로고침하지 않고,
|
|
||||||
// 레벨 잠금 상태(forceRefreshRanks: false)만 새로고침합니다.
|
|
||||||
_loadProgress(forceRefreshRanks: false);
|
_loadProgress(forceRefreshRanks: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// 🔽 [수정] ThemeNotifier와 SessionNotifier를 모두 watch
|
|
||||||
context.watch<ThemeNotifier>();
|
context.watch<ThemeNotifier>();
|
||||||
context.watch<SessionNotifier>(); // 👈 세션 변경(로그인/로그아웃) 감지
|
context.watch<SessionNotifier>();
|
||||||
|
|
||||||
final bool allLevelsUnlocked = _maxUnlockedLevel >= 9;
|
final bool allLevelsUnlocked =
|
||||||
|
_maxUnlockedLevel >= SpiderDifficulties.allDifficulties.length;
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
return CommonGameShell(
|
return CommonGameShell(
|
||||||
title: '스파이더 솔리테어',
|
title: '스파이더 솔리테어',
|
||||||
onRankingPressed: () {
|
onRankingPressed: () {
|
||||||
// ... (랭킹 버튼 로직 동일)
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => RankingScreen(
|
||||||
|
gameType: 'SPIDER',
|
||||||
|
difficulties: SpiderDifficulties.allDifficulties,
|
||||||
|
initialDifficultyName:
|
||||||
|
SpiderDifficulties.getLevel(_maxUnlockedLevel).name,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
// 🔽 [수정] RefreshIndicator 추가 (당겨서 랭킹 새로고침)
|
|
||||||
body: LayoutBuilder(
|
body: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
const double maxContentRatio = 0.6;
|
const double maxContentRatio = 0.6;
|
||||||
final double constrainedWidth = (constraints.maxHeight * maxContentRatio) > 500
|
final double constrainedWidth =
|
||||||
? 500 : (constraints.maxHeight * maxContentRatio);
|
(constraints.maxHeight * maxContentRatio) > 500
|
||||||
|
? 500
|
||||||
|
: (constraints.maxHeight * maxContentRatio);
|
||||||
return Center(
|
return Center(
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: BoxConstraints(maxWidth: constrainedWidth),
|
constraints: BoxConstraints(maxWidth: constrainedWidth),
|
||||||
@ -153,11 +154,15 @@ class _SpiderLobbyScreenState extends State<SpiderLobbyScreen> {
|
|||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
itemCount: SpiderDifficulties.allDifficulties.length,
|
itemCount: SpiderDifficulties.allDifficulties.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
// ... (이하 ListTile 로직은 모두 동일)
|
final SpiderDifficulty level =
|
||||||
final SpiderDifficulty level = SpiderDifficulties.allDifficulties[index];
|
SpiderDifficulties.allDifficulties[index];
|
||||||
final bool isUnlocked = allLevelsUnlocked || level.levelIndex <= _maxUnlockedLevel;
|
final bool isUnlocked = allLevelsUnlocked ||
|
||||||
final (int oldRank, int currentRank) = _rankHistory[level.levelIndex] ?? (0, 0);
|
level.levelIndex <= _maxUnlockedLevel;
|
||||||
Widget? trailingWidget = isUnlocked ? const Icon(Icons.play_arrow_rounded) : null;
|
final (int oldRank, int currentRank) =
|
||||||
|
_rankHistory[level.levelIndex] ?? (0, 0);
|
||||||
|
Widget? trailingWidget = isUnlocked
|
||||||
|
? const Icon(Icons.play_arrow_rounded)
|
||||||
|
: null;
|
||||||
String? subtitleText;
|
String? subtitleText;
|
||||||
Color? subtitleColor;
|
Color? subtitleColor;
|
||||||
if (currentRank > 0) {
|
if (currentRank > 0) {
|
||||||
@ -167,44 +172,70 @@ class _SpiderLobbyScreenState extends State<SpiderLobbyScreen> {
|
|||||||
if (change > 0) {
|
if (change > 0) {
|
||||||
subtitleText = "$rankStr (▲ $change)";
|
subtitleText = "$rankStr (▲ $change)";
|
||||||
subtitleColor = Colors.green;
|
subtitleColor = Colors.green;
|
||||||
trailingWidget = const Icon(Icons.arrow_circle_up_rounded, color: Colors.green, size: 28);
|
trailingWidget = const Icon(
|
||||||
|
Icons.arrow_circle_up_rounded,
|
||||||
|
color: Colors.green,
|
||||||
|
size: 28);
|
||||||
} else if (change < 0) {
|
} else if (change < 0) {
|
||||||
subtitleText = "$rankStr (▼ ${change.abs()})";
|
subtitleText = "$rankStr (▼ ${change.abs()})";
|
||||||
subtitleColor = Colors.red;
|
subtitleColor = Colors.red;
|
||||||
trailingWidget = const Icon(Icons.arrow_circle_down_rounded, color: Colors.red, size: 28);
|
trailingWidget = const Icon(
|
||||||
|
Icons.arrow_circle_down_rounded,
|
||||||
|
color: Colors.red,
|
||||||
|
size: 28);
|
||||||
} else {
|
} else {
|
||||||
subtitleText = "$rankStr (유지)";
|
subtitleText = "$rankStr (유지)";
|
||||||
subtitleColor = Colors.grey;
|
subtitleColor = Colors.grey;
|
||||||
trailingWidget = const Icon(Icons.check_circle_outline_rounded, color: Colors.grey, size: 28);
|
trailingWidget = const Icon(
|
||||||
|
Icons.check_circle_outline_rounded,
|
||||||
|
color: Colors.grey,
|
||||||
|
size: 28);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
subtitleText = "$rankStr (신규 진입)";
|
subtitleText = "$rankStr (신규 진입)";
|
||||||
subtitleColor = Colors.blue;
|
subtitleColor = Colors.blue;
|
||||||
trailingWidget = const Icon(Icons.new_releases_rounded, color: Colors.blue, size: 28);
|
trailingWidget = const Icon(
|
||||||
|
Icons.new_releases_rounded,
|
||||||
|
color: Colors.blue,
|
||||||
|
size: 28);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (oldRank > 0) {
|
if (oldRank > 0) {
|
||||||
subtitleText = "랭킹 이탈 (이전 ${oldRank}위)";
|
subtitleText = "랭킹 이탈 (이전 ${oldRank}위)";
|
||||||
subtitleColor = Colors.orange;
|
subtitleColor = Colors.orange;
|
||||||
trailingWidget = const Icon(Icons.warning_amber_rounded, color: Colors.orange, size: 28);
|
trailingWidget = const Icon(
|
||||||
|
Icons.warning_amber_rounded,
|
||||||
|
color: Colors.orange,
|
||||||
|
size: 28);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Card(
|
return Card(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0),
|
margin: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16.0, vertical: 4.0),
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: Icon(
|
leading: Icon(
|
||||||
isUnlocked ? Icons.lock_open_rounded : Icons.lock_rounded,
|
isUnlocked
|
||||||
|
? Icons.lock_open_rounded
|
||||||
|
: Icons.lock_rounded,
|
||||||
color: isUnlocked ? theme.primaryColor : Colors.grey,
|
color: isUnlocked ? theme.primaryColor : Colors.grey,
|
||||||
),
|
),
|
||||||
title: Text(level.name, style: TextStyle(
|
title: Text(level.name,
|
||||||
fontSize: 18,
|
style: TextStyle(
|
||||||
fontWeight: isUnlocked ? FontWeight.bold : FontWeight.normal,
|
fontSize: 18,
|
||||||
color: isUnlocked ? theme.textTheme.bodyLarge?.color : Colors.grey,
|
fontWeight: isUnlocked
|
||||||
)),
|
? FontWeight.bold
|
||||||
|
: FontWeight.normal,
|
||||||
|
color: isUnlocked
|
||||||
|
? theme.textTheme.bodyLarge?.color
|
||||||
|
: Colors.grey,
|
||||||
|
)),
|
||||||
subtitle: subtitleText != null
|
subtitle: subtitleText != null
|
||||||
? Text(subtitleText, style: TextStyle(color: subtitleColor, fontWeight: FontWeight.bold))
|
? Text(subtitleText,
|
||||||
: null,
|
style: TextStyle(
|
||||||
trailing: trailingWidget,
|
color: subtitleColor,
|
||||||
|
fontWeight: FontWeight.bold))
|
||||||
|
: null,
|
||||||
|
trailing: trailingWidget,
|
||||||
onTap: isUnlocked && !_isLoading
|
onTap: isUnlocked && !_isLoading
|
||||||
? () => _startGame(level)
|
? () => _startGame(level)
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
@ -1,22 +1,27 @@
|
|||||||
// packages/feature_game_sudoku/lib/models/game_level.dart
|
// packages/feature_game_sudoku/lib/models/game_level.dart
|
||||||
// (이 파일은 service_api에서 이동해 옴)
|
import 'package:service_api/service_api.dart'; // 👈 [추가] 공통 모델 import
|
||||||
|
|
||||||
class GameLevel {
|
//
|
||||||
|
// [🔥 수정] 'extends GameDifficulty' 추가
|
||||||
|
//
|
||||||
|
class GameLevel extends GameDifficulty {
|
||||||
final int levelIndex; // 1-11
|
final int levelIndex; // 1-11
|
||||||
final String name; // "입문 (4x4)"
|
|
||||||
final int blockSize; // 2, 3, 4
|
final int blockSize; // 2, 3, 4
|
||||||
final int generatorLevel; // 서버에 요청할 생성기 난이도 (1~5)
|
final int generatorLevel; // 서버에 요청할 생성기 난이도 (1~5)
|
||||||
final String contextId; // 랭킹 ID "SUDOKU_4x4_L1"
|
|
||||||
|
// ❌ 'name'과 'contextId'는 GameDifficulty가 이미 가지고 있으므로 제거
|
||||||
|
// final String name;
|
||||||
|
// final String contextId;
|
||||||
|
|
||||||
final bool isSequentialNumbers;
|
final bool isSequentialNumbers;
|
||||||
final bool isSequentialLetters;
|
final bool isSequentialLetters;
|
||||||
|
|
||||||
const GameLevel({
|
const GameLevel({
|
||||||
required this.levelIndex,
|
required this.levelIndex,
|
||||||
required this.name,
|
required super.name, // 👈 [수정] super()로 전달
|
||||||
|
required super.contextId, // 👈 [수정] super()로 전달
|
||||||
required this.blockSize,
|
required this.blockSize,
|
||||||
required this.generatorLevel,
|
required this.generatorLevel,
|
||||||
required this.contextId,
|
|
||||||
this.isSequentialNumbers = false,
|
this.isSequentialNumbers = false,
|
||||||
this.isSequentialLetters = false,
|
this.isSequentialLetters = false,
|
||||||
});
|
});
|
||||||
@ -26,64 +31,91 @@ class AppLevels {
|
|||||||
static final List<GameLevel> allLevels = [
|
static final List<GameLevel> allLevels = [
|
||||||
// --- 2x2 (blockSize = 2) ---
|
// --- 2x2 (blockSize = 2) ---
|
||||||
const GameLevel(
|
const GameLevel(
|
||||||
levelIndex: 1, name: "입문 (4x4)", blockSize: 2, generatorLevel: 1,
|
levelIndex: 1,
|
||||||
contextId: "SUDOKU_4x4_L1", isSequentialNumbers: true
|
name: "입문 (4x4)",
|
||||||
),
|
blockSize: 2,
|
||||||
|
generatorLevel: 1,
|
||||||
|
contextId: "SUDOKU_4x4_L1",
|
||||||
|
isSequentialNumbers: true),
|
||||||
const GameLevel(
|
const GameLevel(
|
||||||
levelIndex: 2, name: "초급 (4x4)", blockSize: 2, generatorLevel: 3,
|
levelIndex: 2,
|
||||||
contextId: "SUDOKU_4x4_L3", isSequentialLetters: true
|
name: "초급 (4x4)",
|
||||||
),
|
blockSize: 2,
|
||||||
|
generatorLevel: 3,
|
||||||
|
contextId: "SUDOKU_4x4_L3",
|
||||||
|
isSequentialLetters: true),
|
||||||
const GameLevel(
|
const GameLevel(
|
||||||
levelIndex: 3, name: "숙련 (4x4)", blockSize: 2, generatorLevel: 5,
|
levelIndex: 3,
|
||||||
contextId: "SUDOKU_4x4_L5"
|
name: "숙련 (4x4)",
|
||||||
),
|
blockSize: 2,
|
||||||
|
generatorLevel: 5,
|
||||||
|
contextId: "SUDOKU_4x4_L5"),
|
||||||
|
|
||||||
// --- 3x3 (blockSize = 3) ---
|
// --- 3x3 (blockSize = 3) ---
|
||||||
const GameLevel(
|
const GameLevel(
|
||||||
levelIndex: 4, name: "쉬움 (9x9)", blockSize: 3, generatorLevel: 1,
|
levelIndex: 4,
|
||||||
contextId: "SUDOKU_9x9_L1", isSequentialNumbers: true
|
name: "쉬움 (9x9)",
|
||||||
),
|
blockSize: 3,
|
||||||
|
generatorLevel: 1,
|
||||||
|
contextId: "SUDOKU_9x9_L1",
|
||||||
|
isSequentialNumbers: true),
|
||||||
const GameLevel(
|
const GameLevel(
|
||||||
levelIndex: 5, name: "중급 (9x9)", blockSize: 3, generatorLevel: 2,
|
levelIndex: 5,
|
||||||
contextId: "SUDOKU_9x9_L2", isSequentialLetters: true
|
name: "중급 (9x9)",
|
||||||
),
|
blockSize: 3,
|
||||||
|
generatorLevel: 2,
|
||||||
|
contextId: "SUDOKU_9x9_L2",
|
||||||
|
isSequentialLetters: true),
|
||||||
const GameLevel(
|
const GameLevel(
|
||||||
levelIndex: 6, name: "상급 (9x9)", blockSize: 3, generatorLevel: 3,
|
levelIndex: 6,
|
||||||
contextId: "SUDOKU_9x9_L3"
|
name: "상급 (9x9)",
|
||||||
),
|
blockSize: 3,
|
||||||
|
generatorLevel: 3,
|
||||||
|
contextId: "SUDOKU_9x9_L3"),
|
||||||
const GameLevel(
|
const GameLevel(
|
||||||
levelIndex: 7, name: "어려움 (9x9)", blockSize: 3, generatorLevel: 4,
|
levelIndex: 7,
|
||||||
contextId: "SUDOKU_9x9_L4"
|
name: "어려움 (9x9)",
|
||||||
),
|
blockSize: 3,
|
||||||
|
generatorLevel: 4,
|
||||||
|
contextId: "SUDOKU_9x9_L4"),
|
||||||
const GameLevel(
|
const GameLevel(
|
||||||
levelIndex: 8, name: "최상급 (9x9)", blockSize: 3, generatorLevel: 5,
|
levelIndex: 8,
|
||||||
contextId: "SUDOKU_9x9_L5"
|
name: "최상급 (9x9)",
|
||||||
),
|
blockSize: 3,
|
||||||
|
generatorLevel: 5,
|
||||||
|
contextId: "SUDOKU_9x9_L5"),
|
||||||
|
|
||||||
// --- 4x4 (blockSize = 4) ---
|
// --- 4x4 (blockSize = 4) ---
|
||||||
const GameLevel(
|
const GameLevel(
|
||||||
levelIndex: 9, name: "전문가 (16x16)", blockSize: 4, generatorLevel: 1,
|
levelIndex: 9,
|
||||||
contextId: "SUDOKU_16x16_L1", isSequentialNumbers: true
|
name: "전문가 (16x16)",
|
||||||
),
|
blockSize: 4,
|
||||||
|
generatorLevel: 1,
|
||||||
|
contextId: "SUDOKU_16x16_L1",
|
||||||
|
isSequentialNumbers: true),
|
||||||
const GameLevel(
|
const GameLevel(
|
||||||
levelIndex: 10, name: "마스터 (16x16)", blockSize: 4, generatorLevel: 3,
|
levelIndex: 10,
|
||||||
contextId: "SUDOKU_16x16_L3", isSequentialLetters: true
|
name: "마스터 (16x16)",
|
||||||
),
|
blockSize: 4,
|
||||||
|
generatorLevel: 3,
|
||||||
|
contextId: "SUDOKU_16x16_L3",
|
||||||
|
isSequentialLetters: true),
|
||||||
const GameLevel(
|
const GameLevel(
|
||||||
levelIndex: 11, name: "지옥 (16x16)", blockSize: 4, generatorLevel: 5,
|
levelIndex: 11,
|
||||||
contextId: "SUDOKU_16x16_L5"
|
name: "지옥 (16x16)",
|
||||||
),
|
blockSize: 4,
|
||||||
|
generatorLevel: 5,
|
||||||
|
contextId: "SUDOKU_16x16_L5"),
|
||||||
];
|
];
|
||||||
|
|
||||||
static GameLevel getLevel(int levelIndex) {
|
static GameLevel getLevel(int levelIndex) {
|
||||||
if (levelIndex < 1) levelIndex = 1;
|
if (levelIndex < 1) levelIndex = 1;
|
||||||
if (levelIndex > allLevels.length) levelIndex = allLevels.length;
|
if (levelIndex > allLevels.length) levelIndex = allLevels.length;
|
||||||
return allLevels.firstWhere((level) => level.levelIndex == levelIndex,
|
return allLevels.firstWhere((level) => level.levelIndex == levelIndex,
|
||||||
orElse: () => allLevels[0]
|
orElse: () => allLevels[0]);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static Map<String, String> get contextIdToNameMap {
|
static Map<String, String> get contextIdToNameMap {
|
||||||
return { for (var level in allLevels) level.contextId : level.name };
|
return {for (var level in allLevels) level.contextId: level.name};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -6,105 +6,102 @@ import 'package:provider/provider.dart';
|
|||||||
// [C] 서비스 import
|
// [C] 서비스 import
|
||||||
import 'package:service_api/service_api.dart';
|
import 'package:service_api/service_api.dart';
|
||||||
// [A] 공통 셸(Shell) 위젯 import
|
// [A] 공통 셸(Shell) 위젯 import
|
||||||
import 'package:feature_common/feature_common.dart';
|
import 'package:feature_common/feature_common.dart';
|
||||||
// [B] 같은 패키지 내의 화면/모델 import
|
// [B] 같은 패키지 내의 화면/모델 import
|
||||||
import 'game_screen.dart';
|
import 'game_screen.dart';
|
||||||
import '../models/game_level.dart'; // 👈 스도쿠 전용 레벨
|
import '../models/game_level.dart';
|
||||||
|
|
||||||
class SudokuLobbyScreen extends StatefulWidget {
|
class SudokuLobbyScreen extends StatefulWidget {
|
||||||
const SudokuLobbyScreen({ super.key });
|
const SudokuLobbyScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<SudokuLobbyScreen> createState() => _SudokuLobbyScreenState();
|
State<SudokuLobbyScreen> createState() => _SudokuLobbyScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SudokuLobbyScreenState extends State<SudokuLobbyScreen> {
|
class _SudokuLobbyScreenState extends State<SudokuLobbyScreen> {
|
||||||
int _maxUnlockedLevel = 1;
|
int _maxUnlockedLevel = 1;
|
||||||
Map<int, (int, int)> _rankHistory = {};
|
Map<int, (int, int)> _rankHistory = {};
|
||||||
// ❌ String? _userName; (SessionNotifier가 관리)
|
|
||||||
late String _selectedThemeName;
|
late String _selectedThemeName;
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
|
|
||||||
// 🔽 [수정] SessionNotifier를 사용하기 위해 IdentityService 대신 추가
|
|
||||||
late final SessionNotifier _sessionNotifier;
|
late final SessionNotifier _sessionNotifier;
|
||||||
|
late final LobbyHelperService _lobbyHelper;
|
||||||
|
// [🔥 수정] 서비스를 직접 생성 (Provider로 읽지 않음)
|
||||||
final PuzzleService _puzzleService = PuzzleService();
|
final PuzzleService _puzzleService = PuzzleService();
|
||||||
final IdentityService _identityService = IdentityService(); // 👈 (save/load progress를 위해 유지)
|
final IdentityService _identityService = IdentityService();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_selectedThemeName = AppThemes.random;
|
_selectedThemeName = AppThemes.random;
|
||||||
|
|
||||||
// 🔽 [수정] initState에서 SessionNotifier를 read
|
|
||||||
_sessionNotifier = context.read<SessionNotifier>();
|
_sessionNotifier = context.read<SessionNotifier>();
|
||||||
|
|
||||||
// 🔽 [수정] _loadProgress가 랭킹까지 모두 새로고침 (최초 1회)
|
// [🔥 수정] 헬퍼 서비스 초기화 (직접 생성한 서비스 주입)
|
||||||
|
_lobbyHelper = LobbyHelperService(
|
||||||
|
identityService: _identityService,
|
||||||
|
puzzleService: _puzzleService,
|
||||||
|
);
|
||||||
|
|
||||||
_loadProgress(forceRefreshRanks: true);
|
_loadProgress(forceRefreshRanks: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 🔽 [수정] _loadProgress 메서드 (SessionNotifier 사용 및 로직 분리)
|
/// [수정됨] 공통 헬퍼를 사용
|
||||||
Future<void> _loadProgress({bool forceRefreshRanks = false}) async {
|
Future<void> _loadProgress({bool forceRefreshRanks = false}) async {
|
||||||
// 1. (가벼움) 레벨 정보 새로고침
|
// 1. (가벼움) 레벨 정보 새로고침
|
||||||
final maxLevel = await _identityService.getMaxUnlockedLevel();
|
final maxLevel = await _lobbyHelper.loadMaxLevel('SUDOKU');
|
||||||
if (mounted) { setState(() { _maxUnlockedLevel = maxLevel; }); }
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_maxUnlockedLevel = maxLevel;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 2. (무거움) 랭킹 정보 새로고침 (필요할 때만)
|
// 2. (무거움) 랭킹 정보 새로고침 (필요할 때만)
|
||||||
if (!forceRefreshRanks) return;
|
if (!forceRefreshRanks) return;
|
||||||
|
|
||||||
// 🔽 [수정] _sessionNotifier에서 유저 이름을 가져옴
|
|
||||||
final String? myName = _sessionNotifier.session?.userName;
|
final String? myName = _sessionNotifier.session?.userName;
|
||||||
if (myName == null) return; // 게스트이거나 아직 이름 저장을 안 함
|
if (myName == null) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final Map<int, int> oldRankMap = await _identityService.getLastSavedRankMap();
|
final rankHistory = await _lobbyHelper.loadRankHistory<GameLevel>(
|
||||||
List<Future<List<GameRankDto>>> rankFutures = [];
|
gameType: 'SUDOKU',
|
||||||
for (final level in AppLevels.allLevels) {
|
myName: myName,
|
||||||
rankFutures.add(_puzzleService.fetchRanks('SUDOKU', level.contextId));
|
allLevels: AppLevels.allLevels,
|
||||||
|
getLevelIndex: (level) => level.levelIndex,
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_rankHistory = rankHistory;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
final List<List<GameRankDto>> allRankResults = await Future.wait(rankFutures);
|
|
||||||
Map<int, int> newRankMapForStorage = {};
|
|
||||||
Map<int, (int, int)> newRankHistoryForState = {};
|
|
||||||
for (int i = 0; i < AppLevels.allLevels.length; i++) {
|
|
||||||
final level = AppLevels.allLevels[i];
|
|
||||||
final currentRanks = allRankResults[i];
|
|
||||||
final int levelIndex = level.levelIndex;
|
|
||||||
final int oldRank = oldRankMap[levelIndex] ?? 0;
|
|
||||||
int currentRank = 0;
|
|
||||||
int myRankIndex = currentRanks.indexWhere((r) => r.playerName == myName);
|
|
||||||
if (myRankIndex != -1) { currentRank = myRankIndex + 1; }
|
|
||||||
newRankMapForStorage[levelIndex] = currentRank;
|
|
||||||
newRankHistoryForState[levelIndex] = (oldRank, currentRank);
|
|
||||||
}
|
|
||||||
await _identityService.saveLastRankMap(newRankMapForStorage);
|
|
||||||
if (mounted) { setState(() { _rankHistory = newRankHistoryForState; }); }
|
|
||||||
log("모든 레벨 랭킹 변동 확인 완료. (유저: $myName)");
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log("SudokuLobbyScreen: 랭킹 확인 실패: $e");
|
log("SudokuLobbyScreen: 랭킹 확인 실패: $e");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 🔽 [수정] _startGame 메서드 (SessionNotifier 사용)
|
/// 🔽 [수정] _startGame 메서드 (PuzzleService 직접 사용)
|
||||||
Future<void> _startGame(GameLevel level) async {
|
Future<void> _startGame(GameLevel level) async {
|
||||||
setState(() { _isLoading = true; });
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. [수정] SessionNotifier에서 유저 정보 가져오기
|
|
||||||
final session = _sessionNotifier.session;
|
final session = _sessionNotifier.session;
|
||||||
if (session == null) {
|
if (session == null) {
|
||||||
throw Exception("세션이 로드되지 않았습니다.");
|
throw Exception("세션이 로드되지 않았습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
final String difficulty = level.levelIndex.toString();
|
final String difficulty = level.levelIndex.toString();
|
||||||
|
// [🔥 수정] _puzzleService 인스턴스 사용
|
||||||
final SudokuGameDto gameData = await _puzzleService.startGame(difficulty);
|
final SudokuGameDto gameData = await _puzzleService.startGame(difficulty);
|
||||||
|
|
||||||
final String userId = session.userId;
|
final String userId = session.userId;
|
||||||
final String? userName = session.userName;
|
final String? userName = session.userName;
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
await Navigator.push(
|
await Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => GameScreen(
|
builder: (context) => GameScreen(
|
||||||
gameData: gameData,
|
gameData: gameData,
|
||||||
themeName: _selectedThemeName,
|
themeName: _selectedThemeName,
|
||||||
userId: userId,
|
userId: userId,
|
||||||
@ -113,10 +110,7 @@ class _SudokuLobbyScreenState extends State<SudokuLobbyScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 🔽 [핵심 수정]
|
|
||||||
// 랭킹(forceRefreshRanks: true)은 새로고침하지 않고,
|
|
||||||
// 레벨 잠금 상태(forceRefreshRanks: false)만 새로고침합니다.
|
|
||||||
_loadProgress(forceRefreshRanks: false);
|
_loadProgress(forceRefreshRanks: false);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -127,99 +121,93 @@ class _SudokuLobbyScreenState extends State<SudokuLobbyScreen> {
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() { _isLoading = false; });
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// 🔽 [수정] ThemeNotifier와 SessionNotifier를 모두 watch
|
context.watch<ThemeNotifier>();
|
||||||
context.watch<ThemeNotifier>(); // 테마 감지
|
context.watch<SessionNotifier>();
|
||||||
context.watch<SessionNotifier>(); // 👈 세션 변경(로그인/로그아웃) 감지
|
|
||||||
|
|
||||||
final bool allLevelsUnlocked = _maxUnlockedLevel >= 99;
|
final bool allLevelsUnlocked = _maxUnlockedLevel >= 99;
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
// [A] feature_common의 CommonGameShell을 사용
|
|
||||||
return CommonGameShell(
|
return CommonGameShell(
|
||||||
title: '스도쿠 게임', // 셸의 AppBar에 표시될 제목
|
title: '스도쿠 게임',
|
||||||
|
|
||||||
// 🔽 [수정] 랭킹 버튼 클릭 시 실행될 함수를 주입
|
|
||||||
onRankingPressed: () {
|
onRankingPressed: () {
|
||||||
|
// [🔥 수정] GameLevel이 GameDifficulty를 상속하므로 변환(map) 불필요
|
||||||
// 1. 스도쿠 레벨(AppLevels)을 공통 모델(GameDifficulty)로 변환
|
|
||||||
final List<GameDifficulty> sudokuDifficulties = AppLevels.allLevels
|
|
||||||
.map((level) => GameDifficulty(
|
|
||||||
name: level.name,
|
|
||||||
contextId: level.contextId,
|
|
||||||
))
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
// 2. 공통 랭킹 화면(RankingScreen)에 주입하며 호출
|
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => RankingScreen(
|
builder: (context) => RankingScreen(
|
||||||
gameType: 'SUDOKU', // 👈 이 게임은 스도쿠
|
gameType: 'SUDOKU',
|
||||||
difficulties: sudokuDifficulties, // 👈 스도쿠 난이도 목록
|
difficulties: AppLevels.allLevels, // 👈 [수정]
|
||||||
initialDifficultyName: AppLevels.getLevel(_maxUnlockedLevel).name,
|
initialDifficultyName: AppLevels.getLevel(_maxUnlockedLevel).name,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
// 🔽 셸의 'body'에 스도쿠 레벨 목록을 전달
|
|
||||||
body: LayoutBuilder(
|
body: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
const double maxContentRatio = 0.6;
|
const double maxContentRatio = 0.6;
|
||||||
final double constrainedWidth = (constraints.maxHeight * maxContentRatio) > 500
|
final double constrainedWidth =
|
||||||
? 500 : (constraints.maxHeight * maxContentRatio);
|
(constraints.maxHeight * maxContentRatio) > 500
|
||||||
|
? 500
|
||||||
|
: (constraints.maxHeight * maxContentRatio);
|
||||||
return Center(
|
return Center(
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: BoxConstraints(maxWidth: constrainedWidth),
|
constraints: BoxConstraints(maxWidth: constrainedWidth),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// 테마 선택 Dropdown
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 10.0),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 20.0, vertical: 10.0),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
const Text("테마: ", style: TextStyle(fontSize: 18)),
|
const Text("테마: ", style: TextStyle(fontSize: 18)),
|
||||||
DropdownButton<String>(
|
DropdownButton<String>(
|
||||||
value: _selectedThemeName,
|
value: _selectedThemeName,
|
||||||
items: AppThemes.selectableThemeNames.map((themeName) {
|
items:
|
||||||
|
AppThemes.selectableThemeNames.map((themeName) {
|
||||||
return DropdownMenuItem<String>(
|
return DropdownMenuItem<String>(
|
||||||
value: themeName,
|
value: themeName,
|
||||||
child: Text(themeName, style: const TextStyle(fontSize: 20)),
|
child:
|
||||||
|
Text(themeName, style: const TextStyle(fontSize: 20)),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
onChanged: (themeName) {
|
onChanged: (themeName) {
|
||||||
if (themeName != null) {
|
if (themeName != null) {
|
||||||
setState(() { _selectedThemeName = themeName; });
|
setState(() {
|
||||||
|
_selectedThemeName = themeName;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// 레벨 목록 ListView
|
|
||||||
Expanded(
|
Expanded(
|
||||||
// 🔽 [수정] RefreshIndicator 추가 (당겨서 랭킹 새로고침)
|
|
||||||
child: RefreshIndicator(
|
child: RefreshIndicator(
|
||||||
onRefresh: () => _loadProgress(forceRefreshRanks: true),
|
onRefresh: () => _loadProgress(forceRefreshRanks: true),
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
itemCount: AppLevels.allLevels.length,
|
itemCount: AppLevels.allLevels.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
// ... (이하 ListTile 로직은 모두 동일)
|
|
||||||
final GameLevel level = AppLevels.allLevels[index];
|
final GameLevel level = AppLevels.allLevels[index];
|
||||||
final bool isUnlocked = allLevelsUnlocked || level.levelIndex <= _maxUnlockedLevel;
|
final bool isUnlocked = allLevelsUnlocked ||
|
||||||
final (int oldRank, int currentRank) = _rankHistory[level.levelIndex] ?? (0, 0);
|
level.levelIndex <= _maxUnlockedLevel;
|
||||||
Widget? trailingWidget = isUnlocked ? const Icon(Icons.play_arrow_rounded) : null;
|
final (int oldRank, int currentRank) =
|
||||||
|
_rankHistory[level.levelIndex] ?? (0, 0);
|
||||||
|
Widget? trailingWidget = isUnlocked
|
||||||
|
? const Icon(Icons.play_arrow_rounded)
|
||||||
|
: null;
|
||||||
String? subtitleText;
|
String? subtitleText;
|
||||||
Color? subtitleColor;
|
Color? subtitleColor;
|
||||||
|
|
||||||
if (currentRank > 0) {
|
if (currentRank > 0) {
|
||||||
String rankStr = "${currentRank}위";
|
String rankStr = "${currentRank}위";
|
||||||
if (oldRank > 0) {
|
if (oldRank > 0) {
|
||||||
@ -227,45 +215,71 @@ class _SudokuLobbyScreenState extends State<SudokuLobbyScreen> {
|
|||||||
if (change > 0) {
|
if (change > 0) {
|
||||||
subtitleText = "$rankStr (▲ $change)";
|
subtitleText = "$rankStr (▲ $change)";
|
||||||
subtitleColor = Colors.green;
|
subtitleColor = Colors.green;
|
||||||
trailingWidget = const Icon(Icons.arrow_circle_up_rounded, color: Colors.green, size: 28);
|
trailingWidget = const Icon(
|
||||||
|
Icons.arrow_circle_up_rounded,
|
||||||
|
color: Colors.green,
|
||||||
|
size: 28);
|
||||||
} else if (change < 0) {
|
} else if (change < 0) {
|
||||||
subtitleText = "$rankStr (▼ ${change.abs()})";
|
subtitleText = "$rankStr (▼ ${change.abs()})";
|
||||||
subtitleColor = Colors.red;
|
subtitleColor = Colors.red;
|
||||||
trailingWidget = const Icon(Icons.arrow_circle_down_rounded, color: Colors.red, size: 28);
|
trailingWidget = const Icon(
|
||||||
|
Icons.arrow_circle_down_rounded,
|
||||||
|
color: Colors.red,
|
||||||
|
size: 28);
|
||||||
} else {
|
} else {
|
||||||
subtitleText = "$rankStr (유지)";
|
subtitleText = "$rankStr (유지)";
|
||||||
subtitleColor = Colors.grey;
|
subtitleColor = Colors.grey;
|
||||||
trailingWidget = const Icon(Icons.check_circle_outline_rounded, color: Colors.grey, size: 28);
|
trailingWidget = const Icon(
|
||||||
|
Icons.check_circle_outline_rounded,
|
||||||
|
color: Colors.grey,
|
||||||
|
size: 28);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
subtitleText = "$rankStr (신규 진입)";
|
subtitleText = "$rankStr (신규 진입)";
|
||||||
subtitleColor = Colors.blue;
|
subtitleColor = Colors.blue;
|
||||||
trailingWidget = const Icon(Icons.new_releases_rounded, color: Colors.blue, size: 28);
|
trailingWidget = const Icon(
|
||||||
|
Icons.new_releases_rounded,
|
||||||
|
color: Colors.blue,
|
||||||
|
size: 28);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (oldRank > 0) {
|
if (oldRank > 0) {
|
||||||
subtitleText = "랭킹 이탈 (이전 ${oldRank}위)";
|
subtitleText = "랭킹 이탈 (이전 ${oldRank}위)";
|
||||||
subtitleColor = Colors.orange;
|
subtitleColor = Colors.orange;
|
||||||
trailingWidget = const Icon(Icons.warning_amber_rounded, color: Colors.orange, size: 28);
|
trailingWidget = const Icon(
|
||||||
|
Icons.warning_amber_rounded,
|
||||||
|
color: Colors.orange,
|
||||||
|
size: 28);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0),
|
margin: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16.0, vertical: 4.0),
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: Icon(
|
leading: Icon(
|
||||||
isUnlocked ? Icons.lock_open_rounded : Icons.lock_rounded,
|
isUnlocked
|
||||||
|
? Icons.lock_open_rounded
|
||||||
|
: Icons.lock_rounded,
|
||||||
color: isUnlocked ? theme.primaryColor : Colors.grey,
|
color: isUnlocked ? theme.primaryColor : Colors.grey,
|
||||||
),
|
),
|
||||||
title: Text(level.name, style: TextStyle(
|
title: Text(level.name,
|
||||||
fontSize: 18,
|
style: TextStyle(
|
||||||
fontWeight: isUnlocked ? FontWeight.bold : FontWeight.normal,
|
fontSize: 18,
|
||||||
color: isUnlocked ? theme.textTheme.bodyLarge?.color : Colors.grey,
|
fontWeight: isUnlocked
|
||||||
)),
|
? FontWeight.bold
|
||||||
|
: FontWeight.normal,
|
||||||
|
color: isUnlocked
|
||||||
|
? theme.textTheme.bodyLarge?.color
|
||||||
|
: Colors.grey,
|
||||||
|
)),
|
||||||
subtitle: subtitleText != null
|
subtitle: subtitleText != null
|
||||||
? Text(subtitleText, style: TextStyle(color: subtitleColor, fontWeight: FontWeight.bold))
|
? Text(subtitleText,
|
||||||
: null,
|
style: TextStyle(
|
||||||
trailing: trailingWidget,
|
color: subtitleColor,
|
||||||
|
fontWeight: FontWeight.bold))
|
||||||
|
: null,
|
||||||
|
trailing: trailingWidget,
|
||||||
onTap: isUnlocked && !_isLoading
|
onTap: isUnlocked && !_isLoading
|
||||||
? () => _startGame(level)
|
? () => _startGame(level)
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
@ -7,10 +7,11 @@ export 'models/sudoku_game_dto.dart';
|
|||||||
export 'models/sudoku_theme.dart';
|
export 'models/sudoku_theme.dart';
|
||||||
export 'models/unified_rank_dto.dart';
|
export 'models/unified_rank_dto.dart';
|
||||||
export 'models/validate_result_dto.dart';
|
export 'models/validate_result_dto.dart';
|
||||||
export 'models/math_quiz_difficulty.dart'; // 👈 [추가]
|
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
export 'services/identity_service.dart';
|
export 'services/identity_service.dart';
|
||||||
export 'services/puzzle_service.dart';
|
export 'services/puzzle_service.dart';
|
||||||
export 'services/theme_notifier.dart';
|
export 'services/theme_notifier.dart';
|
||||||
export 'services/session_notifier.dart'; // 👈 [추가]
|
export 'services/session_notifier.dart'; // 👈 [추가]
|
||||||
|
export 'services/lobby_helper_service.dart'; // 👈 [추가]
|
||||||
70
packages/service_api/lib/services/lobby_helper_service.dart
Normal file
70
packages/service_api/lib/services/lobby_helper_service.dart
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
// packages/service_api/lib/services/lobby_helper_service.dart
|
||||||
|
import 'dart:developer';
|
||||||
|
import 'package:service_api/service_api.dart';
|
||||||
|
|
||||||
|
/// [수정됨] 로비 화면의 중복 로직(랭킹, 레벨)을 처리하는 공통 서비스
|
||||||
|
class LobbyHelperService {
|
||||||
|
final IdentityService _identityService;
|
||||||
|
final PuzzleService _puzzleService;
|
||||||
|
|
||||||
|
LobbyHelperService({
|
||||||
|
required IdentityService identityService,
|
||||||
|
required PuzzleService puzzleService,
|
||||||
|
}) : _identityService = identityService,
|
||||||
|
_puzzleService = puzzleService;
|
||||||
|
|
||||||
|
/// (가벼움) 현재 게임의 최대 레벨만 로드
|
||||||
|
Future<int> loadMaxLevel(String gameType) async {
|
||||||
|
return await _identityService.getMaxUnlockedLevel(gameType: gameType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [🔥 수정됨] (무거움) 랭킹 변동 이력(▲▼)을 반환 (levelIndex 추출기능 주입)
|
||||||
|
Future<Map<int, (int, int)>> loadRankHistory<T extends GameDifficulty>({
|
||||||
|
required String gameType,
|
||||||
|
required String myName,
|
||||||
|
required List<T> allLevels,
|
||||||
|
required int Function(T level) getLevelIndex, // 👈 [수정] levelIndex 추출 함수
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final Map<int, int> oldRankMap =
|
||||||
|
await _identityService.getLastSavedRankMap(gameType: gameType);
|
||||||
|
|
||||||
|
List<Future<List<GameRankDto>>> rankFutures = [];
|
||||||
|
for (final level in allLevels) {
|
||||||
|
rankFutures.add(_puzzleService.fetchRanks(gameType, level.contextId));
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<List<GameRankDto>> allRankResults =
|
||||||
|
await Future.wait(rankFutures);
|
||||||
|
Map<int, int> newRankMapForStorage = {};
|
||||||
|
Map<int, (int, int)> newRankHistoryForState = {};
|
||||||
|
|
||||||
|
for (int i = 0; i < allLevels.length; i++) {
|
||||||
|
final level = allLevels[i];
|
||||||
|
final currentRanks = allRankResults[i];
|
||||||
|
|
||||||
|
// [🔥 수정] 주입받은 함수로 levelIndex를 안전하게 추출
|
||||||
|
final int levelIndex = getLevelIndex(level);
|
||||||
|
|
||||||
|
final int oldRank = oldRankMap[levelIndex] ?? 0;
|
||||||
|
int currentRank = 0;
|
||||||
|
int myRankIndex =
|
||||||
|
currentRanks.indexWhere((r) => r.playerName == myName);
|
||||||
|
if (myRankIndex != -1) {
|
||||||
|
currentRank = myRankIndex + 1;
|
||||||
|
}
|
||||||
|
newRankMapForStorage[levelIndex] = currentRank;
|
||||||
|
newRankHistoryForState[levelIndex] = (oldRank, currentRank);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _identityService.saveLastRankMap(newRankMapForStorage,
|
||||||
|
gameType: gameType);
|
||||||
|
|
||||||
|
log("$gameType 랭킹 변동 확인 완료. (유저: $myName)");
|
||||||
|
return newRankHistoryForState;
|
||||||
|
} catch (e) {
|
||||||
|
log("LobbyHelperService($gameType): 랭킹 확인 실패: $e");
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user