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:
parent
a8088fd1e7
commit
ea3f0c4e18
112
assets/levels/levels.json
Normal file
112
assets/levels/levels.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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!);
|
||||
}
|
||||
|
||||
|
||||
@ -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
278
lib/game/models/level.dart
Normal 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];
|
||||
}
|
||||
@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
312
lib/screens/level_selection_screen.dart
Normal file
312
lib/screens/level_selection_screen.dart
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
262
lib/services/level_service.dart
Normal file
262
lib/services/level_service.dart
Normal 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;
|
||||
}
|
||||
@ -24,3 +24,4 @@ flutter:
|
||||
assets:
|
||||
- assets/images/
|
||||
- assets/audio/
|
||||
- assets/levels/
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user