From 5a0785f2624a0f343329eae296bb2bd89cc8ba5b Mon Sep 17 00:00:00 2001 From: lunaticbum Date: Tue, 11 Nov 2025 14:38:15 +0900 Subject: [PATCH] ... --- lib/models/game_level.dart | 94 +++++++++++++ lib/models/sudoku_theme.dart | 23 ++-- lib/models/unified_rank_dto.dart | 6 +- lib/screens/game_screen.dart | 115 ++++++++++------ lib/screens/home_screen.dart | 209 +++++++++++++++-------------- lib/screens/ranking_screen.dart | 34 ++--- lib/services/identity_service.dart | 18 ++- lib/services/puzzle_service.dart | 20 ++- lib/widgets/sudoku_board.dart | 47 +++---- 9 files changed, 358 insertions(+), 208 deletions(-) create mode 100644 lib/models/game_level.dart diff --git a/lib/models/game_level.dart b/lib/models/game_level.dart new file mode 100644 index 0000000..2493350 --- /dev/null +++ b/lib/models/game_level.dart @@ -0,0 +1,94 @@ +// lib/models/game_level.dart + +// 11단계 레벨의 모든 속성을 정의하는 클래스 +class GameLevel { + final int levelIndex; // 1-11 + final String name; // "입문 (4x4)" + final int blockSize; // 2, 3, 4 + final int generatorLevel; // 서버에 요청할 생성기 난이도 (1~5) + final String contextId; // 랭킹 ID "SUDOKU_4x4_L1" + + // 🔽 [신규] 테마 정책 + final bool isSequentialNumbers; // L1, L4, L9 (숫자 고정) + final bool isSequentialLetters; // L2, L5, L10 (문자 고정) + // (둘 다 false이면 HomeScreen에서 선택한 랜덤 테마 사용) + + const GameLevel({ + required this.levelIndex, + required this.name, + required this.blockSize, + required this.generatorLevel, + required this.contextId, + this.isSequentialNumbers = false, + this.isSequentialLetters = false, + }); +} + +// 앱 전역에서 사용할 11단계 레벨 정의 +class AppLevels { + static final List allLevels = [ + // --- 2x2 (blockSize = 2) --- + const GameLevel( + levelIndex: 1, name: "입문 (4x4)", blockSize: 2, generatorLevel: 1, + contextId: "SUDOKU_4x4_L1", isSequentialNumbers: true // 👈 순차 숫자 + ), + const GameLevel( + levelIndex: 2, name: "초급 (4x4)", blockSize: 2, generatorLevel: 3, + contextId: "SUDOKU_4x4_L3", isSequentialLetters: true // 👈 순차 문자 + ), + const GameLevel( + levelIndex: 3, name: "숙련 (4x4)", blockSize: 2, generatorLevel: 5, + contextId: "SUDOKU_4x4_L5" // 👈 랜덤 테마 + ), + + // --- 3x3 (blockSize = 3) --- + const GameLevel( + levelIndex: 4, name: "쉬움 (9x9)", blockSize: 3, generatorLevel: 1, + contextId: "SUDOKU_9x9_L1", isSequentialNumbers: true // 👈 순차 숫자 + ), + const GameLevel( + levelIndex: 5, name: "중급 (9x9)", blockSize: 3, generatorLevel: 2, + contextId: "SUDOKU_9x9_L2", isSequentialLetters: true // 👈 순차 문자 + ), + const GameLevel( + levelIndex: 6, name: "상급 (9x9)", blockSize: 3, generatorLevel: 3, + contextId: "SUDOKU_9x9_L3" // 👈 랜덤 테마 + ), + const GameLevel( + levelIndex: 7, name: "어려움 (9x9)", blockSize: 3, generatorLevel: 4, + contextId: "SUDOKU_9x9_L4" // 👈 랜덤 테마 + ), + const GameLevel( + levelIndex: 8, name: "최상급 (9x9)", blockSize: 3, generatorLevel: 5, + contextId: "SUDOKU_9x9_L5" // 👈 랜덤 테마 + ), + + // --- 4x4 (blockSize = 4) --- + const GameLevel( + levelIndex: 9, name: "전문가 (16x16)", blockSize: 4, generatorLevel: 1, + contextId: "SUDOKU_16x16_L1", isSequentialNumbers: true // 👈 순차 숫자 + ), + const GameLevel( + levelIndex: 10, name: "마스터 (16x16)", blockSize: 4, generatorLevel: 3, + contextId: "SUDOKU_16x16_L3", isSequentialLetters: true // 👈 순차 문자 + ), + const GameLevel( + levelIndex: 11, name: "지옥 (16x16)", blockSize: 4, generatorLevel: 5, + contextId: "SUDOKU_16x16_L5" // 👈 랜덤 테마 + ), + ]; + + // 인덱스(1-11)로 레벨 정보 찾기 + static GameLevel getLevel(int levelIndex) { + if (levelIndex < 1) levelIndex = 1; + if (levelIndex > allLevels.length) levelIndex = allLevels.length; + return allLevels.firstWhere((level) => level.levelIndex == levelIndex, + orElse: () => allLevels[0] // 못찾으면 L1 반환 + ); + } + + // 랭킹 화면용 맵 (ContextId -> 이름) + static Map get contextIdToNameMap { + return { for (var level in allLevels) level.contextId : level.name }; + } +} \ No newline at end of file diff --git a/lib/models/sudoku_theme.dart b/lib/models/sudoku_theme.dart index 2aef4e8..3d1c1b2 100644 --- a/lib/models/sudoku_theme.dart +++ b/lib/models/sudoku_theme.dart @@ -84,29 +84,34 @@ class AppThemes { }; // --- 4. [핵심] 게임 시작 시 호출될 테마 '빌더' 함수 --- - static SudokuTheme buildGameTheme(String themeName, int gridSize) { + static SudokuTheme buildGameTheme(String themeName, int gridSize, {bool isEasyMode = false}) { // 👈 [수정] String effectiveThemeName = themeName; - // 1. "랜덤"이 선택된 경우 if (themeName == random) { - // "랜덤"을 제외한 실제 테마 이름 리스트에서 무작위로 하나 선택 final actualThemes = _themePools.keys.toList(); effectiveThemeName = (actualThemes..shuffle()).first; } - // 2. 해당 테마의 '거대 풀'을 가져옴 (없으면 숫자로 대체) final List pool = _themePools[effectiveThemeName] ?? _numberPool; - // 3. 거대 풀을 섞은 뒤, 게임에 필요한 만큼(gridSize)만 뽑음 if (pool.length < gridSize) { throw Exception("$effectiveThemeName 테마의 상징이 ${pool.length}개뿐입니다. $gridSize개가 필요합니다."); } - final List selectedSymbols = (pool.toList()..shuffle()).sublist(0, gridSize); - // 4. 이 게임만을 위한 '일회용' SudokuTheme 객체를 생성하여 반환 + List selectedSymbols; + + // 🔽 [수정] 'isEasyMode'가 true이면 섞지 않고 순서대로 뽑음 + if (isEasyMode) { + // (예: 4x4 Easy -> 1,2,3,4 또는 A,B,C,D) + selectedSymbols = pool.sublist(0, gridSize); + } else { + // 그 외: 거대 풀을 섞은 뒤, gridSize만큼 뽑음 + selectedSymbols = (pool.toList()..shuffle()).sublist(0, gridSize); + } + return SudokuTheme( - name: effectiveThemeName, // 실제 사용된 테마 이름 (예: "과일") - symbols: selectedSymbols, // 무작위로 뽑힌 기호 리스트 + name: effectiveThemeName, + symbols: selectedSymbols, ); } } \ No newline at end of file diff --git a/lib/models/unified_rank_dto.dart b/lib/models/unified_rank_dto.dart index 66b8088..49889a6 100644 --- a/lib/models/unified_rank_dto.dart +++ b/lib/models/unified_rank_dto.dart @@ -1,7 +1,7 @@ // lib/models/unified_rank_dto.dart class UnifiedRankDto { - final String userId; // 👈 [추가] 앱-고유 ID + final String userId; // 👈 [수정] 앱-고유 ID final String gameType; final String? contextId; final String playerName; @@ -9,7 +9,7 @@ class UnifiedRankDto { final int? secondaryScore; UnifiedRankDto({ - required this.userId, // 👈 [추가] 생성자에 추가 + required this.userId, // 👈 [수정] 생성자에 추가 required this.gameType, this.contextId, required this.playerName, @@ -20,7 +20,7 @@ class UnifiedRankDto { // Dart 객체를 JSON으로 변환 (서버 전송용) Map toJson() { return { - 'userId': userId, // 👈 [추가] + 'userId': userId, // 👈 [수정] 'gameType': gameType, 'contextId': contextId, 'playerName': playerName, diff --git a/lib/screens/game_screen.dart b/lib/screens/game_screen.dart index e45663c..509c452 100644 --- a/lib/screens/game_screen.dart +++ b/lib/screens/game_screen.dart @@ -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 { 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 puzzleCells; late List solutionCells; late List originalCells; @@ -80,10 +85,31 @@ class _GameScreenState extends State { @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 { 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 { } 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 { } + // 정답 확인 API 호출 Future _validateGame() async { if (isValidating) return; setState(() { isValidating = true; }); @@ -229,18 +263,17 @@ class _GameScreenState extends State { } } - // 랭킹 등록 팝업 (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 { 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 { onPressed: () async { final playerName = nameController.text.trim(); if (playerName.isEmpty) { - // SnackBar 대신 팝업 내부 에러로 변경 setDialogState(() { dialogErrorMessage = "이름을 입력해주세요."; }); @@ -316,7 +347,7 @@ class _GameScreenState extends State { setDialogState(() { _rankStep = _RankSubmissionStep.submitting; _submittedPlayerName = playerName; - dialogErrorMessage = null; // 👈 [신규] 에러 메시지 초기화 + dialogErrorMessage = null; }); final rankDto = UnifiedRankDto( @@ -330,9 +361,18 @@ class _GameScreenState extends State { 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 { }); } 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 { ); } + // 랭킹 ID 생성을 위해, 원본 문제의 빈칸 비율로 레벨(1~5)을 역산 int _difficultyLevel(String question) { int holes = question.split('').where((c) => c == '0').length; double holeRatio = holes / (gridSize * gridSize); diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 3c4af6a..274bfbc 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -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 { - // 8단계 난이도 - double _difficultyLevel = 4.0; // 1.0 ~ 8.0 (기본값 Level 4: 중급 9x9) - final List 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 _startGame() async { - setState(() { isLoading = true; }); + // 로컬 저장소에서 클리어 레벨 불러오기 + Future _loadProgress() async { + final maxLevel = await _identityService.getMaxUnlockedLevel(); + if (mounted) { + setState(() { + _maxUnlockedLevel = maxLevel; + }); + } + } + + // 게임 시작 로직 + Future _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 { } } 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( - value: _selectedThemeName, - items: AppThemes.selectableThemeNames.map((themeName) { - return DropdownMenuItem( - 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( + value: _selectedThemeName, + items: AppThemes.selectableThemeNames.map((themeName) { + return DropdownMenuItem( + 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 ); } } \ No newline at end of file diff --git a/lib/screens/ranking_screen.dart b/lib/screens/ranking_screen.dart index 21a0141..1cb120b 100644 --- a/lib/screens/ranking_screen.dart +++ b/lib/screens/ranking_screen.dart @@ -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 { final PuzzleService _puzzleService = PuzzleService(); late Future> _rankingFuture; - // 8단계 난이도에 맞는 Context ID 맵 + // 9단계 난이도에 맞는 (이름 -> ContextId) 맵 final Map 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 { Padding( padding: const EdgeInsets.all(16.0), child: DropdownButton( - value: _selectedDifficulty, // 👈 initState에서 설정된 값으로 시작 + value: _selectedDifficultyName, isExpanded: true, - items: difficultyContexts.keys.map((String difficultyName) { + items: AppLevels.allLevels.map((level) { return DropdownMenuItem( - value: difficultyName, - child: Text(difficultyName), + value: level.name, + child: Text(level.name), ); }).toList(), onChanged: (String? newValue) { diff --git a/lib/services/identity_service.dart b/lib/services/identity_service.dart index 31284a3..e97f732 100644 --- a/lib/services/identity_service.dart +++ b/lib/services/identity_service.dart @@ -1,12 +1,11 @@ -// lib/services/identity_service.dart - import 'package:shared_preferences/shared_preferences.dart'; import 'package:uuid/uuid.dart'; -// 앱-고유 ID와 사용자 이름을 관리하는 서비스 +// 앱-고유 ID와 사용자 이름, 레벨 진행 상황을 관리하는 서비스 class IdentityService { static const String _userIdKey = 'app_user_id'; static const String _userNameKey = 'app_user_name'; + static const String _maxLevelKey = 'max_unlocked_level'; // 👈 [추가] // 1. 앱-고유 ID 가져오기 (없으면 생성) Future getOrCreateUserId() async { @@ -32,4 +31,17 @@ class IdentityService { final prefs = await SharedPreferences.getInstance(); await prefs.setString(_userNameKey, name); } + + // 4. 🔽 [추가] 현재 잠금 해제된 최고 레벨 가져오기 + Future getMaxUnlockedLevel() async { + final prefs = await SharedPreferences.getInstance(); + // 최초 실행 시 1 (L1) 반환, 9레벨 클리어 시 99 반환 + return prefs.getInt(_maxLevelKey) ?? 1; + } + + // 5. 🔽 [추가] 새 레벨 잠금 해제 + Future saveMaxUnlockedLevel(int level) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_maxLevelKey, level); + } } \ No newline at end of file diff --git a/lib/services/puzzle_service.dart b/lib/services/puzzle_service.dart index ab50a02..36a3542 100644 --- a/lib/services/puzzle_service.dart +++ b/lib/services/puzzle_service.dart @@ -1,5 +1,5 @@ import 'dart:convert'; -import 'dart:developer'; // 👈 [추가] log 함수를 위한 임포트 +import 'dart:developer'; import 'package:http/http.dart' as http; import 'package:sudoku_app/models/sudoku_game_dto.dart'; import 'package:sudoku_app/models/unified_rank_dto.dart'; @@ -8,11 +8,13 @@ import 'package:sudoku_app/models/game_rank_dto.dart'; class PuzzleService { final String _baseUrl = "https://lunaticbum.kr"; - // ... (startGame 함수는 동일) ... + // 🔽 [수정] 'difficulty' 파라미터 1개만 받음 (1~11) Future startGame(String difficulty) async { final response = await http.get( + // 🔽 [수정] 'difficulty' 파라미터만 전달 Uri.parse('$_baseUrl/puzzle/sudoku/start?difficulty=$difficulty'), ); + if (response.statusCode == 200) { final data = jsonDecode(utf8.decode(response.bodyBytes)); return SudokuGameDto.fromJson(data); @@ -21,7 +23,7 @@ class PuzzleService { } } - // ... (validateSolution 함수는 동일) ... + // 'puzzleId'를 받아 검증 (서버 DTO와 일치) Future validateSolution(int puzzleId, String answer) async { final response = await http.post( Uri.parse('$_baseUrl/puzzle/sudoku/validate'), @@ -31,6 +33,7 @@ class PuzzleService { 'answer': answer, }), ); + if (response.statusCode == 200) { return jsonDecode(response.body)['correct'] ?? false; } else { @@ -40,12 +43,10 @@ class PuzzleService { } } - // POST /api/ranks/submit + // 랭킹 등록 Future submitRank(UnifiedRankDto rankDto) async { final requestBody = jsonEncode(rankDto.toJson()); - - // 🔽 [로그 추가] 1. 서버로 전송하는 JSON 데이터 출력 log(">>> 랭킹 등록 요청: $requestBody"); final response = await http.post( @@ -55,22 +56,19 @@ class PuzzleService { ); if (response.statusCode != 200) { - // 🔽 [로그 추가] 2. 서버가 200(OK)이 아닌 응답을 줬을 때 log("<<< 랭킹 등록 실패: ${response.statusCode}"); try { final errorBody = utf8.decode(response.bodyBytes); - log("<<< 서버 에러 메시지: $errorBody"); // 👈 (예: "이미 사용 중인 이름입니다.") + log("<<< 서버 에러 메시지: $errorBody"); throw Exception(errorBody); } catch (e) { throw Exception('랭킹 등록 실패: ${response.reasonPhrase}'); } } - - // 🔽 [로그 추가] 3. 성공 시 log("<<< 랭킹 등록 성공: 200 OK"); } - // ... (fetchRanks 함수는 동일) ... + // 랭킹 조회 Future> fetchRanks(String gameType, String? contextId) async { final queryParams = { 'gameType': gameType, diff --git a/lib/widgets/sudoku_board.dart b/lib/widgets/sudoku_board.dart index 4ceac48..39c37e6 100644 --- a/lib/widgets/sudoku_board.dart +++ b/lib/widgets/sudoku_board.dart @@ -1,13 +1,13 @@ import 'package:flutter/material.dart'; -import 'package:sudoku_app/models/sudoku_theme.dart'; // 👈 테마 모델 임포트 +import 'package:sudoku_app/models/sudoku_theme.dart'; class SudokuBoard extends StatelessWidget { final int blockSize; - final SudokuTheme theme; // 👈 이번 게임의 테마 - final List cells; // 👈 List (0, 1, 10...) - final List originalCells; // 👈 List (0, 1, 10...) + final SudokuTheme theme; + final List cells; + final List originalCells; final int? selectedIndex; - final int? selectedNumberPad; // 10진수 숫자 (1, 10...) + final int? selectedNumberPad; final Set incorrectCells; final Function(int) onCellTapped; @@ -26,7 +26,6 @@ class SudokuBoard extends StatelessWidget { @override Widget build(BuildContext context) { final int gridSize = blockSize * blockSize; - // 그리드 크기에 따라 폰트 크기 동적 조절 final double fontSize = (gridSize > 9) ? (gridSize > 16 ? 12 : 16) : 24; return AspectRatio( @@ -41,18 +40,19 @@ class SudokuBoard extends StatelessWidget { int row = index ~/ gridSize; int col = index % gridSize; - int cellValue = cells[index]; // 0, 1, 10... + int cellValue = cells[index]; bool isEditable = (originalCells[index] == 0); bool isSelected = (index == selectedIndex); - // int == int 비교 + int selectedNumAsInt = selectedNumberPad ?? -1; + String selectedNumAsSymbol = (selectedNumberPad != null) ? theme.getSymbol(selectedNumberPad!) : ""; + bool isHighlighted = (cellValue != 0 && selectedNumberPad != null && cellValue == selectedNumberPad); bool isIncorrect = incorrectCells.contains(index); - // blockSize에 따른 동적 보더 BorderSide thickBorder = const BorderSide(color: Colors.black, width: 2.0); BorderSide thinBorder = const BorderSide(color: Colors.grey, width: 0.5); @@ -61,15 +61,14 @@ class SudokuBoard extends StatelessWidget { child: Container( alignment: Alignment.center, decoration: BoxDecoration( + // 🔽 [수정] 'isSelected' 조건 제거 (배경색 없음) color: isIncorrect - ? Colors.red.shade100 - : isSelected - ? Colors.blue.shade100 - : isHighlighted - ? Colors.blue.shade200 - : isEditable - ? Colors.white - : Colors.grey.shade200, + ? Colors.red.shade100 // 1순위: 틀림 + : isHighlighted + ? Colors.blue.shade200 // 2순위: 숫자 하이라이트 + : isEditable + ? Colors.white + : Colors.grey.shade200, // 기본 border: Border( top: (row == 0) ? thickBorder : thinBorder, left: (col == 0) ? thickBorder : thinBorder, @@ -78,16 +77,18 @@ class SudokuBoard extends StatelessWidget { ), ), child: Text( - // 0이면 비우고, 아니면 테마 기호("1", "A", "🍎") 표시 cellValue == 0 ? '' : theme.getSymbol(cellValue), style: TextStyle( fontSize: fontSize, fontWeight: FontWeight.bold, - color: isIncorrect - ? Colors.red.shade900 - : isEditable - ? Colors.blue - : Colors.black, + // 🔽 [수정] 'isSelected'를 최우선 순위로 추가 (텍스트 색상 변경) + color: isSelected + ? Colors.orange.shade700 // 1순위: 선택된 셀 + : isIncorrect + ? Colors.red.shade900 // 2순위: 틀린 셀 + : isEditable + ? Colors.blue // 3순위: 수정 가능 셀 + : Colors.black, // 기본 (원본 숫자) ), ), ),