- Add gridWidth and gridHeight properties to level configuration - Update GameGrid to accept custom dimensions instead of using constants - Modify GridComponent to calculate gem size based on grid dimensions - Update MatchThreeGame constructor to pass grid dimensions - Ensure proper scaling and positioning for variable grid sizes
680 lines
18 KiB
Dart
680 lines
18 KiB
Dart
import 'dart:async';
|
|
|
|
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
|
|
abstract class GameEvent extends Equatable {
|
|
@override
|
|
List<Object> get props => [];
|
|
}
|
|
|
|
class SwapGems extends GameEvent {
|
|
final int row1, col1, row2, col2;
|
|
SwapGems(this.row1, this.col1, this.row2, this.col2);
|
|
@override
|
|
List<Object> get props => [row1, col1, row2, col2];
|
|
}
|
|
|
|
class ProcessMatches extends GameEvent {
|
|
final int combo;
|
|
ProcessMatches({this.combo = 0});
|
|
@override
|
|
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>?]);
|
|
|
|
abstract class GameState extends Equatable {
|
|
@override
|
|
List<Object> get props => [];
|
|
}
|
|
|
|
class GameInitial extends GameState {}
|
|
|
|
// 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<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 _onSwapGems(SwapGems event, Emitter<GameState> emit) async {
|
|
// Handle only level-based states (including Free Play)
|
|
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 only level-based matches (including Free Play)
|
|
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(width: level.gridWidth, height: level.gridHeight);
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
}
|