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 'dart:async';
|
||||||
|
|
||||||
import 'package:flame/game.dart';
|
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import '../game/models/grid.dart';
|
import '../game/models/grid.dart';
|
||||||
import '../game/models/gem.dart';
|
import '../game/models/gem.dart';
|
||||||
|
import '../game/models/level.dart';
|
||||||
import '../game/systems/match_detector.dart';
|
import '../game/systems/match_detector.dart';
|
||||||
import '../game/systems/gravity_system.dart';
|
import '../game/systems/gravity_system.dart';
|
||||||
|
import '../services/level_service.dart';
|
||||||
import '../utils/constants.dart';
|
import '../utils/constants.dart';
|
||||||
|
|
||||||
// Events
|
// Events
|
||||||
@ -31,6 +32,24 @@ class ProcessMatches extends GameEvent {
|
|||||||
List<Object> get props => [combo];
|
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
|
// States
|
||||||
typedef DoneCallback = void Function([FutureOr<dynamic>?]);
|
typedef DoneCallback = void Function([FutureOr<dynamic>?]);
|
||||||
|
|
||||||
@ -151,12 +170,218 @@ class GamePlayingMatch extends GamePlaying {
|
|||||||
List<Object> get props => [grid, score, moves, matches];
|
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
|
// Bloc
|
||||||
class GameBloc extends Bloc<GameEvent, GameState> {
|
class GameBloc extends Bloc<GameEvent, GameState> {
|
||||||
|
Timer? _gameTimer;
|
||||||
|
final LevelService _levelService = LevelService.instance;
|
||||||
|
|
||||||
GameBloc() : super(GameInitial()) {
|
GameBloc() : super(GameInitial()) {
|
||||||
on<StartGame>(_onStartGame);
|
on<StartGame>(_onStartGame);
|
||||||
on<SwapGems>(_onSwapGems);
|
on<SwapGems>(_onSwapGems);
|
||||||
on<ProcessMatches>(_onProcessMatches);
|
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 {
|
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 {
|
void _onSwapGems(SwapGems event, Emitter<GameState> emit) async {
|
||||||
|
// Handle regular game states
|
||||||
if (state is GamePlaying) {
|
if (state is GamePlaying) {
|
||||||
final currentState = state as GamePlaying;
|
final currentState = state as GamePlaying;
|
||||||
|
|
||||||
@ -234,9 +460,102 @@ class GameBloc extends Bloc<GameEvent, GameState> {
|
|||||||
await done.future;
|
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 {
|
void _onProcessMatches(ProcessMatches event, Emitter<GameState> emit) async {
|
||||||
|
// Handle regular game matches
|
||||||
if (state is _GamePlayingMatch) {
|
if (state is _GamePlayingMatch) {
|
||||||
final currentState = state as _GamePlayingMatch;
|
final currentState = state as _GamePlayingMatch;
|
||||||
|
|
||||||
@ -315,5 +634,314 @@ class GameBloc extends Bloc<GameEvent, GameState> {
|
|||||||
grid: newGrid, score: newScore, moves: currentState.moves));
|
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;
|
late GameGrid gameGrid;
|
||||||
final List<List<GemComponent?>> gemComponents = [];
|
final List<List<GemComponent?>> gemComponents = [];
|
||||||
final MatchThreeGame game;
|
final MatchThreeGame game;
|
||||||
|
int? levelId;
|
||||||
bool canInterract = false;
|
bool canInterract = false;
|
||||||
|
|
||||||
GridComponent(this.game) : super();
|
GridComponent(this.game, this.levelId) : super();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> onLoad() async {
|
Future<void> onLoad() async {
|
||||||
|
if (levelId == null) {
|
||||||
game.gameBloc.add(StartGame());
|
game.gameBloc.add(StartGame());
|
||||||
|
} else {
|
||||||
|
game.gameBloc.add(StartLevel(levelId!));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _createGemComponents() {
|
void _createGemComponents() {
|
||||||
@ -57,11 +62,13 @@ class GridComponent extends PositionComponent
|
|||||||
|
|
||||||
void updateGrid(GameState state) async {
|
void updateGrid(GameState state) async {
|
||||||
// Work only with relevant events
|
// Work only with relevant events
|
||||||
if (state is! GamePlaying) {
|
if (state is! GamePlaying && state is! GameLevelPlaying) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
canInterract = false;
|
canInterract = false;
|
||||||
print("Update event with state ${state.runtimeType.toString()}");
|
print("Update event with state ${state.runtimeType.toString()}");
|
||||||
|
|
||||||
|
// Handle regular game states
|
||||||
if (state is GamePlayingSwap) {
|
if (state is GamePlayingSwap) {
|
||||||
await _swapGems(
|
await _swapGems(
|
||||||
state.grid,
|
state.grid,
|
||||||
@ -69,24 +76,50 @@ class GridComponent extends PositionComponent
|
|||||||
state.gem2,
|
state.gem2,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (state is GameLevelSwap) {
|
||||||
|
await _swapGems(
|
||||||
|
state.grid,
|
||||||
|
state.gem1,
|
||||||
|
state.gem2,
|
||||||
|
);
|
||||||
|
}
|
||||||
if (state is GamePlayingMatch) {
|
if (state is GamePlayingMatch) {
|
||||||
await _animateMatch(state.grid, state.matches);
|
await _animateMatch(state.grid, state.matches);
|
||||||
}
|
}
|
||||||
|
if (state is GameLevelMatch) {
|
||||||
|
await _animateMatch(state.grid, state.matches);
|
||||||
|
}
|
||||||
if (state is GamePlayingDrop) {
|
if (state is GamePlayingDrop) {
|
||||||
await _animateDrop(state.grid);
|
await _animateDrop(state.grid);
|
||||||
}
|
}
|
||||||
|
if (state is GameLevelDrop) {
|
||||||
|
await _animateDrop(state.grid);
|
||||||
|
}
|
||||||
if (state is GamePlayingNewGems) {
|
if (state is GamePlayingNewGems) {
|
||||||
await _animateNewGemsFall(state.grid, state.gems);
|
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;
|
canInterract = true;
|
||||||
}
|
}
|
||||||
|
if (state is GamePlaying) {
|
||||||
gameGrid = state.grid;
|
gameGrid = state.grid;
|
||||||
gameGrid.printGrid();
|
gameGrid.printGrid();
|
||||||
_createGemComponents();
|
_createGemComponents();
|
||||||
await Future.delayed(const Duration(milliseconds: 100));
|
await Future.delayed(const Duration(milliseconds: 100));
|
||||||
print("Updated state ${state.runtimeType.toString()}");
|
print("Updated state ${state.runtimeType.toString()}");
|
||||||
state.done();
|
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) {
|
GemComponent? _findGemComponent(Gem gem) {
|
||||||
|
|||||||
@ -10,18 +10,20 @@ import '../bloc/game_bloc.dart';
|
|||||||
const gridSize = 8 * 64.0 + 32.0; // 8 gems * 64px + padding
|
const gridSize = 8 * 64.0 + 32.0; // 8 gems * 64px + padding
|
||||||
|
|
||||||
class MatchThreeGame extends FlameGame {
|
class MatchThreeGame extends FlameGame {
|
||||||
GridComponent? gridComponent;
|
|
||||||
late BackgroundComponent backgroundComponent;
|
late BackgroundComponent backgroundComponent;
|
||||||
late GameBloc gameBloc;
|
late GameBloc gameBloc;
|
||||||
|
final int? levelId;
|
||||||
|
GridComponent? gridComponent;
|
||||||
Gem? selectedGem;
|
Gem? selectedGem;
|
||||||
|
|
||||||
|
MatchThreeGame(this.levelId) : super();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> onLoad() async {
|
Future<void> onLoad() async {
|
||||||
backgroundComponent = BackgroundComponent();
|
backgroundComponent = BackgroundComponent();
|
||||||
add(backgroundComponent);
|
add(backgroundComponent);
|
||||||
|
|
||||||
gridComponent = GridComponent(this);
|
gridComponent = GridComponent(this, levelId);
|
||||||
add(gridComponent!);
|
add(gridComponent!);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -32,22 +32,7 @@ class Gem extends Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get name {
|
get name {
|
||||||
switch (type) {
|
return getName(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';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -74,4 +59,23 @@ class Gem extends Equatable {
|
|||||||
isMatched.hashCode ^
|
isMatched.hashCode ^
|
||||||
isSpecial.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/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flame/game.dart';
|
import 'package:flame/game.dart';
|
||||||
|
import 'package:match_three/game/models/gem.dart';
|
||||||
import '../game/match_three_game.dart';
|
import '../game/match_three_game.dart';
|
||||||
import '../bloc/game_bloc.dart';
|
import '../bloc/game_bloc.dart';
|
||||||
|
|
||||||
|
// ignore: must_be_immutable
|
||||||
class GameScreen extends StatefulWidget {
|
class GameScreen extends StatefulWidget {
|
||||||
const GameScreen({super.key});
|
int? levelId;
|
||||||
|
|
||||||
|
GameScreen({super.key, this.levelId});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<GameScreen> createState() => _GameScreenState();
|
State<GameScreen> createState() => _GameScreenState();
|
||||||
@ -17,7 +21,7 @@ class _GameScreenState extends State<GameScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
game = MatchThreeGame();
|
game = MatchThreeGame(widget.levelId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -25,8 +29,13 @@ class _GameScreenState extends State<GameScreen> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: BlocListener<GameBloc, GameState>(
|
body: BlocListener<GameBloc, GameState>(
|
||||||
listener: (context, state) {
|
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);
|
game.gridComponent!.updateGrid(state);
|
||||||
|
} else if (state is GameLevelCompleted) {
|
||||||
|
_showLevelCompletedDialog(context, state);
|
||||||
|
} else if (state is GameLevelFailed) {
|
||||||
|
_showLevelFailedDialog(context, state);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: BlocBuilder<GameBloc, GameState>(
|
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(
|
Positioned(
|
||||||
top: 50,
|
top: 50,
|
||||||
right: 20,
|
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 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import '../bloc/game_bloc.dart';
|
import '../bloc/game_bloc.dart';
|
||||||
import 'game_screen.dart';
|
import 'game_screen.dart';
|
||||||
|
import 'level_selection_screen.dart';
|
||||||
|
|
||||||
class MenuScreen extends StatelessWidget {
|
class MenuScreen extends StatelessWidget {
|
||||||
const MenuScreen({super.key});
|
const MenuScreen({super.key});
|
||||||
@ -34,14 +35,34 @@ class MenuScreen extends StatelessWidget {
|
|||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(builder: (context) => const GameScreen()),
|
MaterialPageRoute(
|
||||||
|
builder: (context) => BlocProvider.value(
|
||||||
|
value: context.read<GameBloc>(),
|
||||||
|
child: const LevelSelectionScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
padding:
|
padding:
|
||||||
const EdgeInsets.symmetric(horizontal: 40, vertical: 16),
|
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:
|
||||||
- assets/images/
|
- assets/images/
|
||||||
- assets/audio/
|
- assets/audio/
|
||||||
|
- assets/levels/
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user