This commit is contained in:
lunaticbum 2025-11-19 11:17:33 +09:00
parent 3b053530f5
commit 2008c377f4
17 changed files with 1416 additions and 966 deletions

15
.vscode/launch.json vendored
View File

@ -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",

View 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:

View File

@ -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(),
// );
// }
// }

View File

@ -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
/// ///

View File

@ -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();
}
}
});
}
} }

View File

@ -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,
});
} }

View File

@ -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};
} }
} }

View File

@ -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, // 👈 []
}); });
} }

View File

@ -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,

View File

@ -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),

View File

@ -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();

View File

@ -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';

View File

@ -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,

View File

@ -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};
} }
} }

View File

@ -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,

View File

@ -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'; // 👈 []

View 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;
}
}
}