...
This commit is contained in:
parent
1883fef583
commit
5a0785f262
94
lib/models/game_level.dart
Normal file
94
lib/models/game_level.dart
Normal file
@ -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<GameLevel> 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<String, String> get contextIdToNameMap {
|
||||||
|
return { for (var level in allLevels) level.contextId : level.name };
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -84,29 +84,34 @@ class AppThemes {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// --- 4. [핵심] 게임 시작 시 호출될 테마 '빌더' 함수 ---
|
// --- 4. [핵심] 게임 시작 시 호출될 테마 '빌더' 함수 ---
|
||||||
static SudokuTheme buildGameTheme(String themeName, int gridSize) {
|
static SudokuTheme buildGameTheme(String themeName, int gridSize, {bool isEasyMode = false}) { // 👈 [수정]
|
||||||
String effectiveThemeName = themeName;
|
String effectiveThemeName = themeName;
|
||||||
|
|
||||||
// 1. "랜덤"이 선택된 경우
|
|
||||||
if (themeName == random) {
|
if (themeName == random) {
|
||||||
// "랜덤"을 제외한 실제 테마 이름 리스트에서 무작위로 하나 선택
|
|
||||||
final actualThemes = _themePools.keys.toList();
|
final actualThemes = _themePools.keys.toList();
|
||||||
effectiveThemeName = (actualThemes..shuffle()).first;
|
effectiveThemeName = (actualThemes..shuffle()).first;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 해당 테마의 '거대 풀'을 가져옴 (없으면 숫자로 대체)
|
|
||||||
final List<String> pool = _themePools[effectiveThemeName] ?? _numberPool;
|
final List<String> pool = _themePools[effectiveThemeName] ?? _numberPool;
|
||||||
|
|
||||||
// 3. 거대 풀을 섞은 뒤, 게임에 필요한 만큼(gridSize)만 뽑음
|
|
||||||
if (pool.length < gridSize) {
|
if (pool.length < gridSize) {
|
||||||
throw Exception("$effectiveThemeName 테마의 상징이 ${pool.length}개뿐입니다. $gridSize개가 필요합니다.");
|
throw Exception("$effectiveThemeName 테마의 상징이 ${pool.length}개뿐입니다. $gridSize개가 필요합니다.");
|
||||||
}
|
}
|
||||||
final List<String> selectedSymbols = (pool.toList()..shuffle()).sublist(0, gridSize);
|
|
||||||
|
|
||||||
// 4. 이 게임만을 위한 '일회용' SudokuTheme 객체를 생성하여 반환
|
List<String> 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(
|
return SudokuTheme(
|
||||||
name: effectiveThemeName, // 실제 사용된 테마 이름 (예: "과일")
|
name: effectiveThemeName,
|
||||||
symbols: selectedSymbols, // 무작위로 뽑힌 기호 리스트
|
symbols: selectedSymbols,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
// lib/models/unified_rank_dto.dart
|
// lib/models/unified_rank_dto.dart
|
||||||
|
|
||||||
class UnifiedRankDto {
|
class UnifiedRankDto {
|
||||||
final String userId; // 👈 [추가] 앱-고유 ID
|
final String userId; // 👈 [수정] 앱-고유 ID
|
||||||
final String gameType;
|
final String gameType;
|
||||||
final String? contextId;
|
final String? contextId;
|
||||||
final String playerName;
|
final String playerName;
|
||||||
@ -9,7 +9,7 @@ class UnifiedRankDto {
|
|||||||
final int? secondaryScore;
|
final int? secondaryScore;
|
||||||
|
|
||||||
UnifiedRankDto({
|
UnifiedRankDto({
|
||||||
required this.userId, // 👈 [추가] 생성자에 추가
|
required this.userId, // 👈 [수정] 생성자에 추가
|
||||||
required this.gameType,
|
required this.gameType,
|
||||||
this.contextId,
|
this.contextId,
|
||||||
required this.playerName,
|
required this.playerName,
|
||||||
@ -20,7 +20,7 @@ class UnifiedRankDto {
|
|||||||
// Dart 객체를 JSON으로 변환 (서버 전송용)
|
// Dart 객체를 JSON으로 변환 (서버 전송용)
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
return {
|
return {
|
||||||
'userId': userId, // 👈 [추가]
|
'userId': userId, // 👈 [수정]
|
||||||
'gameType': gameType,
|
'gameType': gameType,
|
||||||
'contextId': contextId,
|
'contextId': contextId,
|
||||||
'playerName': playerName,
|
'playerName': playerName,
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
import 'package:flutter/material.dart';
|
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_game_dto.dart';
|
||||||
import 'package:sudoku_app/models/sudoku_theme.dart';
|
import 'package:sudoku_app/models/sudoku_theme.dart';
|
||||||
import 'package:sudoku_app/models/unified_rank_dto.dart';
|
import 'package:sudoku_app/models/unified_rank_dto.dart';
|
||||||
@ -19,6 +20,7 @@ class GameScreen extends StatefulWidget {
|
|||||||
final String themeName;
|
final String themeName;
|
||||||
final String userId;
|
final String userId;
|
||||||
final String? userName;
|
final String? userName;
|
||||||
|
final int levelIndex; // 👈 HomeScreen에서 전달받은 현재 레벨
|
||||||
|
|
||||||
const GameScreen({
|
const GameScreen({
|
||||||
super.key,
|
super.key,
|
||||||
@ -26,6 +28,7 @@ class GameScreen extends StatefulWidget {
|
|||||||
required this.themeName,
|
required this.themeName,
|
||||||
required this.userId,
|
required this.userId,
|
||||||
required this.userName,
|
required this.userName,
|
||||||
|
required this.levelIndex,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -36,10 +39,12 @@ class _GameScreenState extends State<GameScreen> {
|
|||||||
final PuzzleService _puzzleService = PuzzleService();
|
final PuzzleService _puzzleService = PuzzleService();
|
||||||
final IdentityService _identityService = IdentityService();
|
final IdentityService _identityService = IdentityService();
|
||||||
|
|
||||||
|
late final GameLevel currentLevel; // 👈 현재 레벨 정보
|
||||||
late final int blockSize;
|
late final int blockSize;
|
||||||
late final int gridSize;
|
late final int gridSize;
|
||||||
late final SudokuTheme activeTheme;
|
late final SudokuTheme activeTheme; // 👈 이번 게임의 '동적' 테마
|
||||||
|
|
||||||
|
// 모든 내부 로직은 'int'로 관리
|
||||||
late List<int> puzzleCells;
|
late List<int> puzzleCells;
|
||||||
late List<int> solutionCells;
|
late List<int> solutionCells;
|
||||||
late List<int> originalCells;
|
late List<int> originalCells;
|
||||||
@ -80,10 +85,31 @@ class _GameScreenState extends State<GameScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
blockSize = widget.gameData.blockSize;
|
// 1. HomeScreen에서 받은 levelIndex로 현재 레벨 정보 로드
|
||||||
gridSize = widget.gameData.gridSize;
|
currentLevel = AppLevels.getLevel(widget.levelIndex);
|
||||||
activeTheme = AppThemes.buildGameTheme(widget.themeName, gridSize);
|
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();
|
puzzleCells = widget.gameData.question.split('').map(_charToInt).toList();
|
||||||
solutionCells = widget.gameData.solution.split('').map(_charToInt).toList();
|
solutionCells = widget.gameData.solution.split('').map(_charToInt).toList();
|
||||||
originalCells = widget.gameData.question.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) {
|
void onCellTapped(int index) {
|
||||||
if (originalCells[index] == 0) {
|
if (originalCells[index] == 0) {
|
||||||
|
|
||||||
|
if (incorrectCells.isNotEmpty && !incorrectCells.contains(index)) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('틀린 값을 먼저 수정해주세요. (되돌리기 ↩)'),
|
||||||
|
duration: Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
selectedIndex = index;
|
selectedIndex = index;
|
||||||
|
|
||||||
if (selectedNumberPad != null) {
|
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!;
|
final int numberValue = selectedNumberPad!;
|
||||||
puzzleCells[index] = numberValue;
|
puzzleCells[index] = numberValue;
|
||||||
|
|
||||||
@ -159,12 +185,19 @@ class _GameScreenState extends State<GameScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void onUndoTapped() {
|
void onUndoTapped() {
|
||||||
setState(() {
|
if (incorrectCells.isNotEmpty) {
|
||||||
if (selectedIndex != null && originalCells[selectedIndex!] == 0) {
|
int errorIndex = incorrectCells.first;
|
||||||
|
setState(() {
|
||||||
|
puzzleCells[errorIndex] = 0;
|
||||||
|
incorrectCells.remove(errorIndex);
|
||||||
|
selectedIndex = errorIndex;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (selectedIndex != null && originalCells[selectedIndex!] == 0) {
|
||||||
|
setState(() {
|
||||||
puzzleCells[selectedIndex!] = 0;
|
puzzleCells[selectedIndex!] = 0;
|
||||||
incorrectCells.remove(selectedIndex);
|
});
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void onHintTapped() {
|
void onHintTapped() {
|
||||||
@ -192,6 +225,7 @@ class _GameScreenState extends State<GameScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 정답 확인 API 호출
|
||||||
Future<void> _validateGame() async {
|
Future<void> _validateGame() async {
|
||||||
if (isValidating) return;
|
if (isValidating) return;
|
||||||
setState(() { isValidating = true; });
|
setState(() { isValidating = true; });
|
||||||
@ -229,18 +263,17 @@ class _GameScreenState extends State<GameScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 랭킹 등록 팝업 (2단계 UI)
|
// 랭킹 등록 팝업 (2단계 UI + 레벨 잠금 해제)
|
||||||
void _showRankingDialog() {
|
void _showRankingDialog() {
|
||||||
final nameController = TextEditingController(text: widget.userName);
|
final nameController = TextEditingController(text: widget.userName);
|
||||||
bool isSubmitting = false;
|
bool isSubmitting = false;
|
||||||
final bool hasExistingName = widget.userName != null;
|
String? dialogErrorMessage;
|
||||||
|
|
||||||
_rankStep = _RankSubmissionStep.enterName;
|
_rankStep = _RankSubmissionStep.enterName;
|
||||||
_rankingList = [];
|
_rankingList = [];
|
||||||
_submittedPlayerName = "";
|
_submittedPlayerName = "";
|
||||||
String? dialogErrorMessage; // 👈 [신규] 팝업 내부 에러 메시지
|
|
||||||
|
|
||||||
final String contextId = "SUDOKU_${gridSize}x${gridSize}_L${_difficultyLevel(widget.gameData.question)}";
|
final String contextId = currentLevel.contextId;
|
||||||
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
@ -290,12 +323,11 @@ class _GameScreenState extends State<GameScreen> {
|
|||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
TextField(
|
TextField(
|
||||||
controller: nameController,
|
controller: nameController,
|
||||||
readOnly: hasExistingName,
|
readOnly: false,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: hasExistingName ? '등록된 이름' : '이름 (10자 이내)',
|
labelText: '이름 (10자 이내)',
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
// 🔽 [신규] 에러 메시지가 있으면 TextField에 에러 스타일 적용
|
errorText: dialogErrorMessage,
|
||||||
errorText: dialogErrorMessage,
|
|
||||||
),
|
),
|
||||||
maxLength: 10,
|
maxLength: 10,
|
||||||
),
|
),
|
||||||
@ -306,7 +338,6 @@ class _GameScreenState extends State<GameScreen> {
|
|||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final playerName = nameController.text.trim();
|
final playerName = nameController.text.trim();
|
||||||
if (playerName.isEmpty) {
|
if (playerName.isEmpty) {
|
||||||
// SnackBar 대신 팝업 내부 에러로 변경
|
|
||||||
setDialogState(() {
|
setDialogState(() {
|
||||||
dialogErrorMessage = "이름을 입력해주세요.";
|
dialogErrorMessage = "이름을 입력해주세요.";
|
||||||
});
|
});
|
||||||
@ -316,7 +347,7 @@ class _GameScreenState extends State<GameScreen> {
|
|||||||
setDialogState(() {
|
setDialogState(() {
|
||||||
_rankStep = _RankSubmissionStep.submitting;
|
_rankStep = _RankSubmissionStep.submitting;
|
||||||
_submittedPlayerName = playerName;
|
_submittedPlayerName = playerName;
|
||||||
dialogErrorMessage = null; // 👈 [신규] 에러 메시지 초기화
|
dialogErrorMessage = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
final rankDto = UnifiedRankDto(
|
final rankDto = UnifiedRankDto(
|
||||||
@ -330,9 +361,18 @@ class _GameScreenState extends State<GameScreen> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await _puzzleService.submitRank(rankDto);
|
await _puzzleService.submitRank(rankDto);
|
||||||
|
await _identityService.saveUserName(playerName);
|
||||||
if (!hasExistingName) {
|
|
||||||
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);
|
final ranks = await _puzzleService.fetchRanks('SUDOKU', contextId);
|
||||||
@ -343,15 +383,11 @@ class _GameScreenState extends State<GameScreen> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 🔽 [수정] 랭킹 등록 실패 시 (이름 중복 등)
|
|
||||||
log("!!! 랭킹 등록 실패 !!!", error: e);
|
log("!!! 랭킹 등록 실패 !!!", error: e);
|
||||||
|
|
||||||
setDialogState(() {
|
setDialogState(() {
|
||||||
_rankStep = _RankSubmissionStep.enterName; // 1단계(이름 입력)로 복귀
|
_rankStep = _RankSubmissionStep.enterName;
|
||||||
// 👈 [신규] 서버 에러 메시지를 팝업에 표시
|
dialogErrorMessage = e.toString().replaceFirst("Exception: ", "");
|
||||||
dialogErrorMessage = e.toString().replaceFirst("Exception: ", "");
|
|
||||||
});
|
});
|
||||||
// ❌ SnackBar 제거
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: const Text('랭킹 등록'),
|
child: const Text('랭킹 등록'),
|
||||||
@ -399,6 +435,7 @@ class _GameScreenState extends State<GameScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 랭킹 ID 생성을 위해, 원본 문제의 빈칸 비율로 레벨(1~5)을 역산
|
||||||
int _difficultyLevel(String question) {
|
int _difficultyLevel(String question) {
|
||||||
int holes = question.split('').where((c) => c == '0').length;
|
int holes = question.split('').where((c) => c == '0').length;
|
||||||
double holeRatio = holes / (gridSize * gridSize);
|
double holeRatio = holes / (gridSize * gridSize);
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
import 'package:flutter/material.dart';
|
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_game_dto.dart';
|
||||||
import 'package:sudoku_app/models/sudoku_theme.dart';
|
import 'package:sudoku_app/models/sudoku_theme.dart';
|
||||||
import 'package:sudoku_app/screens/game_screen.dart';
|
import 'package:sudoku_app/screens/game_screen.dart';
|
||||||
import 'package:sudoku_app/screens/ranking_screen.dart';
|
import 'package:sudoku_app/screens/ranking_screen.dart';
|
||||||
import 'package:sudoku_app/services/puzzle_service.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';
|
import 'package:sudoku_app/widgets/ad_banner_widget.dart';
|
||||||
|
|
||||||
class HomeScreen extends StatefulWidget {
|
class HomeScreen extends StatefulWidget {
|
||||||
@ -15,51 +16,61 @@ class HomeScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _HomeScreenState extends State<HomeScreen> {
|
class _HomeScreenState extends State<HomeScreen> {
|
||||||
// 8단계 난이도
|
// '최대 잠금 해제 레벨' 상태
|
||||||
double _difficultyLevel = 4.0; // 1.0 ~ 8.0 (기본값 Level 4: 중급 9x9)
|
int _maxUnlockedLevel = 1;
|
||||||
final List<String> levelLabels = [
|
|
||||||
"입문 (4x4)", "초급 (4x4)",
|
|
||||||
"쉬움 (9x9)", "중급 (9x9)", "어려움 (9x9)",
|
|
||||||
"전문가 (16x16)", "마스터 (16x16)", "지옥 (16x16)"
|
|
||||||
];
|
|
||||||
|
|
||||||
late String _selectedThemeName;
|
late String _selectedThemeName;
|
||||||
bool isLoading = false;
|
bool _isLoading = false;
|
||||||
|
|
||||||
final PuzzleService _puzzleService = PuzzleService();
|
final PuzzleService _puzzleService = PuzzleService();
|
||||||
final IdentityService _identityService = IdentityService(); // 👈 ID 서비스 초기화
|
final IdentityService _identityService = IdentityService();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.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 {
|
try {
|
||||||
// 1. 난이도 값(String) 전달
|
// 1. 선택한 레벨의 '인덱스'(1-11)를 문자열로 전달
|
||||||
final String difficulty = _difficultyLevel.round().toString();
|
final String difficulty = level.levelIndex.toString();
|
||||||
|
|
||||||
// 2. 서버에서 게임 데이터 가져오기
|
|
||||||
final SudokuGameDto gameData = await _puzzleService.startGame(difficulty);
|
final SudokuGameDto gameData = await _puzzleService.startGame(difficulty);
|
||||||
|
|
||||||
// 3. 로컬에서 앱-고유 ID와 저장된 이름 가져오기
|
|
||||||
final String userId = await _identityService.getOrCreateUserId();
|
final String userId = await _identityService.getOrCreateUserId();
|
||||||
final String? userName = await _identityService.getSavedUserName();
|
final String? userName = await _identityService.getSavedUserName();
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
Navigator.push(
|
// 2. GameScreen으로 이동 (게임 클리어 후 돌아오면 _loadProgress() 호출)
|
||||||
|
await Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => GameScreen(
|
builder: (context) => GameScreen(
|
||||||
gameData: gameData,
|
gameData: gameData,
|
||||||
themeName: _selectedThemeName,
|
themeName: _selectedThemeName, // 👈 '랜덤' 또는 '과일' 등
|
||||||
userId: userId, // 👈 ID 전달
|
userId: userId,
|
||||||
userName: userName, // 👈 이름 전달
|
userName: userName,
|
||||||
|
levelIndex: level.levelIndex, // 👈 1~11
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 3. GameScreen에서 돌아왔을 때, 진행 상황(클리어)을 다시 로드
|
||||||
|
_loadProgress();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@ -69,107 +80,109 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() { isLoading = false; });
|
setState(() { _isLoading = false; });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
// 99 = 모든 레벨 클리어
|
||||||
|
final bool allLevelsUnlocked = _maxUnlockedLevel >= 99;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('스도쿠 게임')),
|
appBar: AppBar(title: const Text('스도쿠 게임')),
|
||||||
body: LayoutBuilder( // 비율 기반 레이아웃
|
body: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
// 너비/높이 비율 변수 (0.6 = 너비가 높이의 60%를 넘지 않도록 함)
|
|
||||||
const double maxContentRatio = 0.6;
|
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(
|
return Center(
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: BoxConstraints(maxWidth: constrainedWidth),
|
constraints: BoxConstraints(maxWidth: constrainedWidth),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
// 1. 테마 선택
|
||||||
child: Center(
|
Padding(
|
||||||
child: Padding(
|
padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 10.0),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20.0),
|
child: Row(
|
||||||
child: Column(
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
children: [
|
||||||
children: [
|
const Text("테마: ", style: TextStyle(fontSize: 18)),
|
||||||
// 1. 난이도 선택 (8단계)
|
DropdownButton<String>(
|
||||||
const Text("난이도", style: TextStyle(fontSize: 18)),
|
value: _selectedThemeName,
|
||||||
Text(
|
items: AppThemes.selectableThemeNames.map((themeName) {
|
||||||
levelLabels[_difficultyLevel.round() - 1],
|
return DropdownMenuItem<String>(
|
||||||
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.blue),
|
value: themeName,
|
||||||
),
|
child: Text(themeName, style: const TextStyle(fontSize: 20)),
|
||||||
Slider(
|
);
|
||||||
value: _difficultyLevel,
|
}).toList(),
|
||||||
min: 1.0, max: 8.0, divisions: 7, // 8단계
|
onChanged: (themeName) {
|
||||||
label: levelLabels[_difficultyLevel.round() - 1],
|
if (themeName != null) {
|
||||||
onChanged: (newValue) => setState(() { _difficultyLevel = newValue; }),
|
setState(() { _selectedThemeName = themeName; });
|
||||||
),
|
}
|
||||||
|
},
|
||||||
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('랭킹 보기'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,14 +1,15 @@
|
|||||||
import 'package:flutter/material.dart';
|
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/models/game_rank_dto.dart';
|
||||||
import 'package:sudoku_app/services/puzzle_service.dart';
|
import 'package:sudoku_app/services/puzzle_service.dart';
|
||||||
|
|
||||||
class RankingScreen extends StatefulWidget {
|
class RankingScreen extends StatefulWidget {
|
||||||
// 🔽 [추가] 홈 화면에서 전달받을 초기 난이도 이름
|
// 🔽 [수정] 홈 화면에서 전달받을 초기 난이도 이름
|
||||||
final String? initialDifficultyName;
|
final String? initialDifficultyName;
|
||||||
|
|
||||||
const RankingScreen({
|
const RankingScreen({
|
||||||
super.key,
|
super.key,
|
||||||
this.initialDifficultyName, // 👈 생성자에 추가
|
this.initialDifficultyName, // 👈 [수정] 생성자에 이 파라미터 추가
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -19,39 +20,28 @@ class _RankingScreenState extends State<RankingScreen> {
|
|||||||
final PuzzleService _puzzleService = PuzzleService();
|
final PuzzleService _puzzleService = PuzzleService();
|
||||||
late Future<List<GameRankDto>> _rankingFuture;
|
late Future<List<GameRankDto>> _rankingFuture;
|
||||||
|
|
||||||
// 8단계 난이도에 맞는 Context ID 맵
|
// 9단계 난이도에 맞는 (이름 -> ContextId) 맵
|
||||||
final Map<String, String> difficultyContexts = {
|
final Map<String, String> difficultyContexts = {
|
||||||
"입문 (4x4)": "SUDOKU_4x4_L1",
|
for (var level in AppLevels.allLevels) level.name : level.contextId
|
||||||
"초급 (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",
|
|
||||||
};
|
};
|
||||||
late String _selectedDifficulty;
|
late String _selectedDifficultyName;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// 🔽 [수정]
|
|
||||||
// 1. HomeScreen에서 전달받은 값이 있는지 확인
|
|
||||||
String defaultDifficulty = widget.initialDifficultyName ?? "중급 (9x9)";
|
String defaultDifficulty = widget.initialDifficultyName ?? "중급 (9x9)";
|
||||||
|
|
||||||
// 2. (안전장치) 전달받은 값이 맵에 없으면(예: 향후 변경) 기본값 사용
|
|
||||||
if (!difficultyContexts.containsKey(defaultDifficulty)) {
|
if (!difficultyContexts.containsKey(defaultDifficulty)) {
|
||||||
defaultDifficulty = "중급 (9x9)";
|
defaultDifficulty = "중급 (9x9)";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 랭킹 조회
|
|
||||||
_fetchRanksForDifficulty(defaultDifficulty);
|
_fetchRanksForDifficulty(defaultDifficulty);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _fetchRanksForDifficulty(String difficultyName) {
|
void _fetchRanksForDifficulty(String difficultyName) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedDifficulty = difficultyName;
|
_selectedDifficultyName = difficultyName;
|
||||||
_rankingFuture = _puzzleService.fetchRanks('SUDOKU', difficultyContexts[_selectedDifficulty]);
|
_rankingFuture = _puzzleService.fetchRanks('SUDOKU', difficultyContexts[_selectedDifficultyName]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -78,12 +68,12 @@ class _RankingScreenState extends State<RankingScreen> {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: DropdownButton<String>(
|
child: DropdownButton<String>(
|
||||||
value: _selectedDifficulty, // 👈 initState에서 설정된 값으로 시작
|
value: _selectedDifficultyName,
|
||||||
isExpanded: true,
|
isExpanded: true,
|
||||||
items: difficultyContexts.keys.map((String difficultyName) {
|
items: AppLevels.allLevels.map((level) {
|
||||||
return DropdownMenuItem<String>(
|
return DropdownMenuItem<String>(
|
||||||
value: difficultyName,
|
value: level.name,
|
||||||
child: Text(difficultyName),
|
child: Text(level.name),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
onChanged: (String? newValue) {
|
onChanged: (String? newValue) {
|
||||||
|
|||||||
@ -1,12 +1,11 @@
|
|||||||
// lib/services/identity_service.dart
|
|
||||||
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
// 앱-고유 ID와 사용자 이름을 관리하는 서비스
|
// 앱-고유 ID와 사용자 이름, 레벨 진행 상황을 관리하는 서비스
|
||||||
class IdentityService {
|
class IdentityService {
|
||||||
static const String _userIdKey = 'app_user_id';
|
static const String _userIdKey = 'app_user_id';
|
||||||
static const String _userNameKey = 'app_user_name';
|
static const String _userNameKey = 'app_user_name';
|
||||||
|
static const String _maxLevelKey = 'max_unlocked_level'; // 👈 [추가]
|
||||||
|
|
||||||
// 1. 앱-고유 ID 가져오기 (없으면 생성)
|
// 1. 앱-고유 ID 가져오기 (없으면 생성)
|
||||||
Future<String> getOrCreateUserId() async {
|
Future<String> getOrCreateUserId() async {
|
||||||
@ -32,4 +31,17 @@ class IdentityService {
|
|||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setString(_userNameKey, name);
|
await prefs.setString(_userNameKey, name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4. 🔽 [추가] 현재 잠금 해제된 최고 레벨 가져오기
|
||||||
|
Future<int> getMaxUnlockedLevel() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
// 최초 실행 시 1 (L1) 반환, 9레벨 클리어 시 99 반환
|
||||||
|
return prefs.getInt(_maxLevelKey) ?? 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 🔽 [추가] 새 레벨 잠금 해제
|
||||||
|
Future<void> saveMaxUnlockedLevel(int level) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setInt(_maxLevelKey, level);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:developer'; // 👈 [추가] log 함수를 위한 임포트
|
import 'dart:developer';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:sudoku_app/models/sudoku_game_dto.dart';
|
import 'package:sudoku_app/models/sudoku_game_dto.dart';
|
||||||
import 'package:sudoku_app/models/unified_rank_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 {
|
class PuzzleService {
|
||||||
final String _baseUrl = "https://lunaticbum.kr";
|
final String _baseUrl = "https://lunaticbum.kr";
|
||||||
|
|
||||||
// ... (startGame 함수는 동일) ...
|
// 🔽 [수정] 'difficulty' 파라미터 1개만 받음 (1~11)
|
||||||
Future<SudokuGameDto> startGame(String difficulty) async {
|
Future<SudokuGameDto> startGame(String difficulty) async {
|
||||||
final response = await http.get(
|
final response = await http.get(
|
||||||
|
// 🔽 [수정] 'difficulty' 파라미터만 전달
|
||||||
Uri.parse('$_baseUrl/puzzle/sudoku/start?difficulty=$difficulty'),
|
Uri.parse('$_baseUrl/puzzle/sudoku/start?difficulty=$difficulty'),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
final data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||||
return SudokuGameDto.fromJson(data);
|
return SudokuGameDto.fromJson(data);
|
||||||
@ -21,7 +23,7 @@ class PuzzleService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ... (validateSolution 함수는 동일) ...
|
// 'puzzleId'를 받아 검증 (서버 DTO와 일치)
|
||||||
Future<bool> validateSolution(int puzzleId, String answer) async {
|
Future<bool> validateSolution(int puzzleId, String answer) async {
|
||||||
final response = await http.post(
|
final response = await http.post(
|
||||||
Uri.parse('$_baseUrl/puzzle/sudoku/validate'),
|
Uri.parse('$_baseUrl/puzzle/sudoku/validate'),
|
||||||
@ -31,6 +33,7 @@ class PuzzleService {
|
|||||||
'answer': answer,
|
'answer': answer,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
return jsonDecode(response.body)['correct'] ?? false;
|
return jsonDecode(response.body)['correct'] ?? false;
|
||||||
} else {
|
} else {
|
||||||
@ -40,12 +43,10 @@ class PuzzleService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/ranks/submit
|
// 랭킹 등록
|
||||||
Future<void> submitRank(UnifiedRankDto rankDto) async {
|
Future<void> submitRank(UnifiedRankDto rankDto) async {
|
||||||
|
|
||||||
final requestBody = jsonEncode(rankDto.toJson());
|
final requestBody = jsonEncode(rankDto.toJson());
|
||||||
|
|
||||||
// 🔽 [로그 추가] 1. 서버로 전송하는 JSON 데이터 출력
|
|
||||||
log(">>> 랭킹 등록 요청: $requestBody");
|
log(">>> 랭킹 등록 요청: $requestBody");
|
||||||
|
|
||||||
final response = await http.post(
|
final response = await http.post(
|
||||||
@ -55,22 +56,19 @@ class PuzzleService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
// 🔽 [로그 추가] 2. 서버가 200(OK)이 아닌 응답을 줬을 때
|
|
||||||
log("<<< 랭킹 등록 실패: ${response.statusCode}");
|
log("<<< 랭킹 등록 실패: ${response.statusCode}");
|
||||||
try {
|
try {
|
||||||
final errorBody = utf8.decode(response.bodyBytes);
|
final errorBody = utf8.decode(response.bodyBytes);
|
||||||
log("<<< 서버 에러 메시지: $errorBody"); // 👈 (예: "이미 사용 중인 이름입니다.")
|
log("<<< 서버 에러 메시지: $errorBody");
|
||||||
throw Exception(errorBody);
|
throw Exception(errorBody);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw Exception('랭킹 등록 실패: ${response.reasonPhrase}');
|
throw Exception('랭킹 등록 실패: ${response.reasonPhrase}');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔽 [로그 추가] 3. 성공 시
|
|
||||||
log("<<< 랭킹 등록 성공: 200 OK");
|
log("<<< 랭킹 등록 성공: 200 OK");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ... (fetchRanks 함수는 동일) ...
|
// 랭킹 조회
|
||||||
Future<List<GameRankDto>> fetchRanks(String gameType, String? contextId) async {
|
Future<List<GameRankDto>> fetchRanks(String gameType, String? contextId) async {
|
||||||
final queryParams = {
|
final queryParams = {
|
||||||
'gameType': gameType,
|
'gameType': gameType,
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
import 'package:flutter/material.dart';
|
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 {
|
class SudokuBoard extends StatelessWidget {
|
||||||
final int blockSize;
|
final int blockSize;
|
||||||
final SudokuTheme theme; // 👈 이번 게임의 테마
|
final SudokuTheme theme;
|
||||||
final List<int> cells; // 👈 List<int> (0, 1, 10...)
|
final List<int> cells;
|
||||||
final List<int> originalCells; // 👈 List<int> (0, 1, 10...)
|
final List<int> originalCells;
|
||||||
final int? selectedIndex;
|
final int? selectedIndex;
|
||||||
final int? selectedNumberPad; // 10진수 숫자 (1, 10...)
|
final int? selectedNumberPad;
|
||||||
final Set<int> incorrectCells;
|
final Set<int> incorrectCells;
|
||||||
final Function(int) onCellTapped;
|
final Function(int) onCellTapped;
|
||||||
|
|
||||||
@ -26,7 +26,6 @@ class SudokuBoard extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final int gridSize = blockSize * blockSize;
|
final int gridSize = blockSize * blockSize;
|
||||||
// 그리드 크기에 따라 폰트 크기 동적 조절
|
|
||||||
final double fontSize = (gridSize > 9) ? (gridSize > 16 ? 12 : 16) : 24;
|
final double fontSize = (gridSize > 9) ? (gridSize > 16 ? 12 : 16) : 24;
|
||||||
|
|
||||||
return AspectRatio(
|
return AspectRatio(
|
||||||
@ -41,18 +40,19 @@ class SudokuBoard extends StatelessWidget {
|
|||||||
int row = index ~/ gridSize;
|
int row = index ~/ gridSize;
|
||||||
int col = index % gridSize;
|
int col = index % gridSize;
|
||||||
|
|
||||||
int cellValue = cells[index]; // 0, 1, 10...
|
int cellValue = cells[index];
|
||||||
bool isEditable = (originalCells[index] == 0);
|
bool isEditable = (originalCells[index] == 0);
|
||||||
bool isSelected = (index == selectedIndex);
|
bool isSelected = (index == selectedIndex);
|
||||||
|
|
||||||
// int == int 비교
|
int selectedNumAsInt = selectedNumberPad ?? -1;
|
||||||
|
String selectedNumAsSymbol = (selectedNumberPad != null) ? theme.getSymbol(selectedNumberPad!) : "";
|
||||||
|
|
||||||
bool isHighlighted = (cellValue != 0 &&
|
bool isHighlighted = (cellValue != 0 &&
|
||||||
selectedNumberPad != null &&
|
selectedNumberPad != null &&
|
||||||
cellValue == selectedNumberPad);
|
cellValue == selectedNumberPad);
|
||||||
|
|
||||||
bool isIncorrect = incorrectCells.contains(index);
|
bool isIncorrect = incorrectCells.contains(index);
|
||||||
|
|
||||||
// blockSize에 따른 동적 보더
|
|
||||||
BorderSide thickBorder = const BorderSide(color: Colors.black, width: 2.0);
|
BorderSide thickBorder = const BorderSide(color: Colors.black, width: 2.0);
|
||||||
BorderSide thinBorder = const BorderSide(color: Colors.grey, width: 0.5);
|
BorderSide thinBorder = const BorderSide(color: Colors.grey, width: 0.5);
|
||||||
|
|
||||||
@ -61,15 +61,14 @@ class SudokuBoard extends StatelessWidget {
|
|||||||
child: Container(
|
child: Container(
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
// 🔽 [수정] 'isSelected' 조건 제거 (배경색 없음)
|
||||||
color: isIncorrect
|
color: isIncorrect
|
||||||
? Colors.red.shade100
|
? Colors.red.shade100 // 1순위: 틀림
|
||||||
: isSelected
|
: isHighlighted
|
||||||
? Colors.blue.shade100
|
? Colors.blue.shade200 // 2순위: 숫자 하이라이트
|
||||||
: isHighlighted
|
: isEditable
|
||||||
? Colors.blue.shade200
|
? Colors.white
|
||||||
: isEditable
|
: Colors.grey.shade200, // 기본
|
||||||
? Colors.white
|
|
||||||
: Colors.grey.shade200,
|
|
||||||
border: Border(
|
border: Border(
|
||||||
top: (row == 0) ? thickBorder : thinBorder,
|
top: (row == 0) ? thickBorder : thinBorder,
|
||||||
left: (col == 0) ? thickBorder : thinBorder,
|
left: (col == 0) ? thickBorder : thinBorder,
|
||||||
@ -78,16 +77,18 @@ class SudokuBoard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
// 0이면 비우고, 아니면 테마 기호("1", "A", "🍎") 표시
|
|
||||||
cellValue == 0 ? '' : theme.getSymbol(cellValue),
|
cellValue == 0 ? '' : theme.getSymbol(cellValue),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: fontSize,
|
fontSize: fontSize,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: isIncorrect
|
// 🔽 [수정] 'isSelected'를 최우선 순위로 추가 (텍스트 색상 변경)
|
||||||
? Colors.red.shade900
|
color: isSelected
|
||||||
: isEditable
|
? Colors.orange.shade700 // 1순위: 선택된 셀
|
||||||
? Colors.blue
|
: isIncorrect
|
||||||
: Colors.black,
|
? Colors.red.shade900 // 2순위: 틀린 셀
|
||||||
|
: isEditable
|
||||||
|
? Colors.blue // 3순위: 수정 가능 셀
|
||||||
|
: Colors.black, // 기본 (원본 숫자)
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user