Refactor game state management to use level-based architecture

Remove legacy GamePlaying states and consolidate all gameplay logic
to use the GameLevelPlaying state system. This simplifies state
management by eliminating duplicate code paths and ensures consistent
behavior across all game modes including Free Play.
This commit is contained in:
savinmax 2025-09-21 17:26:51 +02:00
parent ea3f0c4e18
commit 64053d1d58
5 changed files with 58 additions and 364 deletions

View File

@ -16,8 +16,6 @@ abstract class GameEvent extends Equatable {
List<Object> get props => [];
}
class StartGame extends GameEvent {}
class SwapGems extends GameEvent {
final int row1, col1, row2, col2;
SwapGems(this.row1, this.col1, this.row2, this.col2);
@ -58,118 +56,8 @@ abstract class GameState extends Equatable {
List<Object> get props => [];
}
// Internal match state event
class _GamePlayingMatch extends GameState {
final GameGrid grid;
final int score;
final int moves;
final List<Gem> matches;
_GamePlayingMatch({
required this.grid,
this.score = 0,
this.moves = 0,
this.matches = const [],
});
@override
List<Object> get props => [grid, score, moves, matches];
}
class GameInitial extends GameState {}
class GamePlaying extends GameState {
final GameGrid grid;
final DoneCallback done;
final int score;
final int moves;
GamePlaying({
required this.grid,
this.done = _defaultDoneCallback,
this.score = 0,
this.moves = 0,
});
static void _defaultDoneCallback([FutureOr<dynamic>? _]) {}
@override
List<Object> get props => [grid, score, moves];
}
class GamePlayingStart extends GamePlaying {
GamePlayingStart({
required super.grid,
super.done,
super.score,
super.moves,
});
}
class GamePlayingIdle extends GamePlaying {
GamePlayingIdle({
required super.grid,
super.done,
super.score,
super.moves,
});
}
class GamePlayingDrop extends GamePlaying {
GamePlayingDrop({
required super.grid,
super.done,
super.score,
super.moves,
});
}
class GamePlayingSwap extends GamePlaying {
final Gem gem1, gem2;
GamePlayingSwap(
{required super.grid,
super.score,
super.moves,
super.done,
required this.gem1,
required this.gem2});
@override
List<Object> get props => [grid, score, moves, gem1, gem2];
}
class GamePlayingNewGems extends GamePlaying {
final List<Gem> gems;
GamePlayingNewGems(
{required super.grid,
super.score,
super.moves,
super.done,
required this.gems});
@override
List<Object> get props => [grid, score, moves, gems];
}
class GamePlayingMatch extends GamePlaying {
final List<Gem> matches;
final int combo;
GamePlayingMatch({
required super.grid,
super.score,
super.moves,
super.done,
this.combo = 0,
this.matches = const [],
});
@override
List<Object> get props => [grid, score, moves, matches];
}
// Level-based states
class GameLevelPlaying extends GameState {
final GameGrid grid;
@ -368,10 +256,8 @@ class GameBloc extends Bloc<GameEvent, GameState> {
final LevelService _levelService = LevelService.instance;
GameBloc() : super(GameInitial()) {
on<StartGame>(_onStartGame);
on<SwapGems>(_onSwapGems);
on<ProcessMatches>(_onProcessMatches);
on<StartLevel>(_onStartLevel);
on<UpdateTimer>(_onUpdateTimer);
on<CompleteLevel>(_onCompleteLevel);
@ -384,84 +270,9 @@ class GameBloc extends Bloc<GameEvent, GameState> {
return super.close();
}
void _onStartGame(StartGame event, Emitter<GameState> emit) async {
final grid = GameGrid();
final done = Completer();
emit(GamePlayingStart(grid: grid, done: done.complete));
await done.future;
final matches = MatchDetector.findMatches(grid);
emit(_GamePlayingMatch(grid: grid, matches: matches));
add(ProcessMatches());
}
void _onSwapGems(SwapGems event, Emitter<GameState> emit) async {
// Handle regular game states
if (state is GamePlaying) {
final currentState = state as GamePlaying;
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(GamePlayingSwap(
grid: newGrid,
score: currentState.score,
moves: currentState.moves,
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) {
emit(_GamePlayingMatch(
grid: newGrid,
score: currentState.score,
moves: currentState.moves + 1,
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(GamePlayingSwap(
grid: newGrid,
score: currentState.score,
moves: currentState.moves,
gem1: gem2,
gem2: gem1,
done: done.complete,
));
}
// Wait for swap animation
await done.future;
}
}
// Handle level-based states
else if (state is GameLevelPlaying) {
// Handle only level-based states (including Free Play)
if (state is GameLevelPlaying) {
final currentState = state as GameLevelPlaying;
if (!MatchDetector.isValidSwap(
@ -555,87 +366,8 @@ class GameBloc extends Bloc<GameEvent, GameState> {
}
void _onProcessMatches(ProcessMatches event, Emitter<GameState> emit) async {
// Handle regular game matches
if (state is _GamePlayingMatch) {
final currentState = state as _GamePlayingMatch;
// Calculate score
int newScore = currentState.score +
currentState.matches.length * GameConstants.baseScore;
if (event.combo > 0) {
newScore += GameConstants.comboMultiplier * event.combo;
}
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(GamePlayingMatch(
grid: newGrid,
score: newScore,
moves: currentState.moves,
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(GamePlayingDrop(
grid: newGrid,
score: newScore,
moves: currentState.moves,
done: done.complete,
));
await done.future;
newGrid = newGrid.clone();
// Apply gravity
final newGems = GravitySystem.generateGems(newGrid);
// Emit state for drop columns
done = Completer();
emit(GamePlayingNewGems(
grid: newGrid,
score: newScore,
moves: currentState.moves,
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(_GamePlayingMatch(
grid: newGrid,
score: newScore,
moves: currentState.moves,
matches: newMatches,
));
add(ProcessMatches(combo: event.combo + 1));
} else {
emit(GamePlayingIdle(
grid: newGrid, score: newScore, moves: currentState.moves));
}
}
// Handle level-based matches
else if (state is _GameLevelPlayingMatch) {
// Handle only level-based matches (including Free Play)
if (state is _GameLevelPlayingMatch) {
final currentState = state as _GameLevelPlayingMatch;
// Calculate score

View File

@ -19,11 +19,9 @@ class GridComponent extends PositionComponent
@override
Future<void> onLoad() async {
if (levelId == null) {
game.gameBloc.add(StartGame());
} else {
game.gameBloc.add(StartLevel(levelId!));
}
// Always use StartLevel - Free Play is level ID 0
final id = levelId ?? 0; // Default to Free Play if no level specified
game.gameBloc.add(StartLevel(id));
}
void _createGemComponents() {
@ -61,21 +59,14 @@ class GridComponent extends PositionComponent
}
void updateGrid(GameState state) async {
// Work only with relevant events
if (state is! GamePlaying && state is! GameLevelPlaying) {
// Work only with level-based states (including Free Play)
if (state is! GameLevelPlaying) {
return;
}
canInterract = false;
print("Update event with state ${state.runtimeType.toString()}");
// Handle regular game states
if (state is GamePlayingSwap) {
await _swapGems(
state.grid,
state.gem1,
state.gem2,
);
}
// Handle level-based states
if (state is GameLevelSwap) {
await _swapGems(
state.grid,
@ -83,43 +74,27 @@ class GridComponent extends PositionComponent
state.gem2,
);
}
if (state is GamePlayingMatch) {
await _animateMatch(state.grid, state.matches);
}
if (state is GameLevelMatch) {
await _animateMatch(state.grid, state.matches);
}
if (state is GamePlayingDrop) {
await _animateDrop(state.grid);
}
if (state is GameLevelDrop) {
await _animateDrop(state.grid);
}
if (state is GamePlayingNewGems) {
await _animateNewGemsFall(state.grid, state.gems);
}
if (state is GameLevelNewGems) {
await _animateNewGemsFall(state.grid, state.gems);
}
if (state is GamePlayingIdle || state is GameLevelIdle) {
if (state is GameLevelIdle) {
canInterract = true;
}
if (state is GamePlaying) {
gameGrid = state.grid;
// Update grid and create components for all GameLevelPlaying states
final levelState = state as GameLevelPlaying;
gameGrid = levelState.grid;
gameGrid.printGrid();
_createGemComponents();
await Future.delayed(const Duration(milliseconds: 100));
print("Updated state ${state.runtimeType.toString()}");
state.done();
} else {
state = state as GameLevelPlaying;
gameGrid = state.grid;
gameGrid.printGrid();
_createGemComponents();
await Future.delayed(const Duration(milliseconds: 100));
print("Updated state ${state.runtimeType.toString()}");
state.done();
}
levelState.done();
}
GemComponent? _findGemComponent(Gem gem) {

View File

@ -30,7 +30,7 @@ class _GameScreenState extends State<GameScreen> {
body: BlocListener<GameBloc, GameState>(
listener: (context, state) {
if (game.gridComponent == null) return;
if (state is GameLevelPlaying || state is GamePlaying) {
if (state is GameLevelPlaying) {
game.gridComponent!.updateGrid(state);
} else if (state is GameLevelCompleted) {
_showLevelCompletedDialog(context, state);
@ -48,52 +48,6 @@ class _GameScreenState extends State<GameScreen> {
GameWidget<MatchThreeGame>.controlled(
gameFactory: () => game,
),
if (state is GamePlaying)
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(
'Score: ${state.score}',
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
Text(
'Moves: ${state.moves}',
style: const TextStyle(
color: Colors.white,
fontSize: 16,
),
),
Text(
'Combo x ${state is GamePlayingMatch ? (state.combo + 1) : "-"}',
style: const TextStyle(
color: Colors.white,
fontSize: 16,
),
),
Text(
'Last state: ${state.runtimeType}',
style: const TextStyle(
color: Colors.blue,
fontSize: 16,
),
),
],
),
),
),
if (state is GameLevelPlaying)
Positioned(
top: 50,

View File

@ -53,9 +53,16 @@ class MenuScreen extends StatelessWidget {
const SizedBox(height: 20),
ElevatedButton(
onPressed: () {
// Start Free Play level (ID: 0)
context.read<GameBloc>().add(StartLevel(0));
Navigator.push(
context,
MaterialPageRoute(builder: (context) => GameScreen()),
MaterialPageRoute(
builder: (context) => BlocProvider.value(
value: context.read<GameBloc>(),
child: GameScreen(),
),
),
);
},
style: ElevatedButton.styleFrom(

View File

@ -6,6 +6,7 @@ import '../game/models/level.dart';
class LevelService {
static const String _progressKey = 'level_progress';
static const int freePlayLevelId = 0; // Special ID for Free Play
static LevelService? _instance;
static LevelService get instance => _instance ??= LevelService._();
@ -14,6 +15,25 @@ class LevelService {
List<Level>? _levels;
Map<int, LevelProgress>? _progress;
/// Get the hardcoded Free Play level
Level get freePlayLevel => const Level(
id: freePlayLevelId,
name: 'Free Play',
description: 'Play without constraints - match gems and score points!',
constraints: LevelConstraints(), // No constraints
objectives: LevelObjectives(), // No objectives
starRating: StarRating(
criteria: StarCriteria.SCORE,
thresholds: StarThresholds(
oneStar: 1000,
twoStar: 5000,
threeStar: 10000,
),
),
availableGemTypes: [0, 1, 2, 3, 4, 5], // All gem types available
unlocked: true, // Always unlocked
);
/// Load all levels from JSON configuration
Future<List<Level>> loadLevels() async {
if (_levels != null) return _levels!;
@ -35,6 +55,11 @@ class LevelService {
/// Get a specific level by ID
Future<Level?> getLevel(int levelId) async {
// Return Free Play level for ID 0
if (levelId == freePlayLevelId) {
return freePlayLevel;
}
final levels = await loadLevels();
try {
return levels.firstWhere((level) => level.id == levelId);
@ -196,8 +221,9 @@ class LevelService {
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!) {
if (level.id == 0 ||
(level.constraints.hasScoreTarget &&
score < level.constraints.targetScore!)) {
return false;
}