Add level system with progression and selection screen

- Add Level model with constraints, objectives, and star ratings
- Create LevelService for loading levels from JSON configuration
- Implement LevelSelectionScreen with visual progress tracking
- Update GameBloc to handle level-based gameplay
- Add 10 predefined levels with varying difficulty and objectives
- Integrate level progression system into game flow
This commit is contained in:
savinmax 2025-09-21 17:08:44 +02:00
parent a8088fd1e7
commit ea3f0c4e18
11 changed files with 1980 additions and 35 deletions

112
assets/levels/levels.json Normal file
View File

@ -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
}
]
}

View File

@ -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<Object> get props => [combo];
}
class StartLevel extends GameEvent {
final int levelId;
StartLevel(this.levelId);
@override
List<Object> get props => [levelId];
}
class UpdateTimer extends GameEvent {
final int timeElapsed;
UpdateTimer(this.timeElapsed);
@override
List<Object> get props => [timeElapsed];
}
class CompleteLevel extends GameEvent {}
class FailLevel extends GameEvent {}
// States
typedef DoneCallback = void Function([FutureOr<dynamic>?]);
@ -151,12 +170,218 @@ class GamePlayingMatch extends GamePlaying {
List<Object> 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<int, int> 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<dynamic>? _]) {}
@override
List<Object> 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<Object> get props =>
[grid, score, moves, level, timeElapsed, gemsCleared, gem1, gem2];
}
class GameLevelMatch extends GameLevelPlaying {
final List<Gem> 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<Object> 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<Gem> gems;
GameLevelNewGems({
required super.grid,
required super.level,
super.score,
super.moves,
super.done,
super.timeElapsed,
super.gemsCleared,
required this.gems,
});
@override
List<Object> 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<int, int> gemsCleared;
GameLevelCompleted({
required this.level,
required this.score,
required this.moves,
required this.timeElapsed,
required this.stars,
required this.gemsCleared,
});
@override
List<Object> 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<int, int> 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<Object> 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<int, int> gemsCleared;
final List<Gem> 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<Object> get props =>
[grid, level, score, moves, timeElapsed, gemsCleared, matches];
}
// Bloc
class GameBloc extends Bloc<GameEvent, GameState> {
Timer? _gameTimer;
final LevelService _levelService = LevelService.instance;
GameBloc() : super(GameInitial()) {
on<StartGame>(_onStartGame);
on<SwapGems>(_onSwapGems);
on<ProcessMatches>(_onProcessMatches);
on<StartLevel>(_onStartLevel);
on<UpdateTimer>(_onUpdateTimer);
on<CompleteLevel>(_onCompleteLevel);
on<FailLevel>(_onFailLevel);
}
@override
Future<void> close() {
_gameTimer?.cancel();
return super.close();
}
void _onStartGame(StartGame event, Emitter<GameState> emit) async {
@ -172,6 +397,7 @@ class GameBloc extends Bloc<GameEvent, GameState> {
}
void _onSwapGems(SwapGems event, Emitter<GameState> emit) async {
// Handle regular game states
if (state is GamePlaying) {
final currentState = state as GamePlaying;
@ -234,9 +460,102 @@ class GameBloc extends Bloc<GameEvent, GameState> {
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<GameState> emit) async {
// Handle regular game matches
if (state is _GamePlayingMatch) {
final currentState = state as _GamePlayingMatch;
@ -315,5 +634,314 @@ class GameBloc extends Bloc<GameEvent, GameState> {
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<GameState> 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<GameState> 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<GameState> 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<GameState> 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<int, int> _updateGemsCleared(Map<int, int> current, List<Gem> matches) {
final updated = Map<int, int>.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;
}
}
}

View File

@ -12,13 +12,18 @@ class GridComponent extends PositionComponent
late GameGrid gameGrid;
final List<List<GemComponent?>> gemComponents = [];
final MatchThreeGame game;
int? levelId;
bool canInterract = false;
GridComponent(this.game) : super();
GridComponent(this.game, this.levelId) : super();
@override
Future<void> onLoad() async {
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;
}
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) {

View File

@ -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<void> onLoad() async {
backgroundComponent = BackgroundComponent();
add(backgroundComponent);
gridComponent = GridComponent(this);
gridComponent = GridComponent(this, levelId);
add(gridComponent!);
}

View File

@ -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';
}
}
}

278
lib/game/models/level.dart Normal file
View File

@ -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<int> 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<String, dynamic> 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<int>.from(json['availableGemTypes']),
unlocked: json['unlocked'] ?? false,
);
}
Map<String, dynamic> 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<int>? 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<Object?> 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<String, dynamic> json) {
return LevelConstraints(
targetScore: json['targetScore'],
maxMoves: json['maxMoves'],
timeLimit: json['timeLimit'],
);
}
Map<String, dynamic> 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<Object?> get props => [targetScore, maxMoves, timeLimit];
}
class LevelObjectives extends Equatable {
final Map<int, int> clearGemTypes; // gemType -> count required
const LevelObjectives({
this.clearGemTypes = const {},
});
factory LevelObjectives.fromJson(Map<String, dynamic> json) {
Map<int, int> clearGemTypes = {};
if (json['clearGemTypes'] != null) {
final clearGemTypesJson = json['clearGemTypes'] as Map<String, dynamic>;
clearGemTypes = clearGemTypesJson.map(
(key, value) => MapEntry(int.parse(key), value as int),
);
}
return LevelObjectives(
clearGemTypes: clearGemTypes,
);
}
Map<String, dynamic> toJson() {
return {
'clearGemTypes': clearGemTypes.map(
(key, value) => MapEntry(key.toString(), value),
),
};
}
bool get hasGemTypeObjectives => clearGemTypes.isNotEmpty;
@override
List<Object?> 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<String, dynamic> json) {
return StarRating(
criteria: StarCriteria.values.firstWhere(
(e) => e.name == json['criteria'],
),
thresholds: StarThresholds.fromJson(json['thresholds']),
);
}
Map<String, dynamic> toJson() {
return {
'criteria': criteria.name,
'thresholds': thresholds.toJson(),
};
}
@override
List<Object?> 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<String, dynamic> json) {
return StarThresholds(
oneStar: json['oneStar'],
twoStar: json['twoStar'],
threeStar: json['threeStar'],
);
}
Map<String, dynamic> toJson() {
return {
'oneStar': oneStar,
'twoStar': twoStar,
'threeStar': threeStar,
};
}
@override
List<Object?> 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<String, dynamic> json) {
return LevelProgress(
levelId: json['levelId'],
completed: json['completed'] ?? false,
stars: json['stars'] ?? 0,
bestScore: json['bestScore'] ?? 0,
unlocked: json['unlocked'] ?? false,
);
}
Map<String, dynamic> 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<Object?> get props => [levelId, completed, stars, bestScore, unlocked];
}

