...
This commit is contained in:
@@ -28,8 +28,8 @@ class GameResultArgs {
|
||||
/// (예: 다음 레벨 잠금 해제)
|
||||
final Future<void> Function(String playerName) onProgressSave;
|
||||
|
||||
/// 팝업이 닫힐 때 게임 화면을 닫기 위한 콜백
|
||||
final VoidCallback onScreenClose;
|
||||
// ❌ [삭제] onScreenClose 콜백 제거
|
||||
// final VoidCallback onScreenClose;
|
||||
|
||||
GameResultArgs({
|
||||
required this.gameType,
|
||||
@@ -40,6 +40,7 @@ class GameResultArgs {
|
||||
this.userName,
|
||||
required this.scoreFormatter,
|
||||
required this.onProgressSave,
|
||||
required this.onScreenClose,
|
||||
// ❌ [삭제]
|
||||
// required this.onScreenClose,
|
||||
});
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
// packages/feature_common/lib/screens/game_completion_screen.dart
|
||||
import 'dart:developer';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart'; // 👈 [추가]
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:service_api/service_api.dart';
|
||||
import '../models/game_result_args.dart';
|
||||
|
||||
// 스도쿠/스파이더와 동일한 enum
|
||||
enum _RankSubmissionStep { enterName, submitting, showList }
|
||||
|
||||
class GameCompletionScreen extends StatefulWidget {
|
||||
@@ -17,11 +17,9 @@ class GameCompletionScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _GameCompletionScreenState extends State<GameCompletionScreen> {
|
||||
// 서비스 초기화
|
||||
final PuzzleService _puzzleService = PuzzleService();
|
||||
final IdentityService _identityService = IdentityService();
|
||||
|
||||
// 상태 변수
|
||||
late final TextEditingController _nameController;
|
||||
_RankSubmissionStep _rankStep = _RankSubmissionStep.enterName;
|
||||
List<GameRankDto> _rankingList = [];
|
||||
@@ -32,17 +30,21 @@ class _GameCompletionScreenState extends State<GameCompletionScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 🔽 [수정] 세션에서 userName을 가져옴
|
||||
// (이 화면은 build 이전에 호출되므로 'read' 사용)
|
||||
|
||||
// 🔽 [핵심 수정]
|
||||
// 랭킹 등록 여부와 상관없이, 이 화면에 진입한 것 자체가 "레벨 클리어"이므로
|
||||
// onProgressSave (레벨 잠금 해제)를 즉시 호출합니다.
|
||||
// (playerName은 이 콜백에서 사용되지 않으므로 빈 값을 전달합니다.)
|
||||
widget.args.onProgressSave("");
|
||||
|
||||
// --- (이하 기존 로직) ---
|
||||
final session = context.read<SessionNotifier>().session;
|
||||
_nameController = TextEditingController(text: session?.userName ?? widget.args.userName);
|
||||
|
||||
// 🔽 [수정] 게스트가 아니면, 이름 입력 단계를 건너뛰고 즉시 등록
|
||||
// 로그인 유저일 경우, 이름 입력 생략하고 자동 등록
|
||||
if (session != null && !session.isGuest) {
|
||||
_rankStep = _RankSubmissionStep.submitting;
|
||||
// build가 완료된 후 등록 시작
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// [중요] 세션의 userName으로 자동 제출
|
||||
_submitRank(autoSubmitName: session.userName);
|
||||
});
|
||||
}
|
||||
@@ -54,12 +56,9 @@ class _GameCompletionScreenState extends State<GameCompletionScreen> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 랭킹 등록 로직 (공통화)
|
||||
// 🔽 [수정] _submitRank가 자동 제출용 이름을 받도록
|
||||
Future<void> _submitRank({String? autoSubmitName}) async {
|
||||
String playerName;
|
||||
|
||||
// 자동 제출(로그인 상태)이 아니면(게스트면), 컨트롤러에서 이름을 가져옴
|
||||
if (autoSubmitName == null) {
|
||||
playerName = _nameController.text.trim();
|
||||
if (playerName.isEmpty) {
|
||||
@@ -80,22 +79,23 @@ class _GameCompletionScreenState extends State<GameCompletionScreen> {
|
||||
userId: widget.args.userId,
|
||||
gameType: widget.args.gameType,
|
||||
contextId: widget.args.contextId,
|
||||
playerName: playerName, // 👈 [수정]
|
||||
playerName: playerName,
|
||||
primaryScore: widget.args.primaryScore,
|
||||
secondaryScore: widget.args.secondaryScore,
|
||||
);
|
||||
|
||||
try {
|
||||
// 1. 랭킹 등록
|
||||
final RankSubmissionResult result = await _puzzleService.submitRank(rankDto);
|
||||
|
||||
// 2. 이름 저장 (공통)
|
||||
// [수정] 게스트일 때만 이름을 저장 (소셜 로그인은 이미 이름이 있음)
|
||||
if (autoSubmitName == null) {
|
||||
await _identityService.saveUserName(playerName);
|
||||
}
|
||||
|
||||
// 3. 게임별 후속 처리 (레벨 잠금 해제 등)
|
||||
// 🔽 [수정]
|
||||
// onProgressSave는 initState에서 이미 호출되었지만,
|
||||
// saveMaxUnlockedLevel 함수 자체가 멱등성(Idempotent)을 가지므로
|
||||
// (이미 레벨이 6인데 6으로 덮어써도 문제없음)
|
||||
// 혹시 모를 실패에 대비해 여기서 한 번 더 호출해도 안전합니다.
|
||||
await widget.args.onProgressSave(playerName);
|
||||
|
||||
setState(() {
|
||||
@@ -108,7 +108,6 @@ class _GameCompletionScreenState extends State<GameCompletionScreen> {
|
||||
log("!!! 랭킹 등록 실패 !!!", error: e);
|
||||
setState(() {
|
||||
_rankStep = _RankSubmissionStep.enterName;
|
||||
// 🔽 [수정] 게스트가 아닐 때 실패하면, 이름 입력창 대신 리스트로 보냄
|
||||
if (autoSubmitName != null) {
|
||||
_rankStep = _RankSubmissionStep.showList;
|
||||
}
|
||||
@@ -119,18 +118,16 @@ class _GameCompletionScreenState extends State<GameCompletionScreen> {
|
||||
|
||||
/// 닫기 버튼 로직
|
||||
void _closeScreen() {
|
||||
// 1. 이 팝업 화면을 닫고
|
||||
// 이 화면만 닫습니다. (GameScreen으로 돌아감)
|
||||
Navigator.of(context).pop();
|
||||
// 2. 이전 화면(게임 화면)을 닫도록 콜백 호출
|
||||
widget.args.onScreenClose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// ... (이하 UI 빌드 로직은 모두 동일) ...
|
||||
final theme = Theme.of(context);
|
||||
|
||||
// --- UI 섹션 정의 (스도쿠/스파이더와 동일) ---
|
||||
|
||||
// --- UI 섹션 정의 ---
|
||||
Widget topRankListWidget = _rankingList.isEmpty
|
||||
? const Center(child: Text("현재 랭킹이 없습니다."))
|
||||
: ListView.builder(
|
||||
@@ -139,25 +136,14 @@ class _GameCompletionScreenState extends State<GameCompletionScreen> {
|
||||
itemBuilder: (context, index) {
|
||||
final rank = _rankingList[index];
|
||||
final bool isMe = rank.playerName == _submittedPlayerName;
|
||||
|
||||
// [수정] 점수 포맷터를 주입받은 함수로 대체
|
||||
final String scoreText = widget.args.scoreFormatter(
|
||||
rank.primaryScore,
|
||||
rank.secondaryScore
|
||||
);
|
||||
final String scoreText = widget.args.scoreFormatter(rank.primaryScore, rank.secondaryScore);
|
||||
|
||||
return ListTile(
|
||||
selected: isMe,
|
||||
selectedTileColor: theme.primaryColor.withOpacity(0.1),
|
||||
leading: Text('${index + 1}.', style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
title: Text(rank.playerName, style: TextStyle(fontWeight: isMe ? FontWeight.bold : FontWeight.normal)),
|
||||
trailing: Text(
|
||||
scoreText,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.textTheme.bodyMedium?.color?.withOpacity(0.9)
|
||||
)
|
||||
),
|
||||
trailing: Text(scoreText, style: TextStyle(fontWeight: FontWeight.bold, color: theme.textTheme.bodyMedium?.color?.withOpacity(0.9))),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -166,18 +152,10 @@ class _GameCompletionScreenState extends State<GameCompletionScreen> {
|
||||
if (_myRankResult != null) {
|
||||
final myRank = _myRankResult!.rankData;
|
||||
final myRankNum = _myRankResult!.rankNumber;
|
||||
|
||||
bool isMeInTop10 = _rankingList.any(
|
||||
(topRank) => topRank.playerName == myRank.playerName
|
||||
);
|
||||
bool isMeInTop10 = _rankingList.any((topRank) => topRank.playerName == myRank.playerName);
|
||||
|
||||
if (!isMeInTop10) {
|
||||
// [수정] 점수 포맷터를 주입받은 함수로 대체
|
||||
final String scoreText = widget.args.scoreFormatter(
|
||||
myRank.primaryScore,
|
||||
myRank.secondaryScore
|
||||
);
|
||||
|
||||
final String scoreText = widget.args.scoreFormatter(myRank.primaryScore, myRank.secondaryScore);
|
||||
myRankWidget = Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: ListTile(
|
||||
@@ -185,13 +163,7 @@ class _GameCompletionScreenState extends State<GameCompletionScreen> {
|
||||
selectedTileColor: theme.primaryColor.withOpacity(0.1),
|
||||
leading: Text('$myRankNum.', style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
title: Text(myRank.playerName, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
trailing: Text(
|
||||
scoreText,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.textTheme.bodyMedium?.color?.withOpacity(0.9)
|
||||
)
|
||||
),
|
||||
trailing: Text(scoreText, style: TextStyle(fontWeight: FontWeight.bold, color: theme.textTheme.bodyMedium?.color?.withOpacity(0.9))),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -215,16 +187,12 @@ class _GameCompletionScreenState extends State<GameCompletionScreen> {
|
||||
Widget nameEntryWidget = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// [수정] 게임별 점수 표시 대신 범용 텍스트
|
||||
Text(
|
||||
'축하합니다! 랭킹에 등록할 이름을 입력하세요.',
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
Text('축하합니다! 랭킹에 등록할 이름을 입력하세요.', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 20),
|
||||
TextField(
|
||||
controller: _nameController,
|
||||
autofocus: true,
|
||||
maxLength: 20, // [수정] 10 -> 20
|
||||
maxLength: 20,
|
||||
decoration: InputDecoration(
|
||||
labelText: '이름 (20자 이내)',
|
||||
border: const OutlineInputBorder(),
|
||||
@@ -235,7 +203,6 @@ class _GameCompletionScreenState extends State<GameCompletionScreen> {
|
||||
);
|
||||
|
||||
// --- 상태에 따라 UI와 버튼 결정 ---
|
||||
|
||||
Widget content;
|
||||
List<Widget> actions = [];
|
||||
String titleText;
|
||||
@@ -244,37 +211,26 @@ class _GameCompletionScreenState extends State<GameCompletionScreen> {
|
||||
titleText = '🎉 게임 완료!';
|
||||
content = nameEntryWidget;
|
||||
actions = [
|
||||
TextButton(
|
||||
onPressed: _closeScreen, // 닫기
|
||||
child: const Text('나중에 하기'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => _submitRank(), // 👈 [수정] 인자 없이 호출
|
||||
child: const Text('랭킹 등록'),
|
||||
),
|
||||
TextButton(onPressed: _closeScreen, child: const Text('나중에 하기')),
|
||||
ElevatedButton(onPressed: () => _submitRank(), child: const Text('랭킹 등록')),
|
||||
];
|
||||
}
|
||||
else if (_rankStep == _RankSubmissionStep.submitting) {
|
||||
titleText = '랭킹 등록 중...';
|
||||
content = const Center(child: CircularProgressIndicator());
|
||||
// 로딩 중에는 버튼 없음
|
||||
}
|
||||
else { // _RankSubmissionStep.showList
|
||||
titleText = '🏆 랭킹 (${widget.args.contextId})';
|
||||
content = rankDisplaySection;
|
||||
actions = [
|
||||
TextButton(
|
||||
onPressed: _closeScreen, // 닫기
|
||||
child: const Text('닫기'),
|
||||
)
|
||||
TextButton(onPressed: _closeScreen, child: const Text('닫기')),
|
||||
];
|
||||
}
|
||||
|
||||
// [수정] AlertDialog가 아닌 전체 화면 Scaffold로 변경
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(titleText),
|
||||
automaticallyImplyLeading: false, // 뒤로가기 버튼 숨김
|
||||
automaticallyImplyLeading: false,
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
|
||||
/pubspec.lock
|
||||
**/doc/api/
|
||||
.dart_tool/
|
||||
.flutter-plugins-dependencies
|
||||
/build/
|
||||
/coverage/
|
||||
@@ -0,0 +1,10 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "adc901062556672b4138e18a4dc62a4be8f4b3c2"
|
||||
channel: "stable"
|
||||
|
||||
project_type: package
|
||||
@@ -0,0 +1,3 @@
|
||||
## 0.0.1
|
||||
|
||||
* TODO: Describe initial release.
|
||||
@@ -0,0 +1 @@
|
||||
TODO: Add your license here.
|
||||
@@ -0,0 +1,39 @@
|
||||
<!--
|
||||
This README describes the package. If you publish this package to pub.dev,
|
||||
this README's contents appear on the landing page for your package.
|
||||
|
||||
For information about how to write a good package README, see the guide for
|
||||
[writing package pages](https://dart.dev/tools/pub/writing-package-pages).
|
||||
|
||||
For general information about developing packages, see the Dart guide for
|
||||
[creating packages](https://dart.dev/guides/libraries/create-packages)
|
||||
and the Flutter guide for
|
||||
[developing packages and plugins](https://flutter.dev/to/develop-packages).
|
||||
-->
|
||||
|
||||
TODO: Put a short description of the package here that helps potential users
|
||||
know whether this package might be useful for them.
|
||||
|
||||
## Features
|
||||
|
||||
TODO: List what your package can do. Maybe include images, gifs, or videos.
|
||||
|
||||
## Getting started
|
||||
|
||||
TODO: List prerequisites and provide or point to information on how to
|
||||
start using the package.
|
||||
|
||||
## Usage
|
||||
|
||||
TODO: Include short and useful examples for package users. Add longer examples
|
||||
to `/example` folder.
|
||||
|
||||
```dart
|
||||
const like = 'sample';
|
||||
```
|
||||
|
||||
## Additional information
|
||||
|
||||
TODO: Tell users more about the package: where to find more information, how to
|
||||
contribute to the package, how to file issues, what response they can expect
|
||||
from the package authors, and more.
|
||||
@@ -0,0 +1,4 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
@@ -0,0 +1,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),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
name: feature_game_mathquiz
|
||||
description: "A new Flutter package project."
|
||||
version: 0.0.1
|
||||
homepage:
|
||||
|
||||
environment:
|
||||
sdk: ^3.9.2
|
||||
flutter: ">=1.17.0"
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
# 🔽 [추가] 컨트롤러(ChangeNotifier)를 위한 Provider
|
||||
provider: ^6.1.2 # (버전은 최신 버전 사용)
|
||||
|
||||
# 🔽 [추가] 공통 서비스 (난이도, 랭킹, 세션)
|
||||
service_api:
|
||||
path: ../service_api # 👈 부모 폴더의 service_api 패키지
|
||||
|
||||
# 🔽 [추가] 공통 UI (셸, 랭킹 화면)
|
||||
feature_common:
|
||||
path: ../feature_common # 👈 부모 폴더의 feature_common 패키지
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^5.0.0
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
|
||||
# The following section is specific to Flutter packages.
|
||||
flutter:
|
||||
|
||||
# To add assets to your package, add an assets section, like this:
|
||||
# assets:
|
||||
# - images/a_dot_burr.jpeg
|
||||
# - images/a_dot_ham.jpeg
|
||||
#
|
||||
# For details regarding assets in packages, see
|
||||
# https://flutter.dev/to/asset-from-package
|
||||
#
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/to/resolution-aware-images
|
||||
|
||||
# To add custom fonts to your package, add a fonts section here,
|
||||
# in this "flutter" section. Each entry in this list should have a
|
||||
# "family" key with the font family name, and a "fonts" key with a
|
||||
# list giving the asset and other descriptors for the font. For
|
||||
# example:
|
||||
# fonts:
|
||||
# - family: Schyler
|
||||
# fonts:
|
||||
# - asset: fonts/Schyler-Regular.ttf
|
||||
# - asset: fonts/Schyler-Italic.ttf
|
||||
# style: italic
|
||||
# - family: Trajan Pro
|
||||
# fonts:
|
||||
# - asset: fonts/TrajanPro.ttf
|
||||
# - asset: fonts/TrajanPro_Bold.ttf
|
||||
# weight: 700
|
||||
#
|
||||
# For details regarding fonts in packages, see
|
||||
# https://flutter.dev/to/font-from-package
|
||||
@@ -0,0 +1,12 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:feature_game_mathquiz/feature_game_mathquiz.dart';
|
||||
|
||||
void main() {
|
||||
test('adds one to input values', () {
|
||||
final calculator = Calculator();
|
||||
expect(calculator.addOne(2), 3);
|
||||
expect(calculator.addOne(-7), -6);
|
||||
expect(calculator.addOne(0), 1);
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
// packages/feature_game_spider/lib/screens/spider_game_screen.dart
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -12,8 +11,6 @@ import '../widgets/tableau_pile_widget.dart';
|
||||
import '../widgets/bottom_bar_widget.dart';
|
||||
import '../widgets/card_widget.dart';
|
||||
|
||||
// ❌ [삭제] enum _RankSubmissionStep
|
||||
|
||||
class SpiderGameScreen extends StatefulWidget {
|
||||
const SpiderGameScreen({super.key});
|
||||
|
||||
@@ -34,7 +31,9 @@ class _SpiderGameScreenState extends State<SpiderGameScreen> {
|
||||
bool _isDealAnimationRunning = false;
|
||||
bool _isStackAnimationRunning = false;
|
||||
|
||||
// ( _buildGameAppBar, _showSurrenderDialog 는 동일 )
|
||||
// ( ... _buildGameAppBar, _showSurrenderDialog, initState, dispose ... )
|
||||
// ( ... build, _runDealAnimation, _runStackCompletionAnimation ... )
|
||||
// ( ... 이 메서드들은 모두 동일합니다 ... )
|
||||
AppBar _buildGameAppBar(BuildContext context, SpiderGameController controller) {
|
||||
return AppBar(
|
||||
leading: IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.of(context).pop()),
|
||||
@@ -89,47 +88,33 @@ class _SpiderGameScreenState extends State<SpiderGameScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final controller = Provider.of<SpiderGameController>(context, listen: false);
|
||||
|
||||
_controllerListener = () {
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
const double horizontalPadding = 10;
|
||||
const double cardGap = 5;
|
||||
final double cardWidth = (screenSize.width - (horizontalPadding * 2) - (cardGap * 9)) / 10;
|
||||
final double cardHeight = cardWidth * 1.45;
|
||||
|
||||
// 🔽 [수정] 덱 분배 애니메이션 (플래그 가드 추가)
|
||||
if (controller.cardsToDealAnimate.isNotEmpty && !_isDealAnimationRunning) {
|
||||
_isDealAnimationRunning = true; // 👈 [잠금]
|
||||
_isDealAnimationRunning = true;
|
||||
debugPrint("[LOG] initState Listener: Detected cardsToDealAnimate. Running animation...");
|
||||
_runDealAnimation(controller, cardWidth, cardHeight);
|
||||
}
|
||||
|
||||
// 🔽 [수정] 스택 완성 애니메이션 (경주 조건 해결 로직)
|
||||
if (controller.cardsToAnimateStack.isNotEmpty && !_isStackAnimationRunning) {
|
||||
_isStackAnimationRunning = true; // 👈 [잠금]
|
||||
|
||||
// [핵심] 큐를 복사하고, 인덱스도 *지금* 읽어서 복사합니다.
|
||||
_isStackAnimationRunning = true;
|
||||
final List<SpiderCard> cardsToAnimate = List.of(controller.cardsToAnimateStack);
|
||||
final int sourceIndex = controller.animationSourcePileIndex;
|
||||
final int targetIndex = controller.animationTargetFoundationIndex;
|
||||
|
||||
// 큐를 즉시 비웁니다.
|
||||
controller.clearStackAnimationTrigger();
|
||||
|
||||
debugPrint("[LOG] initState Listener: Detected cardsToAnimateStack (Source: $sourceIndex). Running animation...");
|
||||
// 복사한 데이터를 인자로 전달합니다.
|
||||
_runStackCompletionAnimation(controller, cardWidth, cardHeight, cardsToAnimate, sourceIndex, targetIndex);
|
||||
}
|
||||
};
|
||||
|
||||
controller.addListener(_controllerListener!);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_controllerListener != null) {
|
||||
@@ -138,20 +123,16 @@ class _SpiderGameScreenState extends State<SpiderGameScreen> {
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
debugPrint("[LOG] SpiderGameScreen: --- Main Build Method CALLED ---");
|
||||
|
||||
final controller = context.read<SpiderGameController>();
|
||||
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
const double horizontalPadding = 10;
|
||||
const double cardGap = 5;
|
||||
final double cardWidth = (screenSize.width - (horizontalPadding * 2) - (cardGap * 9)) / 10;
|
||||
final double cardHeight = cardWidth * 1.45;
|
||||
final double cardOverlap = cardHeight * 0.4;
|
||||
|
||||
final bool isGameCompleted = context.select((SpiderGameController c) => c.isGameCompleted);
|
||||
if (isGameCompleted && !_isDialogShowing) {
|
||||
_isDialogShowing = true;
|
||||
@@ -161,7 +142,6 @@ class _SpiderGameScreenState extends State<SpiderGameScreen> {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: _buildGameAppBar(context, controller),
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
@@ -209,54 +189,42 @@ class _SpiderGameScreenState extends State<SpiderGameScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
if (_showDimOverlay)
|
||||
Container(
|
||||
color: Colors.black.withOpacity(0.5),
|
||||
),
|
||||
|
||||
..._animationOverlays,
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// 🔽 덱 분배 애니메이션 (오버레이)
|
||||
void _runDealAnimation(
|
||||
SpiderGameController controller,
|
||||
double cardWidth,
|
||||
double cardHeight,
|
||||
) {
|
||||
// 🔽 [수정] 덱 분배 애니메이션도 경주 조건을 피하기 위해 인자로 받도록 수정
|
||||
final List<SpiderCard> cardsToDeal = List.of(controller.cardsToDealAnimate);
|
||||
controller.clearDealAnimationTrigger();
|
||||
|
||||
debugPrint("[LOG] _runDealAnimation: Starting. ${cardsToDeal.length} cards.");
|
||||
|
||||
final RenderBox? bodyStackBox = _bodyStackKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
final RenderBox? stockBox = _stockKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (bodyStackBox == null || stockBox == null) {
|
||||
debugPrint("[LOG] _runDealAnimation: FAILED (Keys not ready)");
|
||||
_isDealAnimationRunning = false; // 👈 [잠금 해제]
|
||||
_isDealAnimationRunning = false;
|
||||
return;
|
||||
}
|
||||
|
||||
final Offset globalStartPos = stockBox.localToGlobal(Offset.zero);
|
||||
final Offset localStartPos = bodyStackBox.globalToLocal(globalStartPos);
|
||||
|
||||
for (int i = 0; i < cardsToDeal.length; i++) {
|
||||
final card = cardsToDeal[i];
|
||||
final RenderBox? targetBox = _tableauKeys[i].currentContext?.findRenderObject() as RenderBox?;
|
||||
if (targetBox == null) continue;
|
||||
|
||||
final Offset globalEndPos = targetBox.localToGlobal(Offset.zero);
|
||||
final double targetY = globalEndPos.dy + controller.currentState.tableau[i].length * (cardHeight * 0.4);
|
||||
final Offset localEndPos = bodyStackBox.globalToLocal(Offset(globalEndPos.dx, targetY));
|
||||
|
||||
final animationDelayMs = i * 100;
|
||||
final animationDurationMs = 600;
|
||||
|
||||
final overlayEntry = TweenAnimationBuilder<double>(
|
||||
key: ValueKey('deal_${card.id}'),
|
||||
tween: Tween(begin: 0.0, end: 1.0),
|
||||
@@ -265,7 +233,6 @@ class _SpiderGameScreenState extends State<SpiderGameScreen> {
|
||||
final currentPos = Offset.lerp(localStartPos, localEndPos, value)!;
|
||||
final bool isFlipping = value > 0.5;
|
||||
final double rotationY = isFlipping ? (value - 0.5) * 2 * pi : 0;
|
||||
|
||||
return Positioned(
|
||||
left: currentPos.dx,
|
||||
top: currentPos.dy,
|
||||
@@ -284,23 +251,16 @@ class _SpiderGameScreenState extends State<SpiderGameScreen> {
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Future.delayed(Duration(milliseconds: animationDelayMs), () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_animationOverlays.add(overlayEntry);
|
||||
});
|
||||
|
||||
setState(() { _animationOverlays.add(overlayEntry); });
|
||||
Future.delayed(Duration(milliseconds: animationDurationMs), () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_animationOverlays.remove(overlayEntry);
|
||||
});
|
||||
|
||||
setState(() { _animationOverlays.remove(overlayEntry); });
|
||||
if (i == cardsToDeal.length - 1) {
|
||||
debugPrint("[LOG] _runDealAnimation: Animation FINISHED. Calling finalizeDealFromStock.");
|
||||
controller.finalizeDealFromStock(cardsToDeal);
|
||||
_isDealAnimationRunning = false; // 👈 [잠금 해제]
|
||||
_isDealAnimationRunning = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -308,44 +268,32 @@ class _SpiderGameScreenState extends State<SpiderGameScreen> {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔽 스택 완성 애니메이션 (오버레이)
|
||||
void _runStackCompletionAnimation(
|
||||
SpiderGameController controller,
|
||||
double cardWidth,
|
||||
double cardHeight,
|
||||
// 🔽 [수정] 인자를 받습니다.
|
||||
List<SpiderCard> cardsToAnimate,
|
||||
int sourceIndex,
|
||||
int targetIndex,
|
||||
) {
|
||||
debugPrint("[LOG] _runStackCompletionAnimation: Starting. ${cardsToAnimate.length} cards from index $sourceIndex.");
|
||||
|
||||
final RenderBox? bodyStackBox = _bodyStackKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
final RenderBox? stockBox = _stockKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
// 🔽 [수정] 인자로 받은 sourceIndex 사용
|
||||
final RenderBox? startBox = _tableauKeys[sourceIndex].currentContext?.findRenderObject() as RenderBox?;
|
||||
|
||||
if (bodyStackBox == null || stockBox == null || startBox == null) {
|
||||
debugPrint("[LOG] _runStackCompletionAnimation: FAILED (Keys not ready for index $sourceIndex)");
|
||||
_isStackAnimationRunning = false; // 👈 [잠금 해제]
|
||||
_isStackAnimationRunning = false;
|
||||
return;
|
||||
}
|
||||
|
||||
final Offset globalStartPos = startBox.localToGlobal(Offset.zero);
|
||||
final Offset localStartPos = bodyStackBox.globalToLocal(globalStartPos);
|
||||
// 🔽 [수정] 스택이 제거되기 '전'의 길이를 기준으로 계산 (정확)
|
||||
final double startY = localStartPos.dy + (controller.currentState.tableau[sourceIndex].length - cardsToAnimate.length) * (cardHeight * 0.4);
|
||||
|
||||
// 🔽 [수정] 인자로 받은 targetIndex 사용
|
||||
final Offset globalEndPos = stockBox.localToGlobal(Offset( (targetIndex * (cardWidth * 0.15)) - cardWidth*3, 10));
|
||||
final Offset localEndPos = bodyStackBox.globalToLocal(globalEndPos);
|
||||
|
||||
for (int i = 0; i < cardsToAnimate.length; i++) {
|
||||
final card = cardsToAnimate[i];
|
||||
final animationDelayMs = i * 80;
|
||||
final animationDurationMs = 400;
|
||||
|
||||
final overlayEntry = TweenAnimationBuilder<double>(
|
||||
key: ValueKey('stack_${card.id}'),
|
||||
tween: Tween(begin: 0.0, end: 1.0),
|
||||
@@ -364,23 +312,16 @@ class _SpiderGameScreenState extends State<SpiderGameScreen> {
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Future.delayed(Duration(milliseconds: animationDelayMs), () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_animationOverlays.add(overlayEntry);
|
||||
});
|
||||
|
||||
setState(() { _animationOverlays.add(overlayEntry); });
|
||||
Future.delayed(Duration(milliseconds: animationDurationMs), () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_animationOverlays.remove(overlayEntry);
|
||||
});
|
||||
setState(() { _animationOverlays.remove(overlayEntry); });
|
||||
if (i == cardsToAnimate.length - 1) {
|
||||
debugPrint("[LOG] _runStackCompletionAnimation: Animation FINISHED. Calling finalizeStackCompletion for index $sourceIndex.");
|
||||
// 🔽 [수정] finalize가 어떤 스택을 처리할지 인덱스를 전달
|
||||
controller.finalizeStackCompletion(cardsToAnimate, sourceIndex);
|
||||
_isStackAnimationRunning = false; // 👈 [잠금 해제]
|
||||
_isStackAnimationRunning = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -388,8 +329,8 @@ class _SpiderGameScreenState extends State<SpiderGameScreen> {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔽 [수정] _runGameWinAnimation (팝업 호출 로직 변경)
|
||||
|
||||
/// 🔽 [수정] _runGameWinAnimation (Navigation 로직 변경)
|
||||
void _runGameWinAnimation(
|
||||
BuildContext context,
|
||||
SpiderGameController controller,
|
||||
@@ -405,12 +346,12 @@ class _SpiderGameScreenState extends State<SpiderGameScreen> {
|
||||
final Offset localStartPos = bodyStackBox.globalToLocal(globalStartPos);
|
||||
|
||||
for (int i = 0; i < allCards.length; i++) {
|
||||
// ... (애니메이션 오버레이 생성 로직은 동일) ...
|
||||
final card = allCards[i];
|
||||
final animationDelay = Duration(milliseconds: i * 30);
|
||||
final animationDuration = const Duration(milliseconds: 1500);
|
||||
final Offset globalEndPos = Offset(random.nextDouble() * screenSize.width, -cardHeight - (AppBar().preferredSize.height));
|
||||
final Offset localEndPos = bodyStackBox.globalToLocal(globalEndPos);
|
||||
|
||||
final overlayEntry = TweenAnimationBuilder<double>(
|
||||
key: ValueKey('win_${card.id}'),
|
||||
tween: Tween(begin: 0.0, end: 1.0),
|
||||
@@ -432,41 +373,28 @@ class _SpiderGameScreenState extends State<SpiderGameScreen> {
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Future.delayed(animationDelay, () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_animationOverlays.add(overlayEntry);
|
||||
});
|
||||
|
||||
// ❌ [삭제] 500ms 후에 팝업을 띄우는 로직
|
||||
// if (i == 0) { ... }
|
||||
|
||||
setState(() { _animationOverlays.add(overlayEntry); });
|
||||
Future.delayed(animationDuration, () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_animationOverlays.remove(overlayEntry);
|
||||
});
|
||||
setState(() { _animationOverlays.remove(overlayEntry); });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 🔽 [추가] 딤 오버레이(배경 어두워짐)는 500ms 뒤에 바로 표시
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
if (mounted && controller.isGameCompleted) {
|
||||
setState(() { _showDimOverlay = true; });
|
||||
}
|
||||
});
|
||||
|
||||
// 🔽 [추가] 랭킹 팝업은 약 2초 뒤 표시
|
||||
final popupDelay = (allCards.length > 70) ? const Duration(seconds: 2) : const Duration(milliseconds: 500);
|
||||
Future.delayed(popupDelay, () {
|
||||
Future.delayed(popupDelay, () async { // 👈 [수정] async 추가
|
||||
if (mounted && controller.isGameCompleted) {
|
||||
|
||||
// [수정] _showGameCompletedDialog() 호출 대신 공통 화면으로 이동
|
||||
|
||||
// 1. 점수 포맷터 정의
|
||||
String formatSpiderScore(int primary, int? secondary) {
|
||||
final moves = primary.toString();
|
||||
@@ -476,10 +404,7 @@ class _SpiderGameScreenState extends State<SpiderGameScreen> {
|
||||
|
||||
// 2. 레벨 저장 콜백 정의
|
||||
Future<void> saveSpiderProgress(String playerName) async {
|
||||
// (playerName은 공통 화면이 IdentityService로 저장)
|
||||
|
||||
final identityService = IdentityService();
|
||||
|
||||
final int currentMaxLevel = await identityService.getMaxUnlockedLevel(gameType: 'SPIDER');
|
||||
if (currentMaxLevel < 99) {
|
||||
if (controller.difficulty.levelIndex >= currentMaxLevel) {
|
||||
@@ -493,8 +418,8 @@ class _SpiderGameScreenState extends State<SpiderGameScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 화면 이동
|
||||
Navigator.pushReplacement(
|
||||
// 3. [수정] 'pushReplacement' 대신 'await push' 사용
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => GameCompletionScreen(
|
||||
@@ -507,16 +432,16 @@ class _SpiderGameScreenState extends State<SpiderGameScreen> {
|
||||
userName: controller.userName,
|
||||
scoreFormatter: formatSpiderScore,
|
||||
onProgressSave: saveSpiderProgress,
|
||||
onScreenClose: () {
|
||||
// 공통 화면에서 '닫기'를 누르면 게임 화면도 닫힘
|
||||
if (Navigator.canPop(context)) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
// ❌ onScreenClose 제거
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// 4. [추가] 랭킹 화면에서 돌아오면, 게임 화면(self)을 닫고 로비로 돌아감
|
||||
if (mounted && Navigator.canPop(context)) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import 'dart:developer';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:service_api/service_api.dart';
|
||||
import 'package:service_api/service_api.dart'; // 👈 SessionNotifier 포함
|
||||
import 'package:feature_common/feature_common.dart';
|
||||
import 'spider_game_screen.dart';
|
||||
import '../models/spider_difficulty.dart';
|
||||
@@ -17,24 +17,42 @@ class SpiderLobbyScreen extends StatefulWidget {
|
||||
class _SpiderLobbyScreenState extends State<SpiderLobbyScreen> {
|
||||
int _maxUnlockedLevel = 1;
|
||||
Map<int, (int, int)> _rankHistory = {};
|
||||
String? _userName;
|
||||
// ❌ String? _userName; (SessionNotifier가 관리)
|
||||
bool _isLoading = false;
|
||||
|
||||
// 🔽 [수정] SessionNotifier를 사용하기 위해 IdentityService 대신 추가
|
||||
late final SessionNotifier _sessionNotifier;
|
||||
final PuzzleService _puzzleService = PuzzleService();
|
||||
final IdentityService _identityService = IdentityService();
|
||||
final IdentityService _identityService = IdentityService(); // 👈 (save/load progress를 위해 유지)
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadProgress();
|
||||
// 🔽 [수정] initState에서 SessionNotifier를 read
|
||||
// SessionNotifier의 loadSession()이 먼저 완료되었다고 가정
|
||||
_sessionNotifier = context.read<SessionNotifier>();
|
||||
|
||||
// 🔽 [수정] _loadProgress가 랭킹까지 모두 새로고침 (최초 1회)
|
||||
_loadProgress(forceRefreshRanks: true);
|
||||
}
|
||||
|
||||
// ( _loadProgress 메서드는 이전과 동일 )
|
||||
Future<void> _loadProgress() async {
|
||||
/// 🔽 [수정] _loadProgress 메서드 (SessionNotifier 사용 및 로직 분리)
|
||||
Future<void> _loadProgress({bool forceRefreshRanks = false}) async {
|
||||
// 1. (가벼움) 레벨 정보 새로고침
|
||||
final maxLevel = await _identityService.getMaxUnlockedLevel(gameType: 'SPIDER');
|
||||
final String? myName = await _identityService.getSavedUserName();
|
||||
if (mounted) { setState(() { _maxUnlockedLevel = maxLevel; _userName = myName; }); }
|
||||
if (myName == null) return;
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_maxUnlockedLevel = maxLevel;
|
||||
});
|
||||
}
|
||||
|
||||
// 2. (무거움) 랭킹 정보 새로고침 (필요할 때만)
|
||||
if (!forceRefreshRanks) return;
|
||||
|
||||
// 🔽 [수정] _sessionNotifier에서 유저 이름을 가져옴
|
||||
final String? myName = _sessionNotifier.session?.userName;
|
||||
if (myName == null) return; // 게스트이거나 아직 이름 저장을 안 함
|
||||
|
||||
try {
|
||||
final Map<int, int> oldRankMap = await _identityService.getLastSavedRankMap(gameType: 'SPIDER');
|
||||
List<Future<List<GameRankDto>>> rankFutures = [];
|
||||
@@ -64,13 +82,20 @@ class _SpiderLobbyScreenState extends State<SpiderLobbyScreen> {
|
||||
}
|
||||
|
||||
|
||||
/// 🔽 [수정] _startGame 메서드 (UserInfo 주입)
|
||||
/// 🔽 [수정] _startGame 메서드 (SessionNotifier 사용)
|
||||
Future<void> _startGame(SpiderDifficulty level) async {
|
||||
setState(() { _isLoading = true; });
|
||||
|
||||
// 1. [수정] 랭킹 등록에 필요한 정보 미리 로드
|
||||
final String userId = await _identityService.getOrCreateUserId();
|
||||
final String? userName = _userName; // (이미 _loadProgress에서 로드됨)
|
||||
// 1. [수정] SessionNotifier에서 유저 정보 가져오기
|
||||
final session = _sessionNotifier.session;
|
||||
if (session == null) {
|
||||
log("세션이 로드되지 않아 게임을 시작할 수 없습니다.");
|
||||
setState(() { _isLoading = false; });
|
||||
return;
|
||||
}
|
||||
|
||||
final String userId = session.userId;
|
||||
final String? userName = session.userName;
|
||||
|
||||
// 2. 컨트롤러 생성 및 새 게임 시작
|
||||
final gameController = SpiderGameController();
|
||||
@@ -91,36 +116,27 @@ class _SpiderLobbyScreenState extends State<SpiderLobbyScreen> {
|
||||
),
|
||||
);
|
||||
|
||||
_loadProgress();
|
||||
// 🔽 [핵심 수정]
|
||||
// 랭킹(forceRefreshRanks: true)은 새로고침하지 않고,
|
||||
// 레벨 잠금 상태(forceRefreshRanks: false)만 새로고침합니다.
|
||||
_loadProgress(forceRefreshRanks: false);
|
||||
}
|
||||
|
||||
// ( build 메서드는 이전과 동일 )
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 🔽 [수정] ThemeNotifier와 SessionNotifier를 모두 watch
|
||||
context.watch<ThemeNotifier>();
|
||||
context.watch<SessionNotifier>(); // 👈 세션 변경(로그인/로그아웃) 감지
|
||||
|
||||
final bool allLevelsUnlocked = _maxUnlockedLevel >= 9;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return CommonGameShell(
|
||||
title: '스파이더 솔리테어',
|
||||
onRankingPressed: () {
|
||||
final List<GameDifficulty> spiderDifficulties = SpiderDifficulties.allDifficulties
|
||||
.map((level) => GameDifficulty(
|
||||
name: level.name,
|
||||
contextId: level.contextId,
|
||||
))
|
||||
.toList();
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => RankingScreen(
|
||||
gameType: 'SPIDER',
|
||||
difficulties: spiderDifficulties,
|
||||
initialDifficultyName: SpiderDifficulties.getLevel(_maxUnlockedLevel).name,
|
||||
),
|
||||
),
|
||||
);
|
||||
// ... (랭킹 버튼 로직 동일)
|
||||
},
|
||||
// 🔽 [수정] RefreshIndicator 추가 (당겨서 랭킹 새로고침)
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
const double maxContentRatio = 0.6;
|
||||
@@ -132,66 +148,70 @@ class _SpiderLobbyScreenState extends State<SpiderLobbyScreen> {
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: SpiderDifficulties.allDifficulties.length,
|
||||
itemBuilder: (context, index) {
|
||||
final SpiderDifficulty level = SpiderDifficulties.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);
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () => _loadProgress(forceRefreshRanks: true),
|
||||
child: ListView.builder(
|
||||
itemCount: SpiderDifficulties.allDifficulties.length,
|
||||
itemBuilder: (context, index) {
|
||||
// ... (이하 ListTile 로직은 모두 동일)
|
||||
final SpiderDifficulty level = SpiderDifficulties.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.grey;
|
||||
trailingWidget = const Icon(Icons.check_circle_outline_rounded, color: Colors.grey, size: 28);
|
||||
subtitleText = "$rankStr (신규 진입)";
|
||||
subtitleColor = Colors.blue;
|
||||
trailingWidget = const Icon(Icons.new_releases_rounded, color: Colors.blue, size: 28);
|
||||
}
|
||||
} else {
|
||||
subtitleText = "$rankStr (신규 진입)";
|
||||
subtitleColor = Colors.blue;
|
||||
trailingWidget = const Icon(Icons.new_releases_rounded, color: Colors.blue, size: 28);
|
||||
if (oldRank > 0) {
|
||||
subtitleText = "랭킹 이탈 (이전 ${oldRank}위)";
|
||||
subtitleColor = Colors.orange;
|
||||
trailingWidget = const Icon(Icons.warning_amber_rounded, color: Colors.orange, 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)
|
||||
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,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
// packages/feature_game_sudoku/lib/screens/game_screen.dart
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
// [C] 서비스 import
|
||||
import 'package:service_api/service_api.dart';
|
||||
|
||||
// [A] 공통 위젯 import
|
||||
import 'package:feature_common/feature_common.dart';
|
||||
|
||||
// [B] 같은 패키지 내의 위젯 import
|
||||
import '../widgets/number_pad.dart';
|
||||
import '../widgets/sudoku_board.dart';
|
||||
import '../models/game_level.dart';
|
||||
|
||||
// ❌ [삭제] enum _RankSubmissionStep
|
||||
|
||||
class GameScreen extends StatefulWidget {
|
||||
final SudokuGameDto gameData;
|
||||
final String themeName;
|
||||
@@ -57,18 +50,12 @@ class _GameScreenState extends State<GameScreen> {
|
||||
int? selectedNumberPad;
|
||||
Set<int> incorrectCells = {};
|
||||
bool isValidating = false;
|
||||
|
||||
// ❌ [삭제] 랭킹 다이얼로그 전용 상태 변수
|
||||
// _RankSubmissionStep _rankStep = _RankSubmissionStep.enterName;
|
||||
// List<GameRankDto> _rankingList = [];
|
||||
// GameRankWithRankNumber? _myRankResult;
|
||||
// String _submittedPlayerName = "";
|
||||
|
||||
late final TransformationController _transformationController;
|
||||
|
||||
// ... ( _charToInt, _intToChar, initState, dispose, startTimer, onCellTapped, _checkIfBoardIsFull ... )
|
||||
// ... ( onNumberTapped, onUndoTapped, onHintTapped, _onRestartGameTapped, _onQuitGameTapped, _resetBoardZoom ... )
|
||||
// ... ( 이 함수들은 모두 동일합니다 )
|
||||
// ( ... _charToInt, _intToChar, initState, dispose, startTimer ... )
|
||||
// ( ... onCellTapped, _checkIfBoardIsFull, onNumberTapped, onUndoTapped ... )
|
||||
// ( ... onHintTapped, _onRestartGameTapped, _onQuitGameTapped, _resetBoardZoom ... )
|
||||
int _charToInt(String char) {
|
||||
if (char == '0') return 0;
|
||||
if (char.codeUnitAt(0) >= '1'.codeUnitAt(0) && char.codeUnitAt(0) <= '9'.codeUnitAt(0)) {
|
||||
@@ -79,103 +66,74 @@ class _GameScreenState extends State<GameScreen> {
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
String _intToChar(int num) {
|
||||
if (num == 0) return '0';
|
||||
if (num >= 1 && num <= 9) return num.toString();
|
||||
if (num >= 10 && num <= 35) return String.fromCharCode('A'.codeUnitAt(0) + (num - 10));
|
||||
return '?';
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
currentLevel = AppLevels.getLevel(widget.levelIndex);
|
||||
blockSize = currentLevel.blockSize;
|
||||
gridSize = blockSize * blockSize;
|
||||
|
||||
_transformationController = TransformationController();
|
||||
|
||||
String themeForThisGame = widget.themeName;
|
||||
bool isEasyMode = currentLevel.isSequentialNumbers || currentLevel.isSequentialLetters;
|
||||
|
||||
if (currentLevel.isSequentialNumbers) {
|
||||
themeForThisGame = AppThemes.numbers;
|
||||
} else if (currentLevel.isSequentialLetters) {
|
||||
themeForThisGame = AppThemes.letters;
|
||||
}
|
||||
|
||||
activeTheme = AppThemes.buildGameTheme(
|
||||
themeForThisGame,
|
||||
gridSize,
|
||||
isEasyMode: isEasyMode,
|
||||
);
|
||||
|
||||
activeTheme = AppThemes.buildGameTheme(themeForThisGame, gridSize, isEasyMode: isEasyMode);
|
||||
puzzleCells = widget.gameData.question.split('').map(_charToInt).toList();
|
||||
solutionCells = widget.gameData.solution.split('').map(_charToInt).toList();
|
||||
originalCells = widget.gameData.question.split('').map(_charToInt).toList();
|
||||
|
||||
startTimer();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
timer?.cancel();
|
||||
_transformationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void startTimer() {
|
||||
timer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
setState(() {
|
||||
secondsElapsed++;
|
||||
});
|
||||
setState(() { secondsElapsed++; });
|
||||
});
|
||||
}
|
||||
|
||||
void onCellTapped(int index) {
|
||||
if (originalCells[index] == 0) {
|
||||
|
||||
if (incorrectCells.isNotEmpty && !incorrectCells.contains(index)) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('틀린 값을 먼저 수정해주세요. (되돌리기 ↩)'),
|
||||
duration: Duration(seconds: 1),
|
||||
),
|
||||
const SnackBar(content: Text('틀린 값을 먼저 수정해주세요. (되돌리기 ↩)'), duration: Duration(seconds: 1)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
selectedIndex = index;
|
||||
|
||||
if (selectedNumberPad != null) {
|
||||
final int numberValue = selectedNumberPad!;
|
||||
puzzleCells[index] = numberValue;
|
||||
|
||||
if (numberValue != solutionCells[index]) {
|
||||
if (!incorrectCells.contains(index)) {
|
||||
if (score > 0) {
|
||||
score--;
|
||||
}
|
||||
if (score > 0) { score--; }
|
||||
incorrectCells.add(index);
|
||||
}
|
||||
} else {
|
||||
incorrectCells.remove(index);
|
||||
}
|
||||
|
||||
_checkIfBoardIsFull();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _checkIfBoardIsFull() {
|
||||
if (!puzzleCells.contains(0) && !isValidating) {
|
||||
_validateGame();
|
||||
}
|
||||
}
|
||||
|
||||
void onNumberTapped(int numberValue) {
|
||||
setState(() {
|
||||
if (selectedNumberPad == numberValue) {
|
||||
@@ -185,7 +143,6 @@ class _GameScreenState extends State<GameScreen> {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void onUndoTapped() {
|
||||
if (incorrectCells.isNotEmpty) {
|
||||
int errorIndex = incorrectCells.first;
|
||||
@@ -196,18 +153,14 @@ class _GameScreenState extends State<GameScreen> {
|
||||
});
|
||||
}
|
||||
else if (selectedIndex != null && originalCells[selectedIndex!] == 0) {
|
||||
setState(() {
|
||||
puzzleCells[selectedIndex!] = 0;
|
||||
});
|
||||
setState(() { puzzleCells[selectedIndex!] = 0; });
|
||||
}
|
||||
}
|
||||
|
||||
void onHintTapped() {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('힌트 기능은 준비 중입니다.')),
|
||||
);
|
||||
}
|
||||
|
||||
void _onRestartGameTapped() {
|
||||
setState(() {
|
||||
puzzleCells = originalCells.toList();
|
||||
@@ -216,21 +169,19 @@ class _GameScreenState extends State<GameScreen> {
|
||||
selectedNumberPad = null;
|
||||
score = 5;
|
||||
_resetBoardZoom();
|
||||
|
||||
timer?.cancel();
|
||||
secondsElapsed = 0;
|
||||
startTimer();
|
||||
});
|
||||
}
|
||||
|
||||
void _onQuitGameTapped() {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
void _resetBoardZoom() {
|
||||
_transformationController.value = Matrix4.identity();
|
||||
}
|
||||
|
||||
/// 🔽 [수정] _validateGame (Navigation 로직 변경)
|
||||
Future<void> _validateGame() async {
|
||||
if (isValidating) return;
|
||||
setState(() { isValidating = true; });
|
||||
@@ -246,9 +197,6 @@ class _GameScreenState extends State<GameScreen> {
|
||||
|
||||
if (result) {
|
||||
if(mounted) {
|
||||
// 🔽 [수정] _showRankingDialog() 호출 대신
|
||||
// 공통 게임 완료 화면(GameCompletionScreen)으로 이동
|
||||
|
||||
// 1. 점수 포맷터 정의
|
||||
String formatSudokuScore(int primary, int? secondary) {
|
||||
final min = (primary ~/ 60).toString().padLeft(2, '0');
|
||||
@@ -260,8 +208,6 @@ class _GameScreenState extends State<GameScreen> {
|
||||
|
||||
// 2. 레벨 저장 콜백 정의
|
||||
Future<void> saveSudokuProgress(String playerName) async {
|
||||
// (playerName은 공통 화면이 IdentityService로 저장하므로 여기선 사용 안 함)
|
||||
|
||||
final int currentMaxLevel = await _identityService.getMaxUnlockedLevel();
|
||||
if (currentMaxLevel < 99) {
|
||||
if (widget.levelIndex >= currentMaxLevel) {
|
||||
@@ -275,8 +221,8 @@ class _GameScreenState extends State<GameScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 화면 이동
|
||||
Navigator.pushReplacement(
|
||||
// 3. [수정] 'pushReplacement' 대신 'await push' 사용
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => GameCompletionScreen(
|
||||
@@ -289,16 +235,16 @@ class _GameScreenState extends State<GameScreen> {
|
||||
userName: widget.userName,
|
||||
scoreFormatter: formatSudokuScore,
|
||||
onProgressSave: saveSudokuProgress,
|
||||
onScreenClose: () {
|
||||
// 공통 화면에서 '닫기'를 누르면 게임 화면도 닫힘
|
||||
if (Navigator.canPop(context)) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
// ❌ onScreenClose 제거
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// 4. [추가] 랭킹 화면에서 돌아오면, 게임 화면(self)을 닫고 로비로 돌아감
|
||||
if (mounted && Navigator.canPop(context)) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if(mounted) {
|
||||
@@ -322,15 +268,13 @@ class _GameScreenState extends State<GameScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ [삭제] _showRankingDialog() 메서드 전체 (약 150줄) 삭제
|
||||
|
||||
// ( ... build, _buildPortraitLayout, _buildLandscapeLayout ... )
|
||||
// ( ... _buildGameInfoWidget, _buildSudokuBoardWidget, _buildControlPanelWidget ... )
|
||||
// ( ... 이 메서드들은 모두 동일합니다 ... )
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
context.watch<ThemeNotifier>();
|
||||
|
||||
String formattedTime =
|
||||
'${(secondsElapsed ~/ 60).toString().padLeft(2, '0')}:${(secondsElapsed % 60).toString().padLeft(2, '0')}';
|
||||
|
||||
String formattedTime = '${(secondsElapsed ~/ 60).toString().padLeft(2, '0')}:${(secondsElapsed % 60).toString().padLeft(2, '0')}';
|
||||
final Map<int, int> numberCounts = {};
|
||||
for (int i = 1; i <= gridSize; i++) { numberCounts[i] = 0; }
|
||||
for (int cellValue in puzzleCells) {
|
||||
@@ -338,7 +282,6 @@ class _GameScreenState extends State<GameScreen> {
|
||||
numberCounts[cellValue] = (numberCounts[cellValue] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
@@ -355,16 +298,14 @@ class _GameScreenState extends State<GameScreen> {
|
||||
},
|
||||
),
|
||||
),
|
||||
const AdBannerWidget(), // 👈 [A] feature_common의 AdBannerWidget
|
||||
const AdBannerWidget(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPortraitLayout(BuildContext context, Map<int, int> numberCounts, BoxConstraints constraints, String formattedTime) {
|
||||
final double boardWidth = (constraints.maxWidth > 600) ? 600 : constraints.maxWidth;
|
||||
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: boardWidth),
|
||||
@@ -399,26 +340,21 @@ class _GameScreenState extends State<GameScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLandscapeLayout(BuildContext context, Map<int, int> numberCounts, BoxConstraints constraints, String formattedTime) {
|
||||
const double infoBarHeight = 60.0;
|
||||
double boardWidth = constraints.maxHeight - infoBarHeight - 32.0;
|
||||
|
||||
double controlPanelWidth;
|
||||
const double numberPadScaleRatio = 0.6;
|
||||
double padWidth = boardWidth * numberPadScaleRatio;
|
||||
|
||||
if (padWidth < 200) padWidth = 200;
|
||||
if (padWidth > 350) padWidth = 350;
|
||||
controlPanelWidth = padWidth + 100;
|
||||
|
||||
double totalWidth = boardWidth + controlPanelWidth + 16.0;
|
||||
if (totalWidth > (constraints.maxWidth - 32.0)) {
|
||||
double scale = (constraints.maxWidth - 32.0) / totalWidth;
|
||||
boardWidth *= scale;
|
||||
controlPanelWidth *= scale;
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
@@ -451,7 +387,6 @@ class _GameScreenState extends State<GameScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGameInfoWidget(String formattedTime) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
@@ -462,7 +397,6 @@ class _GameScreenState extends State<GameScreen> {
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSudokuBoardWidget() {
|
||||
return GestureDetector(
|
||||
onLongPress: _resetBoardZoom,
|
||||
@@ -484,21 +418,16 @@ class _GameScreenState extends State<GameScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildControlPanelWidget(BuildContext context, Map<int, int> numberCounts, {required bool isLandscape, required double boardWidth}) {
|
||||
|
||||
final ThemeData themeData = Theme.of(context);
|
||||
|
||||
const double numberPadScaleRatio = 0.6;
|
||||
double? padMaxWidth;
|
||||
|
||||
if (!isLandscape) {
|
||||
padMaxWidth = boardWidth * numberPadScaleRatio;
|
||||
} else {
|
||||
padMaxWidth = boardWidth * numberPadScaleRatio;
|
||||
if (padMaxWidth < 200) padMaxWidth = 200;
|
||||
}
|
||||
|
||||
Widget numberPadGrid = ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: padMaxWidth ?? double.infinity),
|
||||
child: NumberPad(
|
||||
@@ -510,7 +439,6 @@ class _GameScreenState extends State<GameScreen> {
|
||||
isLandscape: isLandscape,
|
||||
),
|
||||
);
|
||||
|
||||
Widget leftButtons = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@@ -527,7 +455,6 @@ class _GameScreenState extends State<GameScreen> {
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Widget rightButtons = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@@ -544,7 +471,6 @@ class _GameScreenState extends State<GameScreen> {
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
if (isLandscape) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
||||
@@ -21,25 +21,40 @@ class SudokuLobbyScreen extends StatefulWidget {
|
||||
class _SudokuLobbyScreenState extends State<SudokuLobbyScreen> {
|
||||
int _maxUnlockedLevel = 1;
|
||||
Map<int, (int, int)> _rankHistory = {};
|
||||
String? _userName;
|
||||
// ❌ String? _userName; (SessionNotifier가 관리)
|
||||
late String _selectedThemeName;
|
||||
bool _isLoading = false;
|
||||
|
||||
// 🔽 [수정] SessionNotifier를 사용하기 위해 IdentityService 대신 추가
|
||||
late final SessionNotifier _sessionNotifier;
|
||||
final PuzzleService _puzzleService = PuzzleService();
|
||||
final IdentityService _identityService = IdentityService();
|
||||
final IdentityService _identityService = IdentityService(); // 👈 (save/load progress를 위해 유지)
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedThemeName = AppThemes.random;
|
||||
_loadProgress();
|
||||
|
||||
// 🔽 [수정] initState에서 SessionNotifier를 read
|
||||
_sessionNotifier = context.read<SessionNotifier>();
|
||||
|
||||
// 🔽 [수정] _loadProgress가 랭킹까지 모두 새로고침 (최초 1회)
|
||||
_loadProgress(forceRefreshRanks: true);
|
||||
}
|
||||
|
||||
Future<void> _loadProgress() async {
|
||||
/// 🔽 [수정] _loadProgress 메서드 (SessionNotifier 사용 및 로직 분리)
|
||||
Future<void> _loadProgress({bool forceRefreshRanks = false}) async {
|
||||
// 1. (가벼움) 레벨 정보 새로고침
|
||||
final maxLevel = await _identityService.getMaxUnlockedLevel();
|
||||
final String? myName = await _identityService.getSavedUserName();
|
||||
if (mounted) { setState(() { _maxUnlockedLevel = maxLevel; _userName = myName; }); }
|
||||
if (myName == null) return;
|
||||
if (mounted) { setState(() { _maxUnlockedLevel = maxLevel; }); }
|
||||
|
||||
// 2. (무거움) 랭킹 정보 새로고침 (필요할 때만)
|
||||
if (!forceRefreshRanks) return;
|
||||
|
||||
// 🔽 [수정] _sessionNotifier에서 유저 이름을 가져옴
|
||||
final String? myName = _sessionNotifier.session?.userName;
|
||||
if (myName == null) return; // 게스트이거나 아직 이름 저장을 안 함
|
||||
|
||||
try {
|
||||
final Map<int, int> oldRankMap = await _identityService.getLastSavedRankMap();
|
||||
List<Future<List<GameRankDto>>> rankFutures = [];
|
||||
@@ -68,13 +83,23 @@ class _SudokuLobbyScreenState extends State<SudokuLobbyScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔽 [수정] _startGame 메서드 (SessionNotifier 사용)
|
||||
Future<void> _startGame(GameLevel level) async {
|
||||
setState(() { _isLoading = true; });
|
||||
|
||||
try {
|
||||
// 1. [수정] SessionNotifier에서 유저 정보 가져오기
|
||||
final session = _sessionNotifier.session;
|
||||
if (session == null) {
|
||||
throw Exception("세션이 로드되지 않았습니다.");
|
||||
}
|
||||
|
||||
final String difficulty = level.levelIndex.toString();
|
||||
final SudokuGameDto gameData = await _puzzleService.startGame(difficulty);
|
||||
final String userId = await _identityService.getOrCreateUserId();
|
||||
final String? userName = _userName;
|
||||
|
||||
final String userId = session.userId;
|
||||
final String? userName = session.userName;
|
||||
|
||||
if (mounted) {
|
||||
await Navigator.push(
|
||||
context,
|
||||
@@ -88,7 +113,11 @@ class _SudokuLobbyScreenState extends State<SudokuLobbyScreen> {
|
||||
),
|
||||
),
|
||||
);
|
||||
_loadProgress(); // 게임 끝나고 돌아오면 랭킹 새로고침
|
||||
|
||||
// 🔽 [핵심 수정]
|
||||
// 랭킹(forceRefreshRanks: true)은 새로고침하지 않고,
|
||||
// 레벨 잠금 상태(forceRefreshRanks: false)만 새로고침합니다.
|
||||
_loadProgress(forceRefreshRanks: false);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
@@ -105,7 +134,10 @@ class _SudokuLobbyScreenState extends State<SudokuLobbyScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 🔽 [수정] ThemeNotifier와 SessionNotifier를 모두 watch
|
||||
context.watch<ThemeNotifier>(); // 테마 감지
|
||||
context.watch<SessionNotifier>(); // 👈 세션 변경(로그인/로그아웃) 감지
|
||||
|
||||
final bool allLevelsUnlocked = _maxUnlockedLevel >= 99;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
@@ -174,68 +206,73 @@ class _SudokuLobbyScreenState extends State<SudokuLobbyScreen> {
|
||||
),
|
||||
// 레벨 목록 ListView
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: AppLevels.allLevels.length,
|
||||
itemBuilder: (context, index) {
|
||||
final GameLevel level = AppLevels.allLevels[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);
|
||||
// 🔽 [수정] RefreshIndicator 추가 (당겨서 랭킹 새로고침)
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () => _loadProgress(forceRefreshRanks: true),
|
||||
child: ListView.builder(
|
||||
itemCount: AppLevels.allLevels.length,
|
||||
itemBuilder: (context, index) {
|
||||
// ... (이하 ListTile 로직은 모두 동일)
|
||||
final GameLevel level = AppLevels.allLevels[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.grey;
|
||||
trailingWidget = const Icon(Icons.check_circle_outline_rounded, color: Colors.grey, size: 28);
|
||||
subtitleText = "$rankStr (신규 진입)";
|
||||
subtitleColor = Colors.blue;
|
||||
trailingWidget = const Icon(Icons.new_releases_rounded, color: Colors.blue, size: 28);
|
||||
}
|
||||
} else {
|
||||
subtitleText = "$rankStr (신규 진입)";
|
||||
subtitleColor = Colors.blue;
|
||||
trailingWidget = const Icon(Icons.new_releases_rounded, color: Colors.blue, size: 28);
|
||||
if (oldRank > 0) {
|
||||
subtitleText = "랭킹 이탈 (이전 ${oldRank}위)";
|
||||
subtitleColor = Colors.orange;
|
||||
trailingWidget = const Icon(Icons.warning_amber_rounded, color: Colors.orange, 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)
|
||||
|
||||
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,222 @@
|
||||
import 'game_difficulty.dart';
|
||||
|
||||
/// 빈칸의 유형을 정의
|
||||
enum MathQuizBlankType {
|
||||
numbersOnly, // 숫자만
|
||||
operatorsOnly, // 연산자만
|
||||
numbersAndOperators, // 둘 다
|
||||
}
|
||||
|
||||
/// 퍼즐의 레이아웃 형태를 정의
|
||||
enum MathQuizLayout {
|
||||
/// A + B = C 또는 A + B * C = D
|
||||
singleLine,
|
||||
/// 'ㄱ', 'ㄴ' 모양 (변수 4개)
|
||||
linkedL,
|
||||
/// 3x3 이상 그리드 (변수 8개 이상)
|
||||
gridSquare,
|
||||
}
|
||||
|
||||
/// 수학 퀴즈 게임의 난이도 정의
|
||||
class MathQuizDifficulty extends GameDifficulty {
|
||||
final int levelIndex;
|
||||
final MathQuizLayout layout;
|
||||
final String operators;
|
||||
final int blankCount;
|
||||
final MathQuizBlankType blankType;
|
||||
|
||||
/// [수정] 연산 복잡도 (예: 2=A+B, 3=A+B*C, 4=2x2그리드)
|
||||
final int operationCount;
|
||||
|
||||
const MathQuizDifficulty({
|
||||
required this.levelIndex,
|
||||
required super.name,
|
||||
required super.contextId,
|
||||
required this.layout,
|
||||
required this.operators,
|
||||
required this.blankCount,
|
||||
required this.blankType,
|
||||
required this.operationCount,
|
||||
});
|
||||
}
|
||||
|
||||
/// [수정됨] 앱 전역에서 사용할 수학 퀴즈 난이도 목록 (15단계)
|
||||
class MathQuizDifficulties {
|
||||
static final List<MathQuizDifficulty> allDifficulties = [
|
||||
// --- 패턴 1: 숫자 2개 (A op B = C) [Lv 1-3] ---
|
||||
// (연산자 우선순위 없음)
|
||||
const MathQuizDifficulty(
|
||||
levelIndex: 1,
|
||||
name: 'Lv. 1: 숫자 2개 (숫자 빈칸)',
|
||||
contextId: 'MATH_L1_OP2_NUM',
|
||||
layout: MathQuizLayout.singleLine,
|
||||
operators: '+,-',
|
||||
blankCount: 1,
|
||||
blankType: MathQuizBlankType.numbersOnly,
|
||||
operationCount: 2, // A, B
|
||||
),
|
||||
const MathQuizDifficulty(
|
||||
levelIndex: 2,
|
||||
name: 'Lv. 2: 숫자 2개 (연산자 빈칸)',
|
||||
contextId: 'MATH_L2_OP2_OP',
|
||||
layout: MathQuizLayout.singleLine,
|
||||
operators: '+,-',
|
||||
blankCount: 1,
|
||||
blankType: MathQuizBlankType.operatorsOnly,
|
||||
operationCount: 2,
|
||||
),
|
||||
const MathQuizDifficulty(
|
||||
levelIndex: 3,
|
||||
name: 'Lv. 3: 숫자 2개 (사칙연산)',
|
||||
contextId: 'MATH_L3_OP2_ANY',
|
||||
layout: MathQuizLayout.singleLine,
|
||||
operators: '+,-,*,/',
|
||||
blankCount: 2,
|
||||
blankType: MathQuizBlankType.numbersAndOperators,
|
||||
operationCount: 2,
|
||||
),
|
||||
|
||||
// --- 패턴 2: 숫자 3개 (A op1 B op2 C = D) [Lv 4-6] ---
|
||||
// (연산자 우선순위 *적용*)
|
||||
const MathQuizDifficulty(
|
||||
levelIndex: 4,
|
||||
name: 'Lv. 4: 숫자 3개 (숫자 빈칸)',
|
||||
contextId: 'MATH_L4_OP3_NUM',
|
||||
layout: MathQuizLayout.singleLine,
|
||||
operators: '+,-,*,/',
|
||||
blankCount: 2,
|
||||
blankType: MathQuizBlankType.numbersOnly,
|
||||
operationCount: 3, // A, B, C
|
||||
),
|
||||
const MathQuizDifficulty(
|
||||
levelIndex: 5,
|
||||
name: 'Lv. 5: 숫자 3개 (연산자 빈칸)',
|
||||
contextId: 'MATH_L5_OP3_OP',
|
||||
layout: MathQuizLayout.singleLine,
|
||||
operators: '+,-,*,/',
|
||||
blankCount: 2,
|
||||
blankType: MathQuizBlankType.operatorsOnly,
|
||||
operationCount: 3,
|
||||
),
|
||||
const MathQuizDifficulty(
|
||||
levelIndex: 6,
|
||||
name: 'Lv. 6: 숫자 3개 (랜덤 빈칸)',
|
||||
contextId: 'MATH_L6_OP3_ANY',
|
||||
layout: MathQuizLayout.singleLine,
|
||||
operators: '+,-,*,/',
|
||||
blankCount: 3,
|
||||
blankType: MathQuizBlankType.numbersAndOperators,
|
||||
operationCount: 3,
|
||||
),
|
||||
|
||||
// --- 패턴 3: 숫자 4개 (2x2 그리드 'ㄱ', 'ㄴ') [Lv 7-9] ---
|
||||
const MathQuizDifficulty(
|
||||
levelIndex: 7,
|
||||
name: 'Lv. 7: 숫자 4개 (숫자 빈칸)',
|
||||
contextId: 'MATH_L7_OP4_NUM',
|
||||
layout: MathQuizLayout.linkedL,
|
||||
operators: '+,-',
|
||||
blankCount: 2,
|
||||
blankType: MathQuizBlankType.numbersOnly,
|
||||
operationCount: 4, // A, B, C, D
|
||||
),
|
||||
const MathQuizDifficulty(
|
||||
levelIndex: 8,
|
||||
name: 'Lv. 8: 숫자 4개 (연산자 빈칸)',
|
||||
contextId: 'MATH_L8_OP4_OP',
|
||||
layout: MathQuizLayout.linkedL,
|
||||
operators: '+,-',
|
||||
blankCount: 2,
|
||||
blankType: MathQuizBlankType.operatorsOnly,
|
||||
operationCount: 4,
|
||||
),
|
||||
const MathQuizDifficulty(
|
||||
levelIndex: 9,
|
||||
name: 'Lv. 9: 숫자 4개 (사칙연산)',
|
||||
contextId: 'MATH_L9_OP4_ANY',
|
||||
layout: MathQuizLayout.linkedL,
|
||||
operators: '+,-,*,/',
|
||||
blankCount: 3,
|
||||
blankType: MathQuizBlankType.numbersAndOperators,
|
||||
operationCount: 4,
|
||||
),
|
||||
|
||||
// --- 패턴 4: 3x3 그리드 (숫자 9개) [Lv 10-12] ---
|
||||
const MathQuizDifficulty(
|
||||
levelIndex: 10,
|
||||
name: 'Lv. 10: 3x3 그리드 (숫자)',
|
||||
contextId: 'MATH_L10_OP9_NUM',
|
||||
layout: MathQuizLayout.gridSquare,
|
||||
operators: '+,-',
|
||||
blankCount: 3,
|
||||
blankType: MathQuizBlankType.numbersOnly,
|
||||
operationCount: 9, // 9 Variables
|
||||
),
|
||||
const MathQuizDifficulty(
|
||||
levelIndex: 11,
|
||||
name: 'Lv. 11: 3x3 그리드 (연산자)',
|
||||
contextId: 'MATH_L11_OP9_OP',
|
||||
layout: MathQuizLayout.gridSquare,
|
||||
operators: '+,-',
|
||||
blankCount: 3,
|
||||
blankType: MathQuizBlankType.operatorsOnly,
|
||||
operationCount: 9,
|
||||
),
|
||||
const MathQuizDifficulty(
|
||||
levelIndex: 12,
|
||||
name: 'Lv. 12: 3x3 그리드 (사칙연산)',
|
||||
contextId: 'MATH_L12_OP9_ANY',
|
||||
layout: MathQuizLayout.gridSquare,
|
||||
operators: '+,-,*,/',
|
||||
blankCount: 4,
|
||||
blankType: MathQuizBlankType.numbersAndOperators,
|
||||
operationCount: 9,
|
||||
),
|
||||
|
||||
// --- 패턴 5: 4x4 그리드 (숫자 16개) [Lv 13-15] ---
|
||||
const MathQuizDifficulty(
|
||||
levelIndex: 13,
|
||||
name: 'Lv. 13: 4x4 그리드 (숫자)',
|
||||
contextId: 'MATH_L13_OP16_NUM',
|
||||
layout: MathQuizLayout.gridSquare,
|
||||
operators: '+,-,*,/',
|
||||
blankCount: 5,
|
||||
blankType: MathQuizBlankType.numbersOnly,
|
||||
operationCount: 16,
|
||||
),
|
||||
const MathQuizDifficulty(
|
||||
levelIndex: 14,
|
||||
name: 'Lv. 14: 4x4 그리드 (연산자)',
|
||||
contextId: 'MATH_L14_OP16_OP',
|
||||
layout: MathQuizLayout.gridSquare,
|
||||
operators: '+,-,*,/',
|
||||
blankCount: 5,
|
||||
blankType: MathQuizBlankType.operatorsOnly,
|
||||
operationCount: 16,
|
||||
),
|
||||
const MathQuizDifficulty(
|
||||
levelIndex: 15,
|
||||
name: 'Lv. 15: 4x4 그리드 (랜덤)',
|
||||
contextId: 'MATH_L15_OP16_ANY',
|
||||
layout: MathQuizLayout.gridSquare,
|
||||
operators: '+,-,*,/',
|
||||
blankCount: 6,
|
||||
blankType: MathQuizBlankType.numbersAndOperators,
|
||||
operationCount: 16,
|
||||
),
|
||||
];
|
||||
|
||||
/// 레벨 인덱스로 레벨 정보 찾기
|
||||
static MathQuizDifficulty getLevel(int levelIndex) {
|
||||
if (levelIndex < 1) levelIndex = 1;
|
||||
if (levelIndex > allDifficulties.length) levelIndex = allDifficulties.length;
|
||||
return allDifficulties.firstWhere((level) => level.levelIndex == levelIndex,
|
||||
orElse: () => allDifficulties[0]
|
||||
);
|
||||
}
|
||||
|
||||
/// 랭킹 화면용 맵 (ContextId -> 이름)
|
||||
static Map<String, String> get contextIdToNameMap {
|
||||
return { for (var level in allDifficulties) level.contextId : level.name };
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
// packages/service_api/lib/service_api.dart
|
||||
|
||||
// Models
|
||||
export 'models/game_difficulty.dart'; // 👈 [추가]
|
||||
export 'models/game_difficulty.dart';
|
||||
export 'models/game_rank_dto.dart';
|
||||
export 'models/sudoku_game_dto.dart';
|
||||
export 'models/sudoku_theme.dart';
|
||||
export 'models/unified_rank_dto.dart';
|
||||
export 'models/validate_result_dto.dart';
|
||||
// ❌ (game_level.dart는 여기서 삭제)
|
||||
export 'models/math_quiz_difficulty.dart'; // 👈 [추가]
|
||||
|
||||
// Services
|
||||
export 'services/identity_service.dart';
|
||||
export 'services/puzzle_service.dart';
|
||||
export 'services/theme_notifier.dart';
|
||||
export 'services/session_notifier.dart';
|
||||
export 'services/session_notifier.dart'; // 👈 [추가]
|
||||
@@ -21,10 +21,8 @@ class UserSession {
|
||||
|
||||
// 앱-고유 ID와 사용자 이름, 레벨 진행 상황을 관리하는 서비스
|
||||
class IdentityService {
|
||||
// --- (모든 키 이름은 동일하게 유지) ---
|
||||
static const String _userIdKey = 'app_user_id';
|
||||
static const String _userNameKey = 'app_user_name';
|
||||
// 🔽 [신규] 로그인 상태 저장을 위한 키
|
||||
static const String _loginProviderKey = 'app_login_provider';
|
||||
static const String _userEmailKey = 'app_user_email';
|
||||
|
||||
@@ -33,9 +31,12 @@ class IdentityService {
|
||||
static const String _spiderMaxLevelKey = 'max_unlocked_spider_level';
|
||||
static const String _spiderRankMapKey = 'last_checked_spider_rank_map';
|
||||
|
||||
// 🔽 [신규] 수학 퀴즈 전용 키
|
||||
static const String _mathQuizMaxLevelKey = 'max_unlocked_mathquiz_level';
|
||||
static const String _mathQuizRankMapKey = 'last_checked_mathquiz_rank_map';
|
||||
|
||||
final _storage = const FlutterSecureStorage();
|
||||
|
||||
/// 🔽 [신규] iOS 앱 간 데이터 공유를 위한 옵션
|
||||
IOSOptions _getIOSOptions() => const IOSOptions(
|
||||
// 🔽 [수정] Xcode 설정 전까지 'groupId'를 주석 처리하여 크래시 방지
|
||||
// groupId: 'group.com.lunaticbum.mygamecenter',
|
||||
@@ -45,9 +46,9 @@ class IdentityService {
|
||||
encryptedSharedPreferences: true,
|
||||
);
|
||||
|
||||
// 🔽 [신규] 1. 현재 세션 정보를 '객체'로 가져오기
|
||||
// 1. 현재 세션 정보를 '객체'로 가져오기
|
||||
Future<UserSession> getUserSession() async {
|
||||
final userId = await getOrCreateUserId(); // 게스트 ID는 항상 보장
|
||||
final userId = await getOrCreateUserId();
|
||||
final userName = await getSavedUserName();
|
||||
final loginProvider = await _storage.read(
|
||||
key: _loginProviderKey,
|
||||
@@ -107,12 +108,12 @@ class IdentityService {
|
||||
);
|
||||
}
|
||||
|
||||
// 🔽 [신규] 5. 소셜 로그인 성공 시 호출 (계정 연결)
|
||||
// 5. 소셜 로그인 성공 시 호출 (계정 연결)
|
||||
Future<UserSession> saveSocialLogin({
|
||||
required String newUserId, // 서버가 발급한 마스터 계정 ID
|
||||
required String newUserId,
|
||||
required String newUserName,
|
||||
required String newEmail,
|
||||
required String provider, // "google" 또는 "apple"
|
||||
required String provider,
|
||||
}) async {
|
||||
await _storage.write(key: _userIdKey, value: newUserId, iOptions: _getIOSOptions(), aOptions: _getAndroidOptions());
|
||||
await _storage.write(key: _userNameKey, value: newUserName, iOptions: _getIOSOptions(), aOptions: _getAndroidOptions());
|
||||
@@ -127,9 +128,8 @@ class IdentityService {
|
||||
);
|
||||
}
|
||||
|
||||
// 🔽 [신규] 6. 로그아웃 (게스트 계정으로 되돌리기)
|
||||
// 6. 로그아웃 (게스트 계정으로 되돌리기)
|
||||
Future<UserSession> logout() async {
|
||||
// 소셜 로그인 정보만 삭제
|
||||
await _storage.delete(key: _userNameKey, iOptions: _getIOSOptions(), aOptions: _getAndroidOptions());
|
||||
await _storage.delete(key: _userEmailKey, iOptions: _getIOSOptions(), aOptions: _getAndroidOptions());
|
||||
await _storage.delete(key: _loginProviderKey, iOptions: _getIOSOptions(), aOptions: _getAndroidOptions());
|
||||
@@ -137,20 +137,40 @@ class IdentityService {
|
||||
return await getUserSession();
|
||||
}
|
||||
|
||||
// 7. 최대 레벨 가져오기
|
||||
// 🔽 [수정] 7. 최대 레벨 가져오기 (gameType 분기)
|
||||
Future<int> getMaxUnlockedLevel({String gameType = 'SUDOKU'}) async {
|
||||
final key = gameType == 'SPIDER' ? _spiderMaxLevelKey : _sudokuMaxLevelKey;
|
||||
String key;
|
||||
switch (gameType) {
|
||||
case 'SPIDER':
|
||||
key = _spiderMaxLevelKey;
|
||||
break;
|
||||
case 'MATH_QUIZ': // 👈 [추가]
|
||||
key = _mathQuizMaxLevelKey;
|
||||
break;
|
||||
default: // 'SUDOKU'
|
||||
key = _sudokuMaxLevelKey;
|
||||
}
|
||||
String? levelString = await _storage.read(
|
||||
key: key,
|
||||
iOptions: _getIOSOptions(),
|
||||
aOptions: _getAndroidOptions(),
|
||||
);
|
||||
return int.parse(levelString ?? '1'); // 기본값 1
|
||||
return int.parse(levelString ?? '1');
|
||||
}
|
||||
|
||||
// 8. 최대 레벨 저장하기
|
||||
// 🔽 [수정] 8. 최대 레벨 저장하기 (gameType 분기)
|
||||
Future<void> saveMaxUnlockedLevel(int level, {String gameType = 'SUDOKU'}) async {
|
||||
final key = gameType == 'SPIDER' ? _spiderMaxLevelKey : _sudokuMaxLevelKey;
|
||||
String key;
|
||||
switch (gameType) {
|
||||
case 'SPIDER':
|
||||
key = _spiderMaxLevelKey;
|
||||
break;
|
||||
case 'MATH_QUIZ': // 👈 [추가]
|
||||
key = _mathQuizMaxLevelKey;
|
||||
break;
|
||||
default: // 'SUDOKU'
|
||||
key = _sudokuMaxLevelKey;
|
||||
}
|
||||
await _storage.write(
|
||||
key: key,
|
||||
value: level.toString(),
|
||||
@@ -159,9 +179,19 @@ class IdentityService {
|
||||
);
|
||||
}
|
||||
|
||||
// 9. 마지막 랭킹 맵 가져오기
|
||||
// 🔽 [수정] 9. 마지막 랭킹 맵 가져오기 (gameType 분기)
|
||||
Future<Map<int, int>> getLastSavedRankMap({String gameType = 'SUDOKU'}) async {
|
||||
final key = gameType == 'SPIDER' ? _spiderRankMapKey : _sudokuRankMapKey;
|
||||
String key;
|
||||
switch (gameType) {
|
||||
case 'SPIDER':
|
||||
key = _spiderRankMapKey;
|
||||
break;
|
||||
case 'MATH_QUIZ': // 👈 [추가]
|
||||
key = _mathQuizRankMapKey;
|
||||
break;
|
||||
default: // 'SUDOKU'
|
||||
key = _sudokuRankMapKey;
|
||||
}
|
||||
String? jsonString = await _storage.read(
|
||||
key: key,
|
||||
iOptions: _getIOSOptions(),
|
||||
@@ -177,9 +207,19 @@ class IdentityService {
|
||||
}
|
||||
}
|
||||
|
||||
// 10. 마지막 랭킹 맵 저장하기
|
||||
// 🔽 [수정] 10. 마지막 랭킹 맵 저장하기 (gameType 분기)
|
||||
Future<void> saveLastRankMap(Map<int, int> rankMap, {String gameType = 'SUDOKU'}) async {
|
||||
final key = gameType == 'SPIDER' ? _spiderRankMapKey : _sudokuRankMapKey;
|
||||
String key;
|
||||
switch (gameType) {
|
||||
case 'SPIDER':
|
||||
key = _spiderRankMapKey;
|
||||
break;
|
||||
case 'MATH_QUIZ': // 👈 [추가]
|
||||
key = _mathQuizRankMapKey;
|
||||
break;
|
||||
default: // 'SUDOKU'
|
||||
key = _sudokuRankMapKey;
|
||||
}
|
||||
|
||||
final Map<String, int> stringKeyMap =
|
||||
rankMap.map((key, value) => MapEntry(key.toString(), value));
|
||||
|
||||
Reference in New Issue
Block a user