match-three/lib/bloc/game_bloc.dart
savinmax 3f12ce8d3f Add dynamic grid sizing support for levels
- 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
2025-09-21 18:06:00 +02:00

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