This commit is contained in:
2025-11-17 18:21:49 +09:00
parent 13ed537b23
commit 86611ce092
160 changed files with 7829 additions and 452 deletions
@@ -0,0 +1,127 @@
import 'dart:async'; // 👈 [추가] Timer
import 'package:flutter/material.dart';
import 'package:service_api/service_api.dart';
import 'math_quiz_generator.dart';
import '../models/math_quiz_models.dart';
class MathQuizController with ChangeNotifier {
late final MathQuizDifficulty difficulty;
late final MathQuizPuzzle puzzle;
late final String userId;
late final String? userName;
late List<String?> _userAnswers;
List<String?> get userAnswers => _userAnswers;
int _selectedBlankIndex = 0;
int get selectedBlankIndex => _selectedBlankIndex;
bool _isGameCompleted = false;
bool get isGameCompleted => _isGameCompleted;
// 🔽 [추가] 타이머 및 시간
Timer? _timer;
int _secondsElapsed = 0;
int get secondsElapsed => _secondsElapsed;
// 🔽 [추가] 컨트롤러가 제거될 때 타이머 해제
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
// 🔽 [추가] 타이머 시작
void _startTimer() {
_timer?.cancel();
_secondsElapsed = 0;
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
_secondsElapsed++;
notifyListeners(); // 매초 UI 갱신 (시간 표시용)
});
}
// 🔽 [추가] 타이머 정지
void _stopTimer() {
_timer?.cancel();
}
/// 1. 로비에서 호출: 새 게임 시작
void startNewGame(MathQuizDifficulty level, String userId, String? userName) {
this.difficulty = level;
this.userId = userId;
this.userName = userName;
final generator = MathQuizGenerator();
this.puzzle = generator.generatePuzzle(level);
_userAnswers = List.generate(puzzle.solutions.length, (_) => null);
_selectedBlankIndex = 0;
_isGameCompleted = false;
_startTimer(); // 👈 [추가]
notifyListeners();
}
/// 2. UI(빈칸)에서 호출: 빈칸 선택
void onBlankTapped(int index) {
if (_isGameCompleted) return;
_selectedBlankIndex = index;
notifyListeners();
}
/// 3. UI(숫자 버튼)에서 호출: 답 입력
void onOptionTapped(String option) {
if (_isGameCompleted) return;
_userAnswers[_selectedBlankIndex] = option;
_selectNextBlank();
notifyListeners();
_checkCompletion();
}
/// 4. UI(지우기 버튼)에서 호출: 답 지우기
void onClearTapped() {
if (_isGameCompleted) return;
_userAnswers[_selectedBlankIndex] = null;
notifyListeners();
}
/// 다음 빈칸 (아직 답이 없는)으로 자동 이동
void _selectNextBlank() {
int nextIndex = (_selectedBlankIndex + 1) % _userAnswers.length;
for (int i = 0; i < _userAnswers.length; i++) {
if (_userAnswers[nextIndex] == null) {
_selectedBlankIndex = nextIndex;
return;
}
nextIndex = (nextIndex + 1) % _userAnswers.length;
}
}
/// 모든 답이 채워졌는지, 그리고 정답인지 확인
void _checkCompletion() {
if (_userAnswers.any((answer) => answer == null)) {
return;
}
bool allCorrect = true;
for (int i = 0; i < puzzle.solutions.length; i++) {
if (_userAnswers[i] != puzzle.solutions[i]) {
allCorrect = false;
break;
}
}
if (allCorrect) {
_isGameCompleted = true;
_stopTimer(); // 👈 [추가]
notifyListeners();
} else {
// [TODO] 오답 처리 (예: 스낵바 표시)
debugPrint("오답입니다!");
}
}
}
@@ -0,0 +1,403 @@
import 'dart:math';
import 'package:service_api/service_api.dart';
import '../models/math_quiz_models.dart';
import 'package:function_tree/function_tree.dart';
class MathQuizGenerator {
final Random _random = Random();
/// 난이도에 맞는 퍼즐을 생성합니다.
MathQuizPuzzle generatePuzzle(MathQuizDifficulty level) {
switch (level.layout) {
case MathQuizLayout.singleLine:
if (level.operationCount == 2) { // Lv 1-5
return _generateSimpleEquation(level); // A + B = C
} else { // operationCount == 3 (Lv 6-10)
return _generateMultiOpEquation(level); // A + B * C = D
}
case MathQuizLayout.linkedL: // operationCount == 4 (Lv 11-15)
return _generateLinkedEquations(level); // 2x2 그리드
case MathQuizLayout.gridSquare:
if (level.operationCount == 9) { // Lv 10-12 (3x3)
return _generate3x3GridEquation(level);
} else { // Lv 13-15 (4x4)
return _generate4x4GridEquation(level);
}
}
}
/// [Lv 1-5] 숫자 2개 연산: (A op B = C)
MathQuizPuzzle _generateSimpleEquation(MathQuizDifficulty level) {
// (이전과 동일)
final (int a, int b, int c, String op) = _createEquation(level.operators);
final List<String> allParts = (op == '+')
? [a.toString(), op, b.toString(), '=', c.toString()]
: (op == '-')
? [c.toString(), op, a.toString(), '=', b.toString()]
: (op == '*')
? [a.toString(), op, b.toString(), '=', c.toString()]
: [c.toString(), op, a.toString(), '=', b.toString()];
List<int> candidateIndices;
switch (level.blankType) {
case MathQuizBlankType.numbersOnly: candidateIndices = [0, 2]; break;
case MathQuizBlankType.operatorsOnly: candidateIndices = [1]; break;
case MathQuizBlankType.numbersAndOperators: candidateIndices = [0, 1, 2]; break;
}
final int finalBlankCount = min(level.blankCount, candidateIndices.length);
final List<int> blankIndices = (candidateIndices..shuffle(_random)).sublist(0, finalBlankCount);
final List<String> solutions = [];
final List<String> gridCells = List.of(allParts);
for (int index in blankIndices) {
solutions.add(allParts[index]);
gridCells[index] = '?';
}
final Set<String> optionsSet = solutions.toSet();
while (optionsSet.length < 9) {
optionsSet.add((_random.nextInt(9) + 1).toString());
}
optionsSet.addAll(level.operators.split(','));
if (level.blankType != MathQuizBlankType.operatorsOnly) {
optionsSet.add('=');
}
final List<String> options = (optionsSet.toList()..shuffle(_random)).toList();
return MathQuizPuzzle(
gridCells: gridCells,
gridCrossAxisCount: 5, // 5x1 그리드
solutions: solutions,
options: options,
);
}
/// [Lv 6-10] 숫자 3개 연산: (A op1 B op2 C = D)
MathQuizPuzzle _generateMultiOpEquation(MathQuizDifficulty level) {
// 1. 식 생성 (연산자 우선순위 포함)
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. 빈칸 생성
final int blankCount = level.blankCount;
List<int> candidateIndices;
switch (level.blankType) {
case MathQuizBlankType.numbersOnly: candidateIndices = [0, 2, 4]; break;
case MathQuizBlankType.operatorsOnly: candidateIndices = [1, 3]; break;
case MathQuizBlankType.numbersAndOperators: candidateIndices = [0, 1, 2, 3, 4]; break;
}
final int finalBlankCount = min(blankCount, candidateIndices.length);
final List<int> blankIndices = (candidateIndices..shuffle(_random)).sublist(0, finalBlankCount);
final List<String> solutions = [];
final List<String> gridCells = List.of(allParts);
for (int index in blankIndices) {
solutions.add(allParts[index]);
gridCells[index] = '?';
}
// 3. 옵션 생성
final Set<String> optionsSet = solutions.toSet();
while (optionsSet.length < 9) {
optionsSet.add((_random.nextInt(9) + 1).toString());
}
optionsSet.addAll(level.operators.split(','));
optionsSet.add('=');
// 4. 그리드 모델로 반환
return MathQuizPuzzle(
gridCells: gridCells,
gridCrossAxisCount: 7, // 7x1 그리드
solutions: solutions,
options: (optionsSet.toList()..shuffle(_random)).toList(),
);
}
/// [Lv 11-15] 숫자 4개 연산: (2x2 그리드 'ㄱ', 'ㄴ')
MathQuizPuzzle _generateLinkedEquations(MathQuizDifficulty level) {
// (동적 생성 로직)
while (true) {
final int a = _random.nextInt(9) + 1;
final int b = _random.nextInt(9) + 1;
final int c = _random.nextInt(9) + 1;
final int d = _random.nextInt(9) + 1;
final ops = level.operators.split(',');
final String op1 = (ops..shuffle(_random)).first;
final String op2 = (ops..shuffle(_random)).first;
final String op3 = (ops..shuffle(_random)).first;
final String op4 = (ops..shuffle(_random)).first;
final int? r1 = _calculate(a, b, op1);
final int? r2 = _calculate(c, d, op2);
final int? r3 = _calculate(a, c, op3);
final int? r4 = _calculate(b, d, op4);
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;
final List<String> allParts = [
a.toString(), op1, b.toString(), "=", r1.toString(),
op3, " ", op4, " ", "=",
c.toString(), op2, d.toString(), "=", r2.toString(),
"=", " ", "=", " ", "=",
r3.toString(), " ", r4.toString(), " ", " ",
];
final List<String> solutions = [];
final List<String> gridCells = List.of(allParts);
List<int> numberIndices = [0, 2, 10, 12]; // A,B,C,D
List<int> operatorIndices = [1, 5, 7, 11]; // op1,op3,op4,op2
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();
for (int index in blankIndices) {
solutions.add(allParts[index]);
gridCells[index] = '?';
}
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, // 5x5 그리드
solutions: solutions,
options: (optionsSet.toList()..shuffle(_random)).toList(),
);
} // end while(true)
}
/// [수정됨] 레벨 10-12 (3x3 Grid) - 템플릿 목록 사용
MathQuizPuzzle _generate3x3GridEquation(MathQuizDifficulty level) {
// 1. 3x3 템플릿 목록에서 랜덤 선택
final template = _get3x3Templates()..shuffle();
final selectedTemplate = template.first;
final List<String> allParts = selectedTemplate.gridCells;
final List<int> numberIndices = selectedTemplate.numberIndices;
final List<int> operatorIndices = selectedTemplate.operatorIndices;
// 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(
gridCells: gridCells,
gridCrossAxisCount: 7, // 3x3 퍼즐 (7x7 그리드)
solutions: finalSolutions,
options: options, // 👈 [오류 수정]
);
}
/// [수정됨] 레벨 13-15 (4x4 Grid) - 템플릿 목록 사용
MathQuizPuzzle _generate4x4GridEquation(MathQuizDifficulty level) {
// 1. 4x4 템플릿 목록에서 랜덤 선택
final template = _get4x4Templates()..shuffle();
final selectedTemplate = template.first;
final List<String> allParts = selectedTemplate.gridCells;
final List<int> numberIndices = selectedTemplate.numberIndices;
final List<int> operatorIndices = selectedTemplate.operatorIndices;
// 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(
gridCells: gridCells,
gridCrossAxisCount: 9, // 4x4 퍼즐 (9x9 그리드)
solutions: finalSolutions,
options: options, // 👈 [오류 수정]
);
}
/// [HELPER] (A, B, C, op) 튜플 생성 (사칙연산 지원)
(int, int, int, String) _createEquation(String operators, {int? firstTerm, int maxResult = 9}) {
// (이전과 동일)
final List<String> ops = operators.split(',');
final String op = ops[_random.nextInt(ops.length)];
int a, b, c;
switch (op) {
case '+':
a = firstTerm ?? _random.nextInt(maxResult - 1) + 1;
b = _random.nextInt(maxResult - a) + 1;
c = a + b;
return (a, b, c, op);
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) {
while(true) {
final int a = _random.nextInt(9) + 1;
final int b = _random.nextInt(9) + 1;
final int c = _random.nextInt(9) + 1;
final List<String> ops = operators.split(',');
final String op1 = (ops..shuffle(_random)).first;
final String op2 = (ops..shuffle(_random)).first;
final String expression = "$a $op1 $b $op2 $c";
if (expression.contains('/')) continue;
final num resultNum = expression.interpret();
final int result = resultNum.toInt();
if (result == resultNum && result >= -99 && result <= 999) { // [수정] 범위 확장
return (a, b, c, op1, op2, result);
}
}
}
/// [HELPER] 두 숫자와 연산자로 계산 (실패 시 null 반환)
int? _calculate(int a, int b, String op) {
switch (op) {
case '+': return a + b;
case '-': return a - b;
case '*': return a * b;
case '/':
if (b == 0) return null; // 0으로 나누기
if (a % b != 0) return null; // 나누어떨어지지 않음
return a ~/ b;
}
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,
});
}
@@ -0,0 +1,2 @@
// 이 패키지의 메인 진입점 (로비 화면)을 export합니다.
export 'screens/math_quiz_lobby_screen.dart';
@@ -0,0 +1,29 @@
// packages/feature_game_mathquiz/lib/models/math_quiz_models.dart
/// 퍼즐 한 판의 데이터 구조
class MathQuizPuzzle {
/// [수정] 그리드 셀 데이터
///
/// '?'는 빈칸, ' '는 공백(빈 셀)입니다.
///
/// 예: ["?", "+", "3", "=", "8"] (5x1 그리드)
/// 예: ["?", "+", "2", "=", "5", "+", " ", "+", ... ] (5x5 그리드)
final List<String> gridCells;
/// 그리드의 가로 칸 수 (예: 5)
final int gridCrossAxisCount;
/// 유저가 채워야 할 정답 목록 (순서대로)
final List<String> solutions;
/// 유저에게 제공될 숫자/기호 버튼 옵션
final List<String> options;
MathQuizPuzzle({
// ❌ puzzleType 삭제
required this.gridCells,
required this.gridCrossAxisCount,
required this.solutions,
required this.options,
});
}
@@ -0,0 +1,239 @@
import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
// [C] 공통 서비스 (service_api)
import 'package:service_api/service_api.dart';
// [A] 공통 UI (feature_common)
import 'package:feature_common/feature_common.dart';
// [B] 이 패키지 (mathquiz)
import 'math_quiz_screen.dart';
import '../controllers/math_quiz_controller.dart'; // 👈 [추가] 컨트롤러 임포트
class MathQuizLobbyScreen extends StatefulWidget {
const MathQuizLobbyScreen({ super.key });
@override
State<MathQuizLobbyScreen> createState() => _MathQuizLobbyScreenState();
}
class _MathQuizLobbyScreenState extends State<MathQuizLobbyScreen> {
int _maxUnlockedLevel = 1;
Map<int, (int, int)> _rankHistory = {};
bool _isLoading = false;
late final SessionNotifier _sessionNotifier;
final PuzzleService _puzzleService = PuzzleService();
final IdentityService _identityService = IdentityService();
@override
void initState() {
super.initState();
_sessionNotifier = context.read<SessionNotifier>();
_loadProgress(forceRefreshRanks: true);
}
/// 랭킹 및 레벨 진행 상황 로드
Future<void> _loadProgress({bool forceRefreshRanks = false}) async {
// 1. (가벼움) 레벨 정보 새로고침
final maxLevel = await _identityService.getMaxUnlockedLevel(gameType: 'MATH_QUIZ');
if (mounted) { setState(() { _maxUnlockedLevel = maxLevel; }); }
// 2. (무거움) 랭킹 정보 새로고침 (필요할 때만)
if (!forceRefreshRanks) return;
final String? myName = _sessionNotifier.session?.userName;
if (myName == null) return;
try {
final Map<int, int> oldRankMap = await _identityService.getLastSavedRankMap(gameType: 'MATH_QUIZ');
List<Future<List<GameRankDto>>> rankFutures = [];
for (final level in MathQuizDifficulties.allDifficulties) {
rankFutures.add(_puzzleService.fetchRanks('MATH_QUIZ', 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 < 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) {
log("MathQuizLobbyScreen: 랭킹 확인 실패: $e");
}
}
/// 🔽 [수정] 게임 시작 함수
Future<void> _startGame(MathQuizDifficulty level) async {
setState(() { _isLoading = true; });
try {
final session = _sessionNotifier.session;
if (session == null) {
throw Exception("세션이 로드되지 않았습니다.");
}
// 1. 컨트롤러 생성 및 새 게임 시작 (제너레이터 호출)
final controller = MathQuizController();
controller.startNewGame(level, session.userId, session.userName);
if (mounted) {
await Navigator.push(
context,
MaterialPageRoute(
// 2. 컨트롤러를 게임 화면에 주입
builder: (context) => ChangeNotifierProvider.value(
value: controller,
child: const MathQuizScreen(),
),
),
);
// 3. 게임에서 돌아오면 레벨 잠금 상태만 새로고침
_loadProgress(forceRefreshRanks: false);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('게임 로딩/생성 실패: $e')),
);
}
} finally {
if (mounted) {
setState(() { _isLoading = false; });
}
}
}
@override
Widget build(BuildContext context) {
// ... (이하 build 메서드는 이전과 동일) ...
context.watch<ThemeNotifier>();
context.watch<SessionNotifier>();
final bool allLevelsUnlocked = _maxUnlockedLevel >= MathQuizDifficulties.allDifficulties.length;
final theme = Theme.of(context);
return CommonGameShell(
title: '계산 퀴즈',
onRankingPressed: () {
final List<GameDifficulty> difficulties = MathQuizDifficulties.allDifficulties
.map((level) => GameDifficulty(
name: level.name,
contextId: level.contextId,
))
.toList();
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => RankingScreen(
gameType: 'MATH_QUIZ',
difficulties: difficulties,
initialDifficultyName: MathQuizDifficulties.getLevel(_maxUnlockedLevel).name,
),
),
);
},
body: LayoutBuilder(
builder: (context, constraints) {
const double maxContentRatio = 0.6;
final double constrainedWidth = (constraints.maxHeight * maxContentRatio) > 500
? 500 : (constraints.maxHeight * maxContentRatio);
return Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: constrainedWidth),
child: Column(
children: [
Expanded(
child: RefreshIndicator(
onRefresh: () => _loadProgress(forceRefreshRanks: true),
child: ListView.builder(
itemCount: MathQuizDifficulties.allDifficulties.length,
itemBuilder: (context, index) {
final MathQuizDifficulty level = MathQuizDifficulties.allDifficulties[index];
final bool isUnlocked = allLevelsUnlocked || level.levelIndex <= _maxUnlockedLevel;
final (int oldRank, int currentRank) = _rankHistory[level.levelIndex] ?? (0, 0);
Widget? trailingWidget = isUnlocked ? const Icon(Icons.play_arrow_rounded) : null;
String? subtitleText;
Color? subtitleColor;
if (currentRank > 0) {
String rankStr = "${currentRank}";
if (oldRank > 0) {
int change = oldRank - currentRank;
if (change > 0) {
subtitleText = "$rankStr (▲ $change)";
subtitleColor = Colors.green;
trailingWidget = const Icon(Icons.arrow_circle_up_rounded, color: Colors.green, size: 28);
} else if (change < 0) {
subtitleText = "$rankStr (▼ ${change.abs()})";
subtitleColor = Colors.red;
trailingWidget = const Icon(Icons.arrow_circle_down_rounded, color: Colors.red, size: 28);
} else {
subtitleText = "$rankStr (유지)";
subtitleColor = Colors.grey;
trailingWidget = const Icon(Icons.check_circle_outline_rounded, color: Colors.grey, size: 28);
}
} else {
subtitleText = "$rankStr (신규 진입)";
subtitleColor = Colors.blue;
trailingWidget = const Icon(Icons.new_releases_rounded, color: Colors.blue, size: 28);
}
} else {
if (oldRank > 0) {
subtitleText = "랭킹 이탈 (이전 ${oldRank}위)";
subtitleColor = Colors.orange;
trailingWidget = const Icon(Icons.warning_amber_rounded, color: Colors.orange, size: 28);
}
}
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0),
child: ListTile(
leading: Icon(
isUnlocked ? Icons.lock_open_rounded : Icons.lock_rounded,
color: isUnlocked ? theme.primaryColor : Colors.grey,
),
title: Text(level.name, style: TextStyle(
fontSize: 18,
fontWeight: isUnlocked ? FontWeight.bold : FontWeight.normal,
color: isUnlocked ? theme.textTheme.bodyLarge?.color : Colors.grey,
)),
subtitle: subtitleText != null
? Text(subtitleText, style: TextStyle(color: subtitleColor, fontWeight: FontWeight.bold))
: null,
trailing: trailingWidget,
onTap: isUnlocked && !_isLoading
? () => _startGame(level)
: null,
),
);
},
),
),
),
],
),
),
);
},
),
);
}
}
@@ -0,0 +1,280 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:service_api/service_api.dart';
import 'package:feature_common/feature_common.dart';
import '../controllers/math_quiz_controller.dart';
import '../models/math_quiz_models.dart';
class MathQuizScreen extends StatefulWidget {
const MathQuizScreen({super.key});
@override
State<MathQuizScreen> createState() => _MathQuizScreenState();
}
class _MathQuizScreenState extends State<MathQuizScreen> {
bool _isDialogShowing = false;
// ( ... _showGameCompletion 메서드는 이전과 동일 ... )
void _showGameCompletion(MathQuizController controller) async {
String formatMathQuizScore(int primary, int? secondary) {
final problemCount = primary;
final time = (secondary ?? 0).toString();
return '${problemCount}개 (${time}초)';
}
Future<void> saveMathQuizProgress(String playerName) async {
final identityService = IdentityService();
final int currentMaxLevel = await identityService.getMaxUnlockedLevel(gameType: 'MATH_QUIZ');
if (currentMaxLevel < 99) {
if (controller.difficulty.levelIndex >= currentMaxLevel) {
int nextLevel = controller.difficulty.levelIndex + 1;
if (nextLevel > MathQuizDifficulties.allDifficulties.length) {
await identityService.saveMaxUnlockedLevel(99, gameType: 'MATH_QUIZ');
} else {
await identityService.saveMaxUnlockedLevel(nextLevel, gameType: 'MATH_QUIZ');
}
}
}
}
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => GameCompletionScreen(
args: GameResultArgs(
gameType: 'MATH_QUIZ',
contextId: controller.difficulty.contextId,
primaryScore: controller.puzzle.solutions.length,
secondaryScore: controller.secondsElapsed,
userId: controller.userId,
userName: controller.userName,
scoreFormatter: formatMathQuizScore,
onProgressSave: saveMathQuizProgress,
),
),
),
);
if (mounted && Navigator.canPop(context)) {
Navigator.pop(context);
}
}
@override
Widget build(BuildContext context) {
final controller = context.watch<MathQuizController>();
if (controller.isGameCompleted && !_isDialogShowing) {
_isDialogShowing = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_showGameCompletion(controller);
});
}
return Scaffold(
appBar: AppBar(
title: Text(controller.difficulty.name),
actions: [
Padding(
padding: const EdgeInsets.only(right: 20.0),
child: Center(
child: Text(
'${(controller.secondsElapsed ~/ 60).toString().padLeft(2, '0')}:${(controller.secondsElapsed % 60).toString().padLeft(2, '0')}',
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
),
),
],
),
body: LayoutBuilder(
builder: (context, constraints) {
bool isLandscape = constraints.maxWidth > constraints.maxHeight;
if (isLandscape) {
return _buildLandscapeLayout(context, controller);
} else {
return _buildPortraitLayout(context, controller);
}
},
),
);
}
/// 🔽 세로 모드 레이아웃
Widget _buildPortraitLayout(BuildContext context, MathQuizController controller) {
return Column(
children: [
Expanded(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
// [수정] _buildPuzzleGrid를 항상 호출
child: _buildPuzzleGrid(context, controller),
),
),
),
_buildKeypad(context, controller),
],
);
}
/// 🔽 가로 모드 레이아웃
Widget _buildLandscapeLayout(BuildContext context, MathQuizController controller) {
return Row(
children: [
Expanded(
flex: 6, // 방정식 영역
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
// [수정] _buildPuzzleGrid를 항상 호출
child: _buildPuzzleGrid(context, controller),
),
),
),
Expanded(
flex: 4, // 키패드 영역
child: _buildKeypad(context, controller),
),
],
);
}
// ❌ [삭제] _buildEquationList 메서드 삭제
/// [수정] _buildEquationGrid -> _buildPuzzleGrid
/// (모든 퍼즐을 그리는 유일한 빌더)
Widget _buildPuzzleGrid(BuildContext context, MathQuizController controller) {
final puzzle = controller.puzzle;
final userAnswers = controller.userAnswers;
final int crossAxisCount = puzzle.gridCrossAxisCount;
int blankIndex = 0;
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: puzzle.gridCells.length,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
),
itemBuilder: (context, index) {
final String part = puzzle.gridCells[index];
if (part == "?") {
// 빈칸일 경우
final int currentBlankIndex = blankIndex;
blankIndex++;
return _buildBlankBox(
context,
controller,
currentBlankIndex,
userAnswers[currentBlankIndex],
);
} else if (part == " ") {
// " " (빈 공간)
return Container();
} else {
// 숫자나 기호일 경우
return Center(
child: Text(
part,
style: const TextStyle(fontSize: 32, fontWeight: FontWeight.bold),
),
);
}
},
);
}
/// [수정] _buildBlankBox (isGrid 파라미터 삭제)
Widget _buildBlankBox(BuildContext context, MathQuizController controller, int index, String? value) {
final theme = Theme.of(context);
final bool isSelected = (controller.selectedBlankIndex == index);
// [수정] 그리드 셀의 크기는 GridView가 결정하므로
// AspectRatio를 사용해 1:1 비율 유지
return AspectRatio(
aspectRatio: 1 / 1,
child: GestureDetector(
onTap: () => controller.onBlankTapped(index),
child: Container(
margin: const EdgeInsets.all(4.0), // 그리드/리스트 공통 여백
decoration: BoxDecoration(
color: theme.colorScheme.surfaceVariant,
border: Border.all(
color: isSelected ? theme.colorScheme.primary : Colors.transparent,
width: 3,
),
borderRadius: BorderRadius.circular(8),
),
child: Center(
// [수정] 폰트 크기를 FittedBox로 자동 조절
child: FittedBox(
fit: BoxFit.contain,
child: Padding(
padding: const EdgeInsets.all(4.0),
child: Text(
value ?? '',
style: TextStyle(
fontWeight: FontWeight.bold,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
),
),
),
),
);
}
/// 2. 키패드 UI 빌더
Widget _buildKeypad(BuildContext context, MathQuizController controller) {
// ... (이하 키패드 로직은 동일) ...
final puzzle = controller.puzzle;
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(8.0),
decoration: BoxDecoration(
color: theme.scaffoldBackgroundColor,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 4,
offset: const Offset(0, -2),
),
],
),
child: GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 5, // 5열
childAspectRatio: 1.5,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
),
itemCount: puzzle.options.length + 1, // 옵션 + 지우기 버튼
itemBuilder: (context, index) {
if (index == puzzle.options.length) {
return FilledButton.tonal(
onPressed: () => controller.onClearTapped(),
child: const Icon(Icons.backspace_outlined),
);
}
final String option = puzzle.options[index];
return FilledButton(
onPressed: () => controller.onOptionTapped(option),
child: Text(
option,
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
);
},
),
);
}
}