This commit is contained in:
2025-11-11 14:38:15 +09:00
parent 1883fef583
commit 5a0785f262
9 changed files with 358 additions and 208 deletions
+76 -39
View File
@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:sudoku_app/models/game_level.dart'; // 👈 [추가] 레벨 모델 임포트
import 'package:sudoku_app/models/sudoku_game_dto.dart';
import 'package:sudoku_app/models/sudoku_theme.dart';
import 'package:sudoku_app/models/unified_rank_dto.dart';
@@ -19,6 +20,7 @@ class GameScreen extends StatefulWidget {
final String themeName;
final String userId;
final String? userName;
final int levelIndex; // 👈 HomeScreen에서 전달받은 현재 레벨
const GameScreen({
super.key,
@@ -26,6 +28,7 @@ class GameScreen extends StatefulWidget {
required this.themeName,
required this.userId,
required this.userName,
required this.levelIndex,
});
@override
@@ -36,10 +39,12 @@ class _GameScreenState extends State<GameScreen> {
final PuzzleService _puzzleService = PuzzleService();
final IdentityService _identityService = IdentityService();
late final GameLevel currentLevel; // 👈 현재 레벨 정보
late final int blockSize;
late final int gridSize;
late final SudokuTheme activeTheme;
late final SudokuTheme activeTheme; // 👈 이번 게임의 '동적' 테마
// 모든 내부 로직은 'int'로 관리
late List<int> puzzleCells;
late List<int> solutionCells;
late List<int> originalCells;
@@ -80,10 +85,31 @@ class _GameScreenState extends State<GameScreen> {
@override
void initState() {
super.initState();
blockSize = widget.gameData.blockSize;
gridSize = widget.gameData.gridSize;
activeTheme = AppThemes.buildGameTheme(widget.themeName, gridSize);
// 1. HomeScreen에서 받은 levelIndex로 현재 레벨 정보 로드
currentLevel = AppLevels.getLevel(widget.levelIndex);
blockSize = currentLevel.blockSize;
gridSize = blockSize * blockSize;
// 2. 🔽 [수정] 테마 정책 적용
String themeForThisGame = widget.themeName;
// 🔽 [수정] 'isEasyMode' 변수를 'GameLevel'의 속성들로 조합
bool isEasyMode = currentLevel.isSequentialNumbers || currentLevel.isSequentialLetters;
if (currentLevel.isSequentialNumbers) {
themeForThisGame = AppThemes.numbers; // 👈 숫자 테마 강제
} else if (currentLevel.isSequentialLetters) {
themeForThisGame = AppThemes.letters; // 👈 알파벳 테마 강제
}
// 3. '이번 게임'의 테마 객체 생성
activeTheme = AppThemes.buildGameTheme(
themeForThisGame,
gridSize,
isEasyMode: isEasyMode, // 👈 [수정] 조합된 bool 값 전달
);
// 4. 서버 데이터(String)를 내부 로직(int)으로 변환
puzzleCells = widget.gameData.question.split('').map(_charToInt).toList();
solutionCells = widget.gameData.solution.split('').map(_charToInt).toList();
originalCells = widget.gameData.question.split('').map(_charToInt).toList();
@@ -107,21 +133,21 @@ class _GameScreenState extends State<GameScreen> {
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),
),
);
return;
}
setState(() {
selectedIndex = index;
if (selectedNumberPad != null) {
if (incorrectCells.isNotEmpty && !incorrectCells.contains(index)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('틀린 값을 먼저 수정해주세요. (되돌리기 ↩)'),
duration: Duration(seconds: 1),
),
);
return;
}
final int numberValue = selectedNumberPad!;
puzzleCells[index] = numberValue;
@@ -159,12 +185,19 @@ class _GameScreenState extends State<GameScreen> {
}
void onUndoTapped() {
setState(() {
if (selectedIndex != null && originalCells[selectedIndex!] == 0) {
if (incorrectCells.isNotEmpty) {
int errorIndex = incorrectCells.first;
setState(() {
puzzleCells[errorIndex] = 0;
incorrectCells.remove(errorIndex);
selectedIndex = errorIndex;
});
}
else if (selectedIndex != null && originalCells[selectedIndex!] == 0) {
setState(() {
puzzleCells[selectedIndex!] = 0;
incorrectCells.remove(selectedIndex);
}
});
});
}
}
void onHintTapped() {
@@ -192,6 +225,7 @@ class _GameScreenState extends State<GameScreen> {
}
// 정답 확인 API 호출
Future<void> _validateGame() async {
if (isValidating) return;
setState(() { isValidating = true; });
@@ -229,18 +263,17 @@ class _GameScreenState extends State<GameScreen> {
}
}
// 랭킹 등록 팝업 (2단계 UI)
// 랭킹 등록 팝업 (2단계 UI + 레벨 잠금 해제)
void _showRankingDialog() {
final nameController = TextEditingController(text: widget.userName);
bool isSubmitting = false;
final bool hasExistingName = widget.userName != null;
String? dialogErrorMessage;
_rankStep = _RankSubmissionStep.enterName;
_rankingList = [];
_submittedPlayerName = "";
String? dialogErrorMessage; // 👈 [신규] 팝업 내부 에러 메시지
final String contextId = "SUDOKU_${gridSize}x${gridSize}_L${_difficultyLevel(widget.gameData.question)}";
final String contextId = currentLevel.contextId;
showDialog(
context: context,
@@ -290,12 +323,11 @@ class _GameScreenState extends State<GameScreen> {
const SizedBox(height: 20),
TextField(
controller: nameController,
readOnly: hasExistingName,
readOnly: false,
decoration: InputDecoration(
labelText: hasExistingName ? '등록된 이름' : '이름 (10자 이내)',
labelText: '이름 (10자 이내)',
border: const OutlineInputBorder(),
// 🔽 [신규] 에러 메시지가 있으면 TextField에 에러 스타일 적용
errorText: dialogErrorMessage,
errorText: dialogErrorMessage,
),
maxLength: 10,
),
@@ -306,7 +338,6 @@ class _GameScreenState extends State<GameScreen> {
onPressed: () async {
final playerName = nameController.text.trim();
if (playerName.isEmpty) {
// SnackBar 대신 팝업 내부 에러로 변경
setDialogState(() {
dialogErrorMessage = "이름을 입력해주세요.";
});
@@ -316,7 +347,7 @@ class _GameScreenState extends State<GameScreen> {
setDialogState(() {
_rankStep = _RankSubmissionStep.submitting;
_submittedPlayerName = playerName;
dialogErrorMessage = null; // 👈 [신규] 에러 메시지 초기화
dialogErrorMessage = null;
});
final rankDto = UnifiedRankDto(
@@ -330,9 +361,18 @@ class _GameScreenState extends State<GameScreen> {
try {
await _puzzleService.submitRank(rankDto);
if (!hasExistingName) {
await _identityService.saveUserName(playerName);
await _identityService.saveUserName(playerName);
final int currentMaxLevel = await _identityService.getMaxUnlockedLevel();
if (currentMaxLevel < 99) {
if (widget.levelIndex >= currentMaxLevel) {
int nextLevel = widget.levelIndex + 1;
if (nextLevel > AppLevels.allLevels.length) {
await _identityService.saveMaxUnlockedLevel(99);
} else {
await _identityService.saveMaxUnlockedLevel(nextLevel);
}
}
}
final ranks = await _puzzleService.fetchRanks('SUDOKU', contextId);
@@ -343,15 +383,11 @@ class _GameScreenState extends State<GameScreen> {
});
} catch (e) {
// 🔽 [수정] 랭킹 등록 실패 시 (이름 중복 등)
log("!!! 랭킹 등록 실패 !!!", error: e);
setDialogState(() {
_rankStep = _RankSubmissionStep.enterName; // 1단계(이름 입력)로 복귀
// 👈 [신규] 서버 에러 메시지를 팝업에 표시
dialogErrorMessage = e.toString().replaceFirst("Exception: ", "");
_rankStep = _RankSubmissionStep.enterName;
dialogErrorMessage = e.toString().replaceFirst("Exception: ", "");
});
// ❌ SnackBar 제거
}
},
child: const Text('랭킹 등록'),
@@ -399,6 +435,7 @@ class _GameScreenState extends State<GameScreen> {
);
}
// 랭킹 ID 생성을 위해, 원본 문제의 빈칸 비율로 레벨(1~5)을 역산
int _difficultyLevel(String question) {
int holes = question.split('').where((c) => c == '0').length;
double holeRatio = holes / (gridSize * gridSize);
+111 -98
View File
@@ -1,10 +1,11 @@
import 'package:flutter/material.dart';
import 'package:sudoku_app/models/game_level.dart'; // 👈 [추가]
import 'package:sudoku_app/models/sudoku_game_dto.dart';
import 'package:sudoku_app/models/sudoku_theme.dart';
import 'package:sudoku_app/screens/game_screen.dart';
import 'package:sudoku_app/screens/ranking_screen.dart';
import 'package:sudoku_app/services/puzzle_service.dart';
import 'package:sudoku_app/services/identity_service.dart'; // 👈 ID 서비스 임포트
import 'package:sudoku_app/services/identity_service.dart';
import 'package:sudoku_app/widgets/ad_banner_widget.dart';
class HomeScreen extends StatefulWidget {
@@ -15,51 +16,61 @@ class HomeScreen extends StatefulWidget {
}
class _HomeScreenState extends State<HomeScreen> {
// 8단계 난이도
double _difficultyLevel = 4.0; // 1.0 ~ 8.0 (기본값 Level 4: 중급 9x9)
final List<String> levelLabels = [
"입문 (4x4)", "초급 (4x4)",
"쉬움 (9x9)", "중급 (9x9)", "어려움 (9x9)",
"전문가 (16x16)", "마스터 (16x16)", "지옥 (16x16)"
];
// '최대 잠금 해제 레벨' 상태
int _maxUnlockedLevel = 1;
late String _selectedThemeName;
bool isLoading = false;
bool _isLoading = false;
final PuzzleService _puzzleService = PuzzleService();
final IdentityService _identityService = IdentityService(); // 👈 ID 서비스 초기화
final IdentityService _identityService = IdentityService();
@override
void initState() {
super.initState();
_selectedThemeName = AppThemes.random; // 기본 테마 '랜덤'
_selectedThemeName = AppThemes.random;
_loadProgress(); // 👈 저장된 진행 상황 로드
}
Future<void> _startGame() async {
setState(() { isLoading = true; });
// 로컬 저장소에서 클리어 레벨 불러오기
Future<void> _loadProgress() async {
final maxLevel = await _identityService.getMaxUnlockedLevel();
if (mounted) {
setState(() {
_maxUnlockedLevel = maxLevel;
});
}
}
// 게임 시작 로직
Future<void> _startGame(GameLevel level) async {
setState(() { _isLoading = true; });
try {
// 1. 난이도 값(String) 전달
final String difficulty = _difficultyLevel.round().toString();
// 1. 선택한 레벨의 '인덱스'(1-11)를 문자열로 전달
final String difficulty = level.levelIndex.toString();
// 2. 서버에서 게임 데이터 가져오기
final SudokuGameDto gameData = await _puzzleService.startGame(difficulty);
// 3. 로컬에서 앱-고유 ID와 저장된 이름 가져오기
final String userId = await _identityService.getOrCreateUserId();
final String? userName = await _identityService.getSavedUserName();
if (mounted) {
Navigator.push(
// 2. GameScreen으로 이동 (게임 클리어 후 돌아오면 _loadProgress() 호출)
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => GameScreen(
gameData: gameData,
themeName: _selectedThemeName,
userId: userId, // 👈 ID 전달
userName: userName, // 👈 이름 전달
themeName: _selectedThemeName, // 👈 '랜덤' 또는 '과일' 등
userId: userId,
userName: userName,
levelIndex: level.levelIndex, // 👈 1~11
),
),
);
// 3. GameScreen에서 돌아왔을 때, 진행 상황(클리어)을 다시 로드
_loadProgress();
}
} catch (e) {
if (mounted) {
@@ -69,107 +80,109 @@ class _HomeScreenState extends State<HomeScreen> {
}
} finally {
if (mounted) {
setState(() { isLoading = false; });
setState(() { _isLoading = false; });
}
}
}
@override
Widget build(BuildContext context) {
// 99 = 모든 레벨 클리어
final bool allLevelsUnlocked = _maxUnlockedLevel >= 99;
return Scaffold(
appBar: AppBar(title: const Text('스도쿠 게임')),
body: LayoutBuilder( // 비율 기반 레이아웃
body: LayoutBuilder(
builder: (context, constraints) {
// 너비/높이 비율 변수 (0.6 = 너비가 높이의 60%를 넘지 않도록 함)
const double maxContentRatio = 0.6;
final double constrainedWidth = constraints.maxHeight * maxContentRatio;
// 🔽 [수정] 태블릿 등에서 너무 커지는 것을 방지 (최대 500px)
final double constrainedWidth = (constraints.maxHeight * maxContentRatio) > 500
? 500 : (constraints.maxHeight * maxContentRatio);
return Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: constrainedWidth),
child: Column(
children: [
Expanded(
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 1. 난이도 선택 (8단계)
const Text("난이도", style: TextStyle(fontSize: 18)),
Text(
levelLabels[_difficultyLevel.round() - 1],
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.blue),
),
Slider(
value: _difficultyLevel,
min: 1.0, max: 8.0, divisions: 7, // 8단계
label: levelLabels[_difficultyLevel.round() - 1],
onChanged: (newValue) => setState(() { _difficultyLevel = newValue; }),
),
const SizedBox(height: 20),
// 2. 테마 선택 (String 기반)
const Text("테마", style: TextStyle(fontSize: 18)),
DropdownButton<String>(
value: _selectedThemeName,
items: AppThemes.selectableThemeNames.map((themeName) {
return DropdownMenuItem<String>(
value: themeName,
child: Text(themeName, style: const TextStyle(fontSize: 20)),
);
}).toList(),
onChanged: (themeName) {
if (themeName != null) {
setState(() { _selectedThemeName = themeName; });
}
},
),
const SizedBox(height: 30),
if (isLoading)
const CircularProgressIndicator()
else
ElevatedButton(
onPressed: _startGame,
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 15)),
child: const Text('게임 시작', style: TextStyle(fontSize: 18)),
),
const SizedBox(height: 10),
// "랭킹 보기" 버튼
TextButton(
onPressed: () {
// 현재 슬라이더의 난이도 이름(String)을 가져옴
final String currentDifficultyName = levelLabels[_difficultyLevel.round() - 1];
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => RankingScreen(
initialDifficultyName: currentDifficultyName,
),
),
);
},
child: const Text('랭킹 보기'),
),
],
// 1. 테마 선택
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 10.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text("테마: ", style: TextStyle(fontSize: 18)),
DropdownButton<String>(
value: _selectedThemeName,
items: AppThemes.selectableThemeNames.map((themeName) {
return DropdownMenuItem<String>(
value: themeName,
child: Text(themeName, style: const TextStyle(fontSize: 20)),
);
}).toList(),
onChanged: (themeName) {
if (themeName != null) {
setState(() { _selectedThemeName = themeName; });
}
},
),
),
],
),
),
const AdBannerWidget(),
// 2. 🔽 [수정] 레벨 선택 리스트 (Slider 대체)
Expanded(
child: ListView.builder(
itemCount: AppLevels.allLevels.length,
itemBuilder: (context, index) {
final GameLevel level = AppLevels.allLevels[index];
// 3. 잠금 해제 로직
final bool isUnlocked = allLevelsUnlocked || level.levelIndex <= _maxUnlockedLevel;
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 ? Colors.blue : Colors.grey,
),
title: Text(level.name, style: TextStyle(
fontSize: 18,
fontWeight: isUnlocked ? FontWeight.bold : FontWeight.normal,
color: isUnlocked ? Colors.black : Colors.grey,
)),
trailing: isUnlocked ? const Icon(Icons.play_arrow_rounded) : null,
onTap: isUnlocked && !_isLoading
? () => _startGame(level)
: null, // 잠겼거나 로딩 중이면 탭 비활성화
),
);
},
),
),
// 3. 랭킹 보기 버튼
TextButton(
onPressed: () {
// 현재 최고 레벨을 랭킹 화면의 기본값으로 전달
final String currentDifficultyName = AppLevels.getLevel(_maxUnlockedLevel).name;
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => RankingScreen(
initialDifficultyName: currentDifficultyName,
),
),
);
},
child: const Text('전체 랭킹 보기'),
),
],
),
),
);
},
),
bottomNavigationBar: const AdBannerWidget(), // 👈 [수정] body -> bottomNavigationBar
);
}
}
+12 -22
View File
@@ -1,14 +1,15 @@
import 'package:flutter/material.dart';
import 'package:sudoku_app/models/game_level.dart';
import 'package:sudoku_app/models/game_rank_dto.dart';
import 'package:sudoku_app/services/puzzle_service.dart';
class RankingScreen extends StatefulWidget {
// 🔽 [추가] 홈 화면에서 전달받을 초기 난이도 이름
// 🔽 [수정] 홈 화면에서 전달받을 초기 난이도 이름
final String? initialDifficultyName;
const RankingScreen({
super.key,
this.initialDifficultyName, // 👈 생성자에 추가
this.initialDifficultyName, // 👈 [수정] 생성자에 이 파라미터 추가
});
@override
@@ -19,39 +20,28 @@ class _RankingScreenState extends State<RankingScreen> {
final PuzzleService _puzzleService = PuzzleService();
late Future<List<GameRankDto>> _rankingFuture;
// 8단계 난이도에 맞는 Context ID
// 9단계 난이도에 맞는 (이름 -> ContextId)
final Map<String, String> difficultyContexts = {
"입문 (4x4)": "SUDOKU_4x4_L1",
"초급 (4x4)": "SUDOKU_4x4_L2",
"쉬움 (9x9)": "SUDOKU_9x9_L3",
"중급 (9x9)": "SUDOKU_9x9_L4",
"어려움 (9x9)": "SUDOKU_9x9_L5",
"전문가 (16x16)": "SUDOKU_16x16_L6",
"마스터 (16x16)": "SUDOKU_16x16_L7",
"지옥 (16x16)": "SUDOKU_16x16_L8",
for (var level in AppLevels.allLevels) level.name : level.contextId
};
late String _selectedDifficulty;
late String _selectedDifficultyName;
@override
void initState() {
super.initState();
// 🔽 [수정]
// 1. HomeScreen에서 전달받은 값이 있는지 확인
String defaultDifficulty = widget.initialDifficultyName ?? "중급 (9x9)";
// 2. (안전장치) 전달받은 값이 맵에 없으면(예: 향후 변경) 기본값 사용
if (!difficultyContexts.containsKey(defaultDifficulty)) {
defaultDifficulty = "중급 (9x9)";
}
// 3. 랭킹 조회
_fetchRanksForDifficulty(defaultDifficulty);
}
void _fetchRanksForDifficulty(String difficultyName) {
setState(() {
_selectedDifficulty = difficultyName;
_rankingFuture = _puzzleService.fetchRanks('SUDOKU', difficultyContexts[_selectedDifficulty]);
_selectedDifficultyName = difficultyName;
_rankingFuture = _puzzleService.fetchRanks('SUDOKU', difficultyContexts[_selectedDifficultyName]);
});
}
@@ -78,12 +68,12 @@ class _RankingScreenState extends State<RankingScreen> {
Padding(
padding: const EdgeInsets.all(16.0),
child: DropdownButton<String>(
value: _selectedDifficulty, // 👈 initState에서 설정된 값으로 시작
value: _selectedDifficultyName,
isExpanded: true,
items: difficultyContexts.keys.map((String difficultyName) {
items: AppLevels.allLevels.map((level) {
return DropdownMenuItem<String>(
value: difficultyName,
child: Text(difficultyName),
value: level.name,
child: Text(level.name),
);
}).toList(),
onChanged: (String? newValue) {