diff --git a/assets/levels/levels.json b/assets/levels/levels.json new file mode 100644 index 0000000..5ada4cf --- /dev/null +++ b/assets/levels/levels.json @@ -0,0 +1,112 @@ +{ + "levels": [ + { + "id": 1, + "name": "Getting Started", + "description": "Learn the basics by reaching the target score", + "constraints": { + "targetScore": 1000 + }, + "objectives": {}, + "starRating": { + "criteria": "SCORE", + "thresholds": { + "oneStar": 1000, + "twoStar": 1200, + "threeStar": 1500 + } + }, + "availableGemTypes": [0, 1, 2, 3, 4, 5], + "unlocked": true + }, + { + "id": 2, + "name": "Move Master", + "description": "Reach the target score with limited moves", + "constraints": { + "targetScore": 1500, + "maxMoves": 20 + }, + "objectives": {}, + "starRating": { + "criteria": "MOVES_REMAINING", + "thresholds": { + "oneStar": 0, + "twoStar": 5, + "threeStar": 10 + } + }, + "availableGemTypes": [0, 1, 2, 3, 4, 5], + "unlocked": false + }, + { + "id": 3, + "name": "Time Trial", + "description": "Beat the clock to reach your goal", + "constraints": { + "targetScore": 2000, + "timeLimit": 60 + }, + "objectives": {}, + "starRating": { + "criteria": "TIME_REMAINING", + "thresholds": { + "oneStar": 0, + "twoStar": 15, + "threeStar": 30 + } + }, + "availableGemTypes": [0, 1, 2, 3, 4, 5], + "unlocked": false + }, + { + "id": 4, + "name": "Color Focus", + "description": "Clear specific gem colors while reaching the score", + "constraints": { + "targetScore": 1800 + }, + "objectives": { + "clearGemTypes": { + "0": 15, + "2": 12 + } + }, + "starRating": { + "criteria": "EFFICIENCY", + "thresholds": { + "oneStar": 1800, + "twoStar": 2200, + "threeStar": 2600 + } + }, + "availableGemTypes": [0, 1, 2, 3, 4, 5], + "unlocked": false + }, + { + "id": 5, + "name": "Ultimate Challenge", + "description": "Master all skills in this combined challenge", + "constraints": { + "targetScore": 2500, + "maxMoves": 25 + }, + "objectives": { + "clearGemTypes": { + "1": 8, + "3": 10 + } + }, + "starRating": { + "criteria": "COMBINED", + "thresholds": { + "oneStar": 2500, + "twoStar": 3000, + "threeStar": 3500 + } + }, + "availableGemTypes": [0, 1, 2, 3, 4, 5], + "unlocked": false + } + ] +} diff --git a/lib/bloc/game_bloc.dart b/lib/bloc/game_bloc.dart index fee36ae..9958938 100644 --- a/lib/bloc/game_bloc.dart +++ b/lib/bloc/game_bloc.dart @@ -1,12 +1,13 @@ import 'dart:async'; -import 'package:flame/game.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:equatable/equatable.dart'; import '../game/models/grid.dart'; import '../game/models/gem.dart'; +import '../game/models/level.dart'; import '../game/systems/match_detector.dart'; import '../game/systems/gravity_system.dart'; +import '../services/level_service.dart'; import '../utils/constants.dart'; // Events @@ -31,6 +32,24 @@ class ProcessMatches extends GameEvent { List get props => [combo]; } +class StartLevel extends GameEvent { + final int levelId; + StartLevel(this.levelId); + @override + List get props => [levelId]; +} + +class UpdateTimer extends GameEvent { + final int timeElapsed; + UpdateTimer(this.timeElapsed); + @override + List get props => [timeElapsed]; +} + +class CompleteLevel extends GameEvent {} + +class FailLevel extends GameEvent {} + // States typedef DoneCallback = void Function([FutureOr?]); @@ -151,12 +170,218 @@ class GamePlayingMatch extends GamePlaying { List get props => [grid, score, moves, matches]; } +// Level-based states +class GameLevelPlaying extends GameState { + final GameGrid grid; + final DoneCallback done; + final int score; + final int moves; + final Level level; + final int timeElapsed; + final Map gemsCleared; + + GameLevelPlaying({ + required this.grid, + required this.level, + this.done = _defaultDoneCallback, + this.score = 0, + this.moves = 0, + this.timeElapsed = 0, + this.gemsCleared = const {}, + }); + + static void _defaultDoneCallback([FutureOr? _]) {} + + @override + List get props => + [grid, score, moves, level, timeElapsed, gemsCleared]; +} + +class GameLevelStart extends GameLevelPlaying { + GameLevelStart({ + required super.grid, + required super.level, + super.done, + super.score, + super.moves, + super.timeElapsed, + super.gemsCleared, + }); +} + +class GameLevelIdle extends GameLevelPlaying { + GameLevelIdle({ + required super.grid, + required super.level, + super.done, + super.score, + super.moves, + super.timeElapsed, + super.gemsCleared, + }); +} + +class GameLevelSwap extends GameLevelPlaying { + final Gem gem1, gem2; + + GameLevelSwap({ + required super.grid, + required super.level, + super.score, + super.moves, + super.done, + super.timeElapsed, + super.gemsCleared, + required this.gem1, + required this.gem2, + }); + + @override + List get props => + [grid, score, moves, level, timeElapsed, gemsCleared, gem1, gem2]; +} + +class GameLevelMatch extends GameLevelPlaying { + final List matches; + final int combo; + + GameLevelMatch({ + required super.grid, + required super.level, + super.score, + super.moves, + super.done, + super.timeElapsed, + super.gemsCleared, + this.combo = 0, + this.matches = const [], + }); + + @override + List get props => + [grid, score, moves, level, timeElapsed, gemsCleared, matches, combo]; +} + +class GameLevelDrop extends GameLevelPlaying { + GameLevelDrop({ + required super.grid, + required super.level, + super.done, + super.score, + super.moves, + super.timeElapsed, + super.gemsCleared, + }); +} + +class GameLevelNewGems extends GameLevelPlaying { + final List gems; + + GameLevelNewGems({ + required super.grid, + required super.level, + super.score, + super.moves, + super.done, + super.timeElapsed, + super.gemsCleared, + required this.gems, + }); + + @override + List get props => + [grid, score, moves, level, timeElapsed, gemsCleared, gems]; +} + +class GameLevelCompleted extends GameState { + final Level level; + final int score; + final int moves; + final int timeElapsed; + final int stars; + final Map gemsCleared; + + GameLevelCompleted({ + required this.level, + required this.score, + required this.moves, + required this.timeElapsed, + required this.stars, + required this.gemsCleared, + }); + + @override + List get props => + [level, score, moves, timeElapsed, stars, gemsCleared]; +} + +class GameLevelFailed extends GameState { + final Level level; + final int score; + final int moves; + final int timeElapsed; + final Map gemsCleared; + final String reason; + + GameLevelFailed({ + required this.level, + required this.score, + required this.moves, + required this.timeElapsed, + required this.gemsCleared, + required this.reason, + }); + + @override + List get props => + [level, score, moves, timeElapsed, gemsCleared, reason]; +} + +// Internal level match state +class _GameLevelPlayingMatch extends GameState { + final GameGrid grid; + final Level level; + final int score; + final int moves; + final int timeElapsed; + final Map gemsCleared; + final List matches; + + _GameLevelPlayingMatch({ + required this.grid, + required this.level, + this.score = 0, + this.moves = 0, + this.timeElapsed = 0, + this.gemsCleared = const {}, + this.matches = const [], + }); + + @override + List get props => + [grid, level, score, moves, timeElapsed, gemsCleared, matches]; +} + // Bloc class GameBloc extends Bloc { + Timer? _gameTimer; + final LevelService _levelService = LevelService.instance; + GameBloc() : super(GameInitial()) { on(_onStartGame); on(_onSwapGems); on(_onProcessMatches); + + on(_onStartLevel); + on(_onUpdateTimer); + on(_onCompleteLevel); + on(_onFailLevel); + } + + @override + Future close() { + _gameTimer?.cancel(); + return super.close(); } void _onStartGame(StartGame event, Emitter emit) async { @@ -172,6 +397,7 @@ class GameBloc extends Bloc { } void _onSwapGems(SwapGems event, Emitter emit) async { + // Handle regular game states if (state is GamePlaying) { final currentState = state as GamePlaying; @@ -234,9 +460,102 @@ class GameBloc extends Bloc { await done.future; } } + // Handle level-based states + else if (state is GameLevelPlaying) { + final currentState = state as GameLevelPlaying; + + if (!MatchDetector.isValidSwap( + currentState.grid, event.row1, event.col1, event.row2, event.col2)) { + return; + } + final newGrid = currentState.grid.clone(); + + // Perform swap + final gem1 = newGrid.getGem(event.row1, event.col1); + final gem2 = newGrid.getGem(event.row2, event.col2); + + if (gem1 != null && gem2 != null) { + newGrid.setGem(event.row1, event.col1, + gem2.copyWith(row: event.row1, col: event.col1)); + newGrid.setGem(event.row2, event.col2, + gem1.copyWith(row: event.row2, col: event.col2)); + + // Emit state for swap animation + var done = Completer(); + emit(GameLevelSwap( + grid: newGrid, + level: currentState.level, + score: currentState.score, + moves: currentState.moves, + timeElapsed: currentState.timeElapsed, + gemsCleared: currentState.gemsCleared, + gem1: gem1, + gem2: gem2, + done: done.complete, + )); + + // Wait for swap animation + await done.future; + + // Check for matches + final matches = MatchDetector.findMatches(newGrid); + if (matches.isNotEmpty) { + final newMoves = currentState.moves + 1; + + // Check if move limit exceeded + if (currentState.level.constraints.hasMoveLimit && + newMoves >= currentState.level.constraints.maxMoves!) { + // This is the last move, check if level can be completed + final updatedGemsCleared = + _updateGemsCleared(currentState.gemsCleared, matches); + if (!_levelService.isLevelCompleted( + currentState.level, + currentState.score, + newMoves, + currentState.timeElapsed, + updatedGemsCleared, + )) { + add(FailLevel()); + return; + } + } + + emit(_GameLevelPlayingMatch( + grid: newGrid, + level: currentState.level, + score: currentState.score, + moves: newMoves, + timeElapsed: currentState.timeElapsed, + gemsCleared: currentState.gemsCleared, + matches: matches, + )); + add(ProcessMatches()); + } else { + // Revert swap if no matches + newGrid.setGem(event.row1, event.col1, gem1); + newGrid.setGem(event.row2, event.col2, gem2); + var done = Completer(); + + emit(GameLevelSwap( + grid: newGrid, + level: currentState.level, + score: currentState.score, + moves: currentState.moves, + timeElapsed: currentState.timeElapsed, + gemsCleared: currentState.gemsCleared, + gem1: gem2, + gem2: gem1, + done: done.complete, + )); + } + // Wait for swap animation + await done.future; + } + } } void _onProcessMatches(ProcessMatches event, Emitter emit) async { + // Handle regular game matches if (state is _GamePlayingMatch) { final currentState = state as _GamePlayingMatch; @@ -315,5 +634,314 @@ class GameBloc extends Bloc { grid: newGrid, score: newScore, moves: currentState.moves)); } } + // Handle level-based matches + else if (state is _GameLevelPlayingMatch) { + final currentState = state as _GameLevelPlayingMatch; + + // Calculate score + int newScore = currentState.score + + currentState.matches.length * GameConstants.baseScore; + + if (event.combo > 0) { + newScore += GameConstants.comboMultiplier * event.combo; + } + + // Update gems cleared count + final updatedGemsCleared = + _updateGemsCleared(currentState.gemsCleared, currentState.matches); + + var newGrid = currentState.grid.clone(); + + // Mark matched gems and emit state for animation + for (final gem in currentState.matches) { + newGrid.setGem(gem.row, gem.col, null); + } + + var done = Completer(); + emit(GameLevelMatch( + grid: newGrid, + level: currentState.level, + score: newScore, + moves: currentState.moves, + timeElapsed: currentState.timeElapsed, + gemsCleared: updatedGemsCleared, + matches: currentState.matches, + combo: event.combo, + done: done.complete, + )); + + // Wait for match animations to complete + await done.future; + newGrid = newGrid.clone(); + + // Apply gravity + GravitySystem.applyGravity(newGrid); + + // Emit state for drop columns + done = Completer(); + emit(GameLevelDrop( + grid: newGrid, + level: currentState.level, + score: newScore, + moves: currentState.moves, + timeElapsed: currentState.timeElapsed, + gemsCleared: updatedGemsCleared, + done: done.complete, + )); + await done.future; + newGrid = newGrid.clone(); + + // Apply gravity + final newGems = GravitySystem.generateGems(newGrid); + + // Emit state for drop columns + done = Completer(); + emit(GameLevelNewGems( + grid: newGrid, + level: currentState.level, + score: newScore, + moves: currentState.moves, + timeElapsed: currentState.timeElapsed, + gemsCleared: updatedGemsCleared, + gems: newGems, + done: done.complete, + )); + // Wait for fall animations + await done.future; + + newGrid = newGrid.clone(); + + // Check for new matches + final newMatches = MatchDetector.findMatches(newGrid); + + if (newMatches.isNotEmpty) { + emit(_GameLevelPlayingMatch( + grid: newGrid, + level: currentState.level, + score: newScore, + moves: currentState.moves, + timeElapsed: currentState.timeElapsed, + gemsCleared: updatedGemsCleared, + matches: newMatches, + )); + add(ProcessMatches(combo: event.combo + 1)); + } else { + final finalState = GameLevelIdle( + grid: newGrid, + level: currentState.level, + score: newScore, + moves: currentState.moves, + timeElapsed: currentState.timeElapsed, + gemsCleared: updatedGemsCleared, + ); + + emit(finalState); + + // Check level completion/failure after processing matches + _checkLevelStatus(finalState); + } + } + } + + void _onStartLevel(StartLevel event, Emitter emit) async { + try { + final level = await _levelService.getLevel(event.levelId); + if (level == null) { + emit(GameLevelFailed( + level: Level( + id: event.levelId, + name: 'Unknown Level', + description: '', + constraints: const LevelConstraints(), + objectives: const LevelObjectives(), + starRating: const StarRating( + criteria: StarCriteria.SCORE, + thresholds: StarThresholds(oneStar: 0, twoStar: 0, threeStar: 0), + ), + availableGemTypes: [], + ), + score: 0, + moves: 0, + timeElapsed: 0, + gemsCleared: {}, + reason: 'Level not found', + )); + return; + } + + final grid = GameGrid(); + + // Start timer if level has time limit + if (level.constraints.hasTimeLimit) { + _gameTimer?.cancel(); + _gameTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + add(UpdateTimer(timer.tick)); + }); + } + + final done = Completer(); + emit(GameLevelStart( + grid: grid, + level: level, + done: done.complete, + )); + await done.future; + + final matches = MatchDetector.findMatches(grid); + emit(_GameLevelPlayingMatch( + grid: grid, + level: level, + matches: matches, + )); + add(ProcessMatches()); + } catch (e) { + emit(GameLevelFailed( + level: Level( + id: event.levelId, + name: 'Error', + description: '', + constraints: const LevelConstraints(), + objectives: const LevelObjectives(), + starRating: const StarRating( + criteria: StarCriteria.SCORE, + thresholds: StarThresholds(oneStar: 0, twoStar: 0, threeStar: 0), + ), + availableGemTypes: [], + ), + score: 0, + moves: 0, + timeElapsed: 0, + gemsCleared: {}, + reason: 'Failed to load level: $e', + )); + } + } + + void _onUpdateTimer(UpdateTimer event, Emitter emit) { + if (state is GameLevelPlaying) { + final currentState = state as GameLevelPlaying; + + // Check if time limit exceeded + if (currentState.level.constraints.hasTimeLimit && + event.timeElapsed >= currentState.level.constraints.timeLimit!) { + _gameTimer?.cancel(); + add(FailLevel()); + return; + } + + // Update the current state with new time + if (currentState is GameLevelIdle) { + emit(GameLevelIdle( + grid: currentState.grid, + level: currentState.level, + score: currentState.score, + moves: currentState.moves, + timeElapsed: event.timeElapsed, + gemsCleared: currentState.gemsCleared, + )); + } + } + } + + void _onCompleteLevel(CompleteLevel event, Emitter emit) async { + if (state is GameLevelPlaying) { + final currentState = state as GameLevelPlaying; + _gameTimer?.cancel(); + + // Calculate stars + final stars = _levelService.calculateStars( + currentState.level, + currentState.score, + currentState.moves, + currentState.timeElapsed, + currentState.gemsCleared, + ); + + // Update progress + final currentProgress = + await _levelService.getLevelProgress(currentState.level.id); + final newProgress = currentProgress.copyWith( + completed: true, + stars: stars > currentProgress.stars ? stars : currentProgress.stars, + bestScore: currentState.score > currentProgress.bestScore + ? currentState.score + : currentProgress.bestScore, + ); + + await _levelService.updateLevelProgress(newProgress); + + emit(GameLevelCompleted( + level: currentState.level, + score: currentState.score, + moves: currentState.moves, + timeElapsed: currentState.timeElapsed, + stars: stars, + gemsCleared: currentState.gemsCleared, + )); + } + } + + void _onFailLevel(FailLevel event, Emitter emit) { + if (state is GameLevelPlaying) { + final currentState = state as GameLevelPlaying; + _gameTimer?.cancel(); + + String reason = 'Level failed'; + if (currentState.level.constraints.hasMoveLimit && + currentState.moves >= currentState.level.constraints.maxMoves!) { + reason = 'No moves remaining'; + } else if (currentState.level.constraints.hasTimeLimit && + currentState.timeElapsed >= + currentState.level.constraints.timeLimit!) { + reason = 'Time limit exceeded'; + } + + emit(GameLevelFailed( + level: currentState.level, + score: currentState.score, + moves: currentState.moves, + timeElapsed: currentState.timeElapsed, + gemsCleared: currentState.gemsCleared, + reason: reason, + )); + } + } + + // Helper method to update gems cleared count + Map _updateGemsCleared(Map current, List matches) { + final updated = Map.from(current); + for (final gem in matches) { + updated[gem.type] = (updated[gem.type] ?? 0) + 1; + } + return updated; + } + + // Helper method to check level completion/failure + void _checkLevelStatus(GameLevelPlaying currentState) { + final level = currentState.level; + + // Check if level is completed + if (_levelService.isLevelCompleted( + level, + currentState.score, + currentState.moves, + currentState.timeElapsed, + currentState.gemsCleared, + )) { + add(CompleteLevel()); + return; + } + + // Check if level is failed + if (_levelService.isLevelFailed( + level, + currentState.score, + currentState.moves, + currentState.timeElapsed, + currentState.gemsCleared, + )) { + add(FailLevel()); + return; + } } } diff --git a/lib/game/components/grid_component.dart b/lib/game/components/grid_component.dart index 7928935..0315168 100644 --- a/lib/game/components/grid_component.dart +++ b/lib/game/components/grid_component.dart @@ -12,13 +12,18 @@ class GridComponent extends PositionComponent late GameGrid gameGrid; final List> gemComponents = []; final MatchThreeGame game; + int? levelId; bool canInterract = false; - GridComponent(this.game) : super(); + GridComponent(this.game, this.levelId) : super(); @override Future onLoad() async { - game.gameBloc.add(StartGame()); + if (levelId == null) { + game.gameBloc.add(StartGame()); + } else { + game.gameBloc.add(StartLevel(levelId!)); + } } void _createGemComponents() { @@ -57,11 +62,13 @@ class GridComponent extends PositionComponent void updateGrid(GameState state) async { // Work only with relevant events - if (state is! GamePlaying) { + if (state is! GamePlaying && state is! GameLevelPlaying) { return; } canInterract = false; print("Update event with state ${state.runtimeType.toString()}"); + + // Handle regular game states if (state is GamePlayingSwap) { await _swapGems( state.grid, @@ -69,24 +76,50 @@ class GridComponent extends PositionComponent state.gem2, ); } + if (state is GameLevelSwap) { + await _swapGems( + state.grid, + state.gem1, + state.gem2, + ); + } if (state is GamePlayingMatch) { await _animateMatch(state.grid, state.matches); } + if (state is GameLevelMatch) { + await _animateMatch(state.grid, state.matches); + } if (state is GamePlayingDrop) { await _animateDrop(state.grid); } + if (state is GameLevelDrop) { + await _animateDrop(state.grid); + } if (state is GamePlayingNewGems) { await _animateNewGemsFall(state.grid, state.gems); } - if (state is GamePlayingIdle) { + if (state is GameLevelNewGems) { + await _animateNewGemsFall(state.grid, state.gems); + } + if (state is GamePlayingIdle || state is GameLevelIdle) { canInterract = true; } - gameGrid = state.grid; - gameGrid.printGrid(); - _createGemComponents(); - await Future.delayed(const Duration(milliseconds: 100)); - print("Updated state ${state.runtimeType.toString()}"); - state.done(); + if (state is GamePlaying) { + gameGrid = state.grid; + gameGrid.printGrid(); + _createGemComponents(); + await Future.delayed(const Duration(milliseconds: 100)); + print("Updated state ${state.runtimeType.toString()}"); + state.done(); + } else { + state = state as GameLevelPlaying; + gameGrid = state.grid; + gameGrid.printGrid(); + _createGemComponents(); + await Future.delayed(const Duration(milliseconds: 100)); + print("Updated state ${state.runtimeType.toString()}"); + state.done(); + } } GemComponent? _findGemComponent(Gem gem) { diff --git a/lib/game/match_three_game.dart b/lib/game/match_three_game.dart index 7d39898..b7098aa 100644 --- a/lib/game/match_three_game.dart +++ b/lib/game/match_three_game.dart @@ -10,18 +10,20 @@ import '../bloc/game_bloc.dart'; const gridSize = 8 * 64.0 + 32.0; // 8 gems * 64px + padding class MatchThreeGame extends FlameGame { - GridComponent? gridComponent; late BackgroundComponent backgroundComponent; late GameBloc gameBloc; - + final int? levelId; + GridComponent? gridComponent; Gem? selectedGem; + MatchThreeGame(this.levelId) : super(); + @override Future onLoad() async { backgroundComponent = BackgroundComponent(); add(backgroundComponent); - gridComponent = GridComponent(this); + gridComponent = GridComponent(this, levelId); add(gridComponent!); } diff --git a/lib/game/models/gem.dart b/lib/game/models/gem.dart index 8bdc3c1..afd2c68 100644 --- a/lib/game/models/gem.dart +++ b/lib/game/models/gem.dart @@ -32,22 +32,7 @@ class Gem extends Equatable { } get name { - switch (type) { - case 0: - return 'red'; - case 1: - return 'blue'; - case 2: - return 'green'; - case 3: - return 'yellow'; - case 4: - return 'purple'; - case 5: - return 'orange'; - default: - return 'unknown'; - } + return getName(type); } @override @@ -74,4 +59,23 @@ class Gem extends Equatable { isMatched.hashCode ^ isSpecial.hashCode; } + + static String getName(int type) { + switch (type) { + case 0: + return 'red'; + case 1: + return 'blue'; + case 2: + return 'green'; + case 3: + return 'yellow'; + case 4: + return 'purple'; + case 5: + return 'orange'; + default: + return 'unknown'; + } + } } diff --git a/lib/game/models/level.dart b/lib/game/models/level.dart new file mode 100644 index 0000000..40e3235 --- /dev/null +++ b/lib/game/models/level.dart @@ -0,0 +1,278 @@ +import 'package:equatable/equatable.dart'; +import 'package:match_three/game/models/gem.dart'; + +enum StarCriteria { + SCORE, + MOVES_REMAINING, + TIME_REMAINING, + EFFICIENCY, + COMBINED, +} + +class Level extends Equatable { + final int id; + final String name; + final String description; + final LevelConstraints constraints; + final LevelObjectives objectives; + final StarRating starRating; + final List availableGemTypes; + final bool unlocked; + + const Level({ + required this.id, + required this.name, + required this.description, + required this.constraints, + required this.objectives, + required this.starRating, + required this.availableGemTypes, + this.unlocked = false, + }); + + factory Level.fromJson(Map json) { + return Level( + id: json['id'], + name: json['name'], + description: json['description'], + constraints: LevelConstraints.fromJson(json['constraints']), + objectives: LevelObjectives.fromJson(json['objectives']), + starRating: StarRating.fromJson(json['starRating']), + availableGemTypes: List.from(json['availableGemTypes']), + unlocked: json['unlocked'] ?? false, + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'description': description, + 'constraints': constraints.toJson(), + 'objectives': objectives.toJson(), + 'starRating': starRating.toJson(), + 'availableGemTypes': availableGemTypes, + 'unlocked': unlocked, + }; + } + + Level copyWith({ + int? id, + String? name, + String? description, + LevelConstraints? constraints, + LevelObjectives? objectives, + StarRating? starRating, + List? availableGemTypes, + bool? unlocked, + }) { + return Level( + id: id ?? this.id, + name: name ?? this.name, + description: description ?? this.description, + constraints: constraints ?? this.constraints, + objectives: objectives ?? this.objectives, + starRating: starRating ?? this.starRating, + availableGemTypes: availableGemTypes ?? this.availableGemTypes, + unlocked: unlocked ?? this.unlocked, + ); + } + + @override + List get props => [ + id, + name, + description, + constraints, + objectives, + starRating, + availableGemTypes, + unlocked, + ]; +} + +class LevelConstraints extends Equatable { + final int? targetScore; + final int? maxMoves; + final int? timeLimit; // in seconds + + const LevelConstraints({ + this.targetScore, + this.maxMoves, + this.timeLimit, + }); + + factory LevelConstraints.fromJson(Map json) { + return LevelConstraints( + targetScore: json['targetScore'], + maxMoves: json['maxMoves'], + timeLimit: json['timeLimit'], + ); + } + + Map toJson() { + return { + 'targetScore': targetScore, + 'maxMoves': maxMoves, + 'timeLimit': timeLimit, + }; + } + + bool get hasTimeLimit => timeLimit != null; + bool get hasMoveLimit => maxMoves != null; + bool get hasScoreTarget => targetScore != null; + + @override + List get props => [targetScore, maxMoves, timeLimit]; +} + +class LevelObjectives extends Equatable { + final Map clearGemTypes; // gemType -> count required + + const LevelObjectives({ + this.clearGemTypes = const {}, + }); + + factory LevelObjectives.fromJson(Map json) { + Map clearGemTypes = {}; + + if (json['clearGemTypes'] != null) { + final clearGemTypesJson = json['clearGemTypes'] as Map; + clearGemTypes = clearGemTypesJson.map( + (key, value) => MapEntry(int.parse(key), value as int), + ); + } + + return LevelObjectives( + clearGemTypes: clearGemTypes, + ); + } + + Map toJson() { + return { + 'clearGemTypes': clearGemTypes.map( + (key, value) => MapEntry(key.toString(), value), + ), + }; + } + + bool get hasGemTypeObjectives => clearGemTypes.isNotEmpty; + + @override + List get props => [clearGemTypes]; +} + +class StarRating extends Equatable { + final StarCriteria criteria; + final StarThresholds thresholds; + + const StarRating({ + required this.criteria, + required this.thresholds, + }); + + factory StarRating.fromJson(Map json) { + return StarRating( + criteria: StarCriteria.values.firstWhere( + (e) => e.name == json['criteria'], + ), + thresholds: StarThresholds.fromJson(json['thresholds']), + ); + } + + Map toJson() { + return { + 'criteria': criteria.name, + 'thresholds': thresholds.toJson(), + }; + } + + @override + List get props => [criteria, thresholds]; +} + +class StarThresholds extends Equatable { + final int oneStar; + final int twoStar; + final int threeStar; + + const StarThresholds({ + required this.oneStar, + required this.twoStar, + required this.threeStar, + }); + + factory StarThresholds.fromJson(Map json) { + return StarThresholds( + oneStar: json['oneStar'], + twoStar: json['twoStar'], + threeStar: json['threeStar'], + ); + } + + Map toJson() { + return { + 'oneStar': oneStar, + 'twoStar': twoStar, + 'threeStar': threeStar, + }; + } + + @override + List get props => [oneStar, twoStar, threeStar]; +} + +class LevelProgress extends Equatable { + final int levelId; + final bool completed; + final int stars; + final int bestScore; + final bool unlocked; + + const LevelProgress({ + required this.levelId, + this.completed = false, + this.stars = 0, + this.bestScore = 0, + this.unlocked = false, + }); + + factory LevelProgress.fromJson(Map json) { + return LevelProgress( + levelId: json['levelId'], + completed: json['completed'] ?? false, + stars: json['stars'] ?? 0, + bestScore: json['bestScore'] ?? 0, + unlocked: json['unlocked'] ?? false, + ); + } + + Map toJson() { + return { + 'levelId': levelId, + 'completed': completed, + 'stars': stars, + 'bestScore': bestScore, + 'unlocked': unlocked, + }; + } + + LevelProgress copyWith({ + int? levelId, + bool? completed, + int? stars, + int? bestScore, + bool? unlocked, + }) { + return LevelProgress( + levelId: levelId ?? this.levelId, + completed: completed ?? this.completed, + stars: stars ?? this.stars, + bestScore: bestScore ?? this.bestScore, + unlocked: unlocked ?? this.unlocked, + ); + } + + @override + List get props => [levelId, completed, stars, bestScore, unlocked]; +} diff --git a/lib/screens/game_screen.dart b/lib/screens/game_screen.dart index 633956b..eb48fb3 100644 --- a/lib/screens/game_screen.dart +++ b/lib/screens/game_screen.dart @@ -1,11 +1,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flame/game.dart'; +import 'package:match_three/game/models/gem.dart'; import '../game/match_three_game.dart'; import '../bloc/game_bloc.dart'; +// ignore: must_be_immutable class GameScreen extends StatefulWidget { - const GameScreen({super.key}); + int? levelId; + + GameScreen({super.key, this.levelId}); @override State createState() => _GameScreenState(); @@ -17,7 +21,7 @@ class _GameScreenState extends State { @override void initState() { super.initState(); - game = MatchThreeGame(); + game = MatchThreeGame(widget.levelId); } @override @@ -25,8 +29,13 @@ class _GameScreenState extends State { return Scaffold( body: BlocListener( listener: (context, state) { - if (state is GamePlaying && game.gridComponent != null) { + if (game.gridComponent == null) return; + if (state is GameLevelPlaying || state is GamePlaying) { game.gridComponent!.updateGrid(state); + } else if (state is GameLevelCompleted) { + _showLevelCompletedDialog(context, state); + } else if (state is GameLevelFailed) { + _showLevelFailedDialog(context, state); } }, child: BlocBuilder( @@ -85,6 +94,113 @@ class _GameScreenState extends State { ), ), ), + if (state is GameLevelPlaying) + Positioned( + top: 50, + left: 20, + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + state.level.name, + style: const TextStyle( + color: Colors.amber, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + 'Score: ${state.score}', + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + if (state.level.constraints.hasScoreTarget) + Text( + 'Target: ${state.level.constraints.targetScore}', + style: const TextStyle( + color: Colors.green, + fontSize: 14, + ), + ), + Text( + 'Moves: ${state.moves}', + style: const TextStyle( + color: Colors.white, + fontSize: 16, + ), + ), + if (state.level.constraints.hasMoveLimit) + Text( + 'Limit: ${state.level.constraints.maxMoves}', + style: TextStyle( + color: state.moves >= + state.level.constraints.maxMoves! + ? Colors.red + : Colors.orange, + fontSize: 14, + ), + ), + if (state.level.constraints.hasTimeLimit) + Text( + 'Time: ${state.level.constraints.timeLimit! - state.timeElapsed}s', + style: TextStyle( + color: (state.level.constraints.timeLimit! - + state.timeElapsed) <= + 10 + ? Colors.red + : Colors.cyan, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + if (state.level.objectives.hasGemTypeObjectives) ...[ + const SizedBox(height: 4), + const Text( + 'Objectives:', + style: TextStyle( + color: Colors.yellow, + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + ...state.level.objectives.clearGemTypes.entries + .map((entry) { + final gemType = Gem.getName(entry.key); + final required = entry.value; + final cleared = state.gemsCleared[entry.key] ?? 0; + return Text( + 'Gem $gemType: $cleared/$required', + style: TextStyle( + color: cleared >= required + ? Colors.green + : Colors.white, + fontSize: 12, + ), + ); + }), + ], + if (state is GameLevelMatch) + Text( + 'Combo x ${state.combo + 1}', + style: const TextStyle( + color: Colors.white, + fontSize: 16, + ), + ), + ], + ), + ), + ), Positioned( top: 50, right: 20, @@ -101,4 +217,180 @@ class _GameScreenState extends State { ), ); } + + void _showLevelCompletedDialog( + BuildContext context, GameLevelCompleted state) { + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return AlertDialog( + title: const Text( + 'Level Complete!', + style: TextStyle( + color: Colors.green, + fontWeight: FontWeight.bold, + ), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + state.level.name, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(3, (index) { + return Icon( + index < state.stars ? Icons.star : Icons.star_border, + color: index < state.stars ? Colors.amber : Colors.grey, + size: 32, + ); + }), + ), + const SizedBox(height: 16), + Text( + 'Final Score: ${state.score}', + style: const TextStyle(fontSize: 16), + ), + Text( + 'Moves Used: ${state.moves}', + style: const TextStyle(fontSize: 16), + ), + if (state.level.constraints.hasTimeLimit) + Text( + 'Time: ${state.timeElapsed}s', + style: const TextStyle(fontSize: 16), + ), + if (state.level.objectives.hasGemTypeObjectives) ...[ + const SizedBox(height: 8), + const Text( + 'Objectives Completed:', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ...state.level.objectives.clearGemTypes.entries.map((entry) { + final gemType = entry.key; + final required = entry.value; + final cleared = state.gemsCleared[gemType] ?? 0; + return Text( + 'Gem $gemType: $cleared/$required ✓', + style: const TextStyle(color: Colors.green), + ); + }), + ], + ], + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); // Close dialog + Navigator.of(context).pop(); // Return to level selection + }, + child: const Text('Continue'), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); // Close dialog + context.read().add(StartLevel(state.level.id)); + }, + child: const Text('Replay'), + ), + ], + ); + }, + ); + } + + void _showLevelFailedDialog(BuildContext context, GameLevelFailed state) { + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return AlertDialog( + title: const Text( + 'Level Failed', + style: TextStyle( + color: Colors.red, + fontWeight: FontWeight.bold, + ), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + state.level.name, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + Text( + state.reason, + style: const TextStyle( + fontSize: 16, + color: Colors.red, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + Text( + 'Final Score: ${state.score}', + style: const TextStyle(fontSize: 16), + ), + Text( + 'Moves Used: ${state.moves}', + style: const TextStyle(fontSize: 16), + ), + if (state.level.constraints.hasTimeLimit) + Text( + 'Time: ${state.timeElapsed}s', + style: const TextStyle(fontSize: 16), + ), + if (state.level.objectives.hasGemTypeObjectives) ...[ + const SizedBox(height: 8), + const Text( + 'Objectives Progress:', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ...state.level.objectives.clearGemTypes.entries.map((entry) { + final gemType = entry.key; + final required = entry.value; + final cleared = state.gemsCleared[gemType] ?? 0; + final completed = cleared >= required; + return Text( + 'Gem $gemType: $cleared/$required ${completed ? "✓" : "✗"}', + style: TextStyle( + color: completed ? Colors.green : Colors.red, + ), + ); + }), + ], + ], + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); // Close dialog + Navigator.of(context).pop(); // Return to level selection + }, + child: const Text('Give Up'), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); // Close dialog + context.read().add(StartLevel(state.level.id)); + }, + child: const Text('Try Again'), + ), + ], + ); + }, + ); + } } diff --git a/lib/screens/level_selection_screen.dart b/lib/screens/level_selection_screen.dart new file mode 100644 index 0000000..6028a41 --- /dev/null +++ b/lib/screens/level_selection_screen.dart @@ -0,0 +1,312 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../bloc/game_bloc.dart'; +import '../services/level_service.dart'; +import '../game/models/level.dart'; +import 'game_screen.dart'; + +class LevelSelectionScreen extends StatefulWidget { + const LevelSelectionScreen({super.key}); + + @override + State createState() => _LevelSelectionScreenState(); +} + +class _LevelSelectionScreenState extends State { + List? _levelsWithProgress; + bool _isLoading = true; + String? _error; + + @override + void initState() { + super.initState(); + _loadLevels(); + } + + Future _loadLevels() async { + try { + final levelsWithProgress = + await LevelService.instance.getLevelsWithProgress(); + setState(() { + _levelsWithProgress = levelsWithProgress; + _isLoading = false; + }); + } catch (e) { + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.purple, Colors.deepPurple], + ), + ), + child: SafeArea( + child: Column( + children: [ + // Header + Padding( + padding: const EdgeInsets.all(20.0), + child: Row( + children: [ + IconButton( + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.arrow_back, + color: Colors.white, size: 30), + ), + const Expanded( + child: Text( + 'Select Level', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 32, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + const SizedBox(width: 50), // Balance the back button + ], + ), + ), + + // Content + Expanded( + child: _buildContent(), + ), + ], + ), + ), + ), + ); + } + + Widget _buildContent() { + if (_isLoading) { + return const Center( + child: CircularProgressIndicator(color: Colors.white), + ); + } + + if (_error != null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error, color: Colors.white, size: 64), + const SizedBox(height: 16), + const Text( + 'Error loading levels', + style: const TextStyle(color: Colors.white, fontSize: 18), + ), + const SizedBox(height: 8), + Text( + _error!, + style: const TextStyle(color: Colors.white70, fontSize: 14), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () { + setState(() { + _isLoading = true; + _error = null; + }); + _loadLevels(); + }, + child: const Text('Retry'), + ), + ], + ), + ); + } + + if (_levelsWithProgress == null || _levelsWithProgress!.isEmpty) { + return const Center( + child: Text( + 'No levels available', + style: TextStyle(color: Colors.white, fontSize: 18), + ), + ); + } + + return Padding( + padding: const EdgeInsets.all(20.0), + child: GridView.builder( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + childAspectRatio: 0.8, + ), + itemCount: _levelsWithProgress!.length, + itemBuilder: (context, index) { + final levelWithProgress = _levelsWithProgress![index]; + return _buildLevelCard(levelWithProgress); + }, + ), + ); + } + + Widget _buildLevelCard(LevelWithProgress levelWithProgress) { + final level = levelWithProgress.level; + final progress = levelWithProgress.progress; + final isUnlocked = levelWithProgress.isUnlocked; + final isCompleted = levelWithProgress.isCompleted; + + return GestureDetector( + onTap: isUnlocked ? () => _startLevel(level.id) : null, + child: Container( + decoration: BoxDecoration( + color: isUnlocked ? Colors.white : Colors.grey.shade400, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.2), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Level number + Container( + width: 50, + height: 50, + decoration: BoxDecoration( + color: isUnlocked + ? (isCompleted ? Colors.green : Colors.blue) + : Colors.grey, + shape: BoxShape.circle, + ), + child: Center( + child: isUnlocked + ? Text( + '${level.id}', + style: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ) + : const Icon( + Icons.lock, + color: Colors.white, + size: 24, + ), + ), + ), + + const SizedBox(height: 8), + + // Level name + Text( + level.name, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: isUnlocked ? Colors.black87 : Colors.grey.shade600, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + + const SizedBox(height: 4), + + // Stars (if completed) + if (isCompleted) ...[ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(3, (starIndex) { + return Icon( + starIndex < progress.stars ? Icons.star : Icons.star_border, + color: + starIndex < progress.stars ? Colors.amber : Colors.grey, + size: 16, + ); + }), + ), + const SizedBox(height: 4), + Text( + 'Best: ${progress.bestScore}', + style: TextStyle( + fontSize: 10, + color: Colors.grey.shade600, + ), + ), + ] else if (isUnlocked) ...[ + // Level constraints info + _buildConstraintInfo(level), + ], + ], + ), + ), + ); + } + + Widget _buildConstraintInfo(Level level) { + final constraints = []; + + if (level.constraints.hasScoreTarget) { + constraints.add('${level.constraints.targetScore!} pts'); + } + + if (level.constraints.hasMoveLimit) { + constraints.add('${level.constraints.maxMoves!} moves'); + } + + if (level.constraints.hasTimeLimit) { + constraints.add('${level.constraints.timeLimit!}s'); + } + + if (level.objectives.hasGemTypeObjectives) { + constraints.add('Clear gems'); + } + + return Column( + children: constraints + .take(2) + .map( + (constraint) => Text( + constraint, + style: TextStyle( + fontSize: 10, + color: Colors.grey.shade600, + ), + ), + ) + .toList(), + ); + } + + void _startLevel(int levelId) { + // Start the level first + print("Starting Level $levelId"); + + // Then navigate to game screen + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => BlocProvider.value( + value: context.read(), + child: GameScreen(levelId: levelId), + ), + ), + ).then((_) { + // Refresh levels when returning from game + _loadLevels(); + }); + } +} diff --git a/lib/screens/menu_screen.dart b/lib/screens/menu_screen.dart index 074bd6f..29e7392 100644 --- a/lib/screens/menu_screen.dart +++ b/lib/screens/menu_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../bloc/game_bloc.dart'; import 'game_screen.dart'; +import 'level_selection_screen.dart'; class MenuScreen extends StatelessWidget { const MenuScreen({super.key}); @@ -34,14 +35,34 @@ class MenuScreen extends StatelessWidget { onPressed: () { Navigator.push( context, - MaterialPageRoute(builder: (context) => const GameScreen()), + MaterialPageRoute( + builder: (context) => BlocProvider.value( + value: context.read(), + child: const LevelSelectionScreen(), + ), + ), ); }, style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 16), ), - child: const Text('Start Game', style: TextStyle(fontSize: 20)), + child: + const Text('Play Levels', style: TextStyle(fontSize: 20)), + ), + const SizedBox(height: 20), + ElevatedButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => GameScreen()), + ); + }, + style: ElevatedButton.styleFrom( + padding: + const EdgeInsets.symmetric(horizontal: 40, vertical: 16), + ), + child: const Text('Free Play', style: TextStyle(fontSize: 20)), ), ], ), diff --git a/lib/services/level_service.dart b/lib/services/level_service.dart new file mode 100644 index 0000000..055e995 --- /dev/null +++ b/lib/services/level_service.dart @@ -0,0 +1,262 @@ +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 LevelService? _instance; + static LevelService get instance => _instance ??= LevelService._(); + + LevelService._(); + + List? _levels; + Map? _progress; + + /// Load all levels from JSON configuration + Future> loadLevels() async { + if (_levels != null) return _levels!; + + try { + final String jsonString = + await rootBundle.loadString('assets/levels/levels.json'); + final Map 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 getLevel(int levelId) async { + final levels = await loadLevels(); + try { + return levels.firstWhere((level) => level.id == levelId); + } catch (e) { + return null; + } + } + + /// Load level progress from persistent storage + Future> loadProgress() async { + if (_progress != null) return _progress!; + + try { + final prefs = await SharedPreferences.getInstance(); + final String? progressJson = prefs.getString(_progressKey); + + if (progressJson != null) { + final Map 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 saveProgress() async { + if (_progress == null) return; + + try { + final prefs = await SharedPreferences.getInstance(); + final Map 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 getLevelProgress(int levelId) async { + final progress = await loadProgress(); + return progress[levelId] ?? LevelProgress(levelId: levelId); + } + + /// Update progress for a specific level + Future 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> 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 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 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 gemsCleared) { + // Check score constraint + if (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 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 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; +} diff --git a/pubspec.yaml b/pubspec.yaml index 0546115..6a236b8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -24,3 +24,4 @@ flutter: assets: - assets/images/ - assets/audio/ + - assets/levels/