- Add gridWidth and gridHeight properties to level configuration - Update GameGrid to accept custom dimensions instead of using constants - Modify GridComponent to calculate gem size based on grid dimensions - Update MatchThreeGame constructor to pass grid dimensions - Ensure proper scaling and positioning for variable grid sizes
291 lines
8.9 KiB
Dart
291 lines
8.9 KiB
Dart
import 'dart:convert';
|
|
import 'dart:async';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import '../game/models/level.dart';
|
|
|
|
class LevelService {
|
|
static const String _progressKey = 'level_progress';
|
|
static const int freePlayLevelId = 0; // Special ID for Free Play
|
|
static LevelService? _instance;
|
|
static LevelService get instance => _instance ??= LevelService._();
|
|
|
|
LevelService._();
|
|
|
|
List<Level>? _levels;
|
|
Map<int, LevelProgress>? _progress;
|
|
|
|
/// Get the hardcoded Free Play level
|
|
Level get freePlayLevel => const Level(
|
|
id: freePlayLevelId,
|
|
name: 'Free Play',
|
|
description: 'Play without constraints - match gems and score points!',
|
|
gridWidth: 5,
|
|
gridHeight: 5,
|
|
constraints: LevelConstraints(), // No constraints
|
|
objectives: LevelObjectives(), // No objectives
|
|
starRating: StarRating(
|
|
criteria: StarCriteria.SCORE,
|
|
thresholds: StarThresholds(
|
|
oneStar: 1000,
|
|
twoStar: 5000,
|
|
threeStar: 10000,
|
|
),
|
|
),
|
|
availableGemTypes: [0, 1, 2, 3, 4, 5], // All gem types available
|
|
unlocked: true, // Always unlocked
|
|
);
|
|
|
|
/// Load all levels from JSON configuration
|
|
Future<List<Level>> loadLevels() async {
|
|
if (_levels != null) return _levels!;
|
|
|
|
try {
|
|
final String jsonString =
|
|
await rootBundle.loadString('assets/levels/levels.json');
|
|
final Map<String, dynamic> jsonData = json.decode(jsonString);
|
|
|
|
_levels = (jsonData['levels'] as List)
|
|
.map((levelJson) => Level.fromJson(levelJson))
|
|
.toList();
|
|
|
|
return _levels!;
|
|
} catch (e) {
|
|
throw Exception('Failed to load levels: $e');
|
|
}
|
|
}
|
|
|
|
/// Get a specific level by ID
|
|
Future<Level?> getLevel(int levelId) async {
|
|
// Return Free Play level for ID 0
|
|
if (levelId == freePlayLevelId) {
|
|
return freePlayLevel;
|
|
}
|
|
|
|
final levels = await loadLevels();
|
|
try {
|
|
return levels.firstWhere((level) => level.id == levelId);
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Load level progress from persistent storage
|
|
Future<Map<int, LevelProgress>> loadProgress() async {
|
|
if (_progress != null) return _progress!;
|
|
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final String? progressJson = prefs.getString(_progressKey);
|
|
|
|
if (progressJson != null) {
|
|
final Map<String, dynamic> progressData = json.decode(progressJson);
|
|
_progress = progressData.map(
|
|
(key, value) => MapEntry(
|
|
int.parse(key),
|
|
LevelProgress.fromJson(value),
|
|
),
|
|
);
|
|
} else {
|
|
// Initialize default progress - level 1 is unlocked
|
|
_progress = {1: const LevelProgress(levelId: 1, unlocked: true)};
|
|
await saveProgress();
|
|
}
|
|
|
|
return _progress!;
|
|
} catch (e) {
|
|
// Fallback to default progress
|
|
_progress = {1: const LevelProgress(levelId: 1, unlocked: true)};
|
|
return _progress!;
|
|
}
|
|
}
|
|
|
|
/// Save level progress to persistent storage
|
|
Future<void> saveProgress() async {
|
|
if (_progress == null) return;
|
|
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final Map<String, dynamic> progressData = _progress!.map(
|
|
(key, value) => MapEntry(key.toString(), value.toJson()),
|
|
);
|
|
|
|
await prefs.setString(_progressKey, json.encode(progressData));
|
|
} catch (e) {
|
|
throw Exception('Failed to save progress: $e');
|
|
}
|
|
}
|
|
|
|
/// Get progress for a specific level
|
|
Future<LevelProgress> getLevelProgress(int levelId) async {
|
|
final progress = await loadProgress();
|
|
return progress[levelId] ?? LevelProgress(levelId: levelId);
|
|
}
|
|
|
|
/// Update progress for a specific level
|
|
Future<void> updateLevelProgress(LevelProgress newProgress) async {
|
|
final progress = await loadProgress();
|
|
_progress![newProgress.levelId] = newProgress;
|
|
|
|
// Auto-unlock next level if current level is completed
|
|
if (newProgress.completed) {
|
|
final nextLevelId = newProgress.levelId + 1;
|
|
final levels = await loadLevels();
|
|
|
|
// Check if next level exists
|
|
if (levels.any((level) => level.id == nextLevelId)) {
|
|
final nextProgress =
|
|
progress[nextLevelId] ?? LevelProgress(levelId: nextLevelId);
|
|
if (!nextProgress.unlocked) {
|
|
_progress![nextLevelId] = nextProgress.copyWith(unlocked: true);
|
|
}
|
|
}
|
|
}
|
|
|
|
await saveProgress();
|
|
}
|
|
|
|
/// Get all levels with their current progress
|
|
Future<List<LevelWithProgress>> getLevelsWithProgress() async {
|
|
final levels = await loadLevels();
|
|
final progress = await loadProgress();
|
|
|
|
return levels.map((level) {
|
|
final levelProgress =
|
|
progress[level.id] ?? LevelProgress(levelId: level.id);
|
|
return LevelWithProgress(level: level, progress: levelProgress);
|
|
}).toList();
|
|
}
|
|
|
|
/// Calculate stars earned based on level performance
|
|
int calculateStars(Level level, int score, int movesUsed, int timeUsed,
|
|
Map<int, int> gemsCleared) {
|
|
final criteria = level.starRating.criteria;
|
|
final thresholds = level.starRating.thresholds;
|
|
|
|
int value;
|
|
switch (criteria) {
|
|
case StarCriteria.SCORE:
|
|
value = score;
|
|
break;
|
|
case StarCriteria.MOVES_REMAINING:
|
|
value = (level.constraints.maxMoves ?? 0) - movesUsed;
|
|
break;
|
|
case StarCriteria.TIME_REMAINING:
|
|
value = (level.constraints.timeLimit ?? 0) - timeUsed;
|
|
break;
|
|
case StarCriteria.EFFICIENCY:
|
|
// For efficiency, we use score but consider objectives completion
|
|
value = score;
|
|
// Bonus for completing objectives
|
|
if (_areObjectivesCompleted(level.objectives, gemsCleared)) {
|
|
value += 500; // Bonus points for completing objectives
|
|
}
|
|
break;
|
|
case StarCriteria.COMBINED:
|
|
// Combined scoring considers multiple factors
|
|
value = score;
|
|
if (level.constraints.hasMoveLimit) {
|
|
final movesRemaining = (level.constraints.maxMoves ?? 0) - movesUsed;
|
|
value += movesRemaining * 50; // Bonus for efficient moves
|
|
}
|
|
if (_areObjectivesCompleted(level.objectives, gemsCleared)) {
|
|
value += 500; // Bonus for completing objectives
|
|
}
|
|
break;
|
|
}
|
|
|
|
if (value >= thresholds.threeStar) return 3;
|
|
if (value >= thresholds.twoStar) return 2;
|
|
if (value >= thresholds.oneStar) return 1;
|
|
return 0;
|
|
}
|
|
|
|
/// Check if level objectives are completed
|
|
bool _areObjectivesCompleted(
|
|
LevelObjectives objectives, Map<int, int> gemsCleared) {
|
|
// If there are no objectives, they are considered completed
|
|
if (!objectives.hasGemTypeObjectives) {
|
|
return true;
|
|
}
|
|
|
|
for (final entry in objectives.clearGemTypes.entries) {
|
|
final requiredCount = entry.value;
|
|
final clearedCount = gemsCleared[entry.key] ?? 0;
|
|
if (clearedCount < requiredCount) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/// Check if level is completed (all constraints and objectives met)
|
|
bool isLevelCompleted(Level level, int score, int moves, int timeUsed,
|
|
Map<int, int> gemsCleared) {
|
|
// Check score constraint
|
|
if (level.id == 0 ||
|
|
(level.constraints.hasScoreTarget &&
|
|
score < level.constraints.targetScore!)) {
|
|
return false;
|
|
}
|
|
|
|
// Check move constraint
|
|
if (level.constraints.hasMoveLimit && moves > level.constraints.maxMoves!) {
|
|
return false;
|
|
}
|
|
|
|
// Check time constraint
|
|
if (level.constraints.hasTimeLimit &&
|
|
timeUsed > level.constraints.timeLimit!) {
|
|
return false;
|
|
}
|
|
|
|
// Check objectives
|
|
if (!_areObjectivesCompleted(level.objectives, gemsCleared)) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/// Check if level is failed (constraints violated but not completed)
|
|
bool isLevelFailed(Level level, int score, int moves, int timeUsed,
|
|
Map<int, int> gemsCleared) {
|
|
// Failed if move limit exceeded and not completed
|
|
if (level.constraints.hasMoveLimit &&
|
|
moves >= level.constraints.maxMoves!) {
|
|
return !isLevelCompleted(level, score, moves, timeUsed, gemsCleared);
|
|
}
|
|
|
|
// Failed if time limit exceeded and not completed
|
|
if (level.constraints.hasTimeLimit &&
|
|
timeUsed >= level.constraints.timeLimit!) {
|
|
return !isLevelCompleted(level, score, moves, timeUsed, gemsCleared);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/// Reset all progress (for testing or new game)
|
|
Future<void> resetProgress() async {
|
|
_progress = {1: const LevelProgress(levelId: 1, unlocked: true)};
|
|
await saveProgress();
|
|
}
|
|
}
|
|
|
|
/// Helper class to combine level and progress data
|
|
class LevelWithProgress {
|
|
final Level level;
|
|
final LevelProgress progress;
|
|
|
|
const LevelWithProgress({
|
|
required this.level,
|
|
required this.progress,
|
|
});
|
|
|
|
bool get isUnlocked => progress.unlocked || level.unlocked;
|
|
bool get isCompleted => progress.completed;
|
|
int get stars => progress.stars;
|
|
int get bestScore => progress.bestScore;
|
|
}
|