View File

@ -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<GameScreen> createState() => _GameScreenState();
@ -17,7 +21,7 @@ class _GameScreenState extends State<GameScreen> {
@override
void initState() {
super.initState();
game = MatchThreeGame();
game = MatchThreeGame(widget.levelId);
}
@override
@ -25,8 +29,13 @@ class _GameScreenState extends State<GameScreen> {
return Scaffold(
body: BlocListener<GameBloc, GameState>(
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<GameBloc, GameState>(
@ -85,6 +94,113 @@ class _GameScreenState extends State<GameScreen> {
),
),
),
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<GameScreen> {
),
);
}
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<GameBloc>().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<GameBloc>().add(StartLevel(state.level.id));
},
child: const Text('Try Again'),
),
],
);
},
);
}
}

View File

@ -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<LevelSelectionScreen> createState() => _LevelSelectionScreenState();
}
class _LevelSelectionScreenState extends State<LevelSelectionScreen> {
List<LevelWithProgress>? _levelsWithProgress;
bool _isLoading = true;
String? _error;
@override
void initState() {
super.initState();
_loadLevels();
}
Future<void> _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 = <String>[];
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<GameBloc>(),
child: GameScreen(levelId: levelId),
),
),
).then((_) {
// Refresh levels when returning from game
_loadLevels();
});
}
}

View File

@ -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<GameBloc>(),
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)),
),
],
),

View File

@ -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<Level>? _levels;
Map<int, LevelProgress>? _progress;
/// 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 {
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.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;
}

View File

@ -24,3 +24,4 @@ flutter:
assets:
- assets/images/
- assets/audio/
- assets/levels/