match-three/lib/screens/game_screen.dart
savinmax eaa6947e93 Refactor gem swap logic to handle invalid moves and animations
- Add proper validation for null gems during swap operations
- Implement swap-back animation for invalid moves (no matches)
- Restructure swap flow to emit animation states before match detection
- Add move limit validation before processing matches
- Improve error handling and logging for edge cases
2025-09-28 13:00:45 +02:00

367 lines
13 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flame/game.dart';
import 'package:match_three/game/models/gem.dart';
import '../game/match_three_game.dart';
import '../bloc/game_bloc.dart';
class GameScreen extends StatefulWidget {
final int levelId;
final int gridHeight;
final int gridWidth;
const GameScreen(
{super.key, this.levelId = 0, this.gridHeight = 5, this.gridWidth = 5});
@override
State<GameScreen> createState() => _GameScreenState();
}
class _GameScreenState extends State<GameScreen> {
late MatchThreeGame game;
final bool isDebug = true;
@override
void initState() {
super.initState();
game = MatchThreeGame(
levelId: widget.levelId,
gridHeight: widget.gridHeight,
gridWidth: widget.gridWidth);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: BlocListener<GameBloc, GameState>(
listener: (context, state) {
if (game.gridComponent == null) return;
if (state is GameLevelPlaying) {
game.gridComponent!.updateGrid(state);
} else if (state is GameLevelCompleted) {
_showLevelCompletedDialog(context, state);
} else if (state is GameLevelFailed) {
_showLevelFailedDialog(context, state);
}
},
child: BlocBuilder<GameBloc, GameState>(
builder: (context, state) {
// Set game bloc reference
game.setGameBloc(context.read<GameBloc>());
return Stack(
children: [
GameWidget<MatchThreeGame>.controlled(
gameFactory: () => game,
),
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),
if (isDebug)
Text(
'State: ${state.runtimeType}',
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'Score: ${state.score}',
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
if (state.level.constraints.hasScoreTarget)
Text(
'Target: ${state.level.constraints.targetScore}',
style: const TextStyle(
color: Colors.green,
fontSize: 14,
),
),
Text(
'Moves: ${state.moves}',
style: const TextStyle(
color: Colors.white,
fontSize: 16,
),
),
if (state.level.constraints.hasMoveLimit)
Text(
'Limit: ${state.level.constraints.maxMoves}',
style: TextStyle(
color: state.moves >=
state.level.constraints.maxMoves!
? Colors.red
: Colors.orange,
fontSize: 14,
),
),
if (state.level.constraints.hasTimeLimit)
Text(
'Time: ${state.level.constraints.timeLimit! - state.timeElapsed}s',
style: TextStyle(
color: (state.level.constraints.timeLimit! -
state.timeElapsed) <=
10
? Colors.red
: Colors.cyan,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
if (state.level.objectives.hasGemTypeObjectives) ...[
const SizedBox(height: 4),
const Text(
'Objectives:',
style: TextStyle(
color: Colors.yellow,
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
...state.level.objectives.clearGemTypes.entries
.map((entry) {
final gemType = Gem.getName(entry.key);
final required = entry.value;
final cleared = state.gemsCleared[entry.key] ?? 0;
return Text(
'Gem $gemType: $cleared/$required',
style: TextStyle(
color: cleared >= required
? Colors.green
: Colors.white,
fontSize: 12,
),
);
}),
],
if (state is GameLevelMatch)
Text(
'Combo x ${state.combo + 1}',
style: const TextStyle(
color: Colors.white,
fontSize: 16,
),
),
],
),
),
),
Positioned(
top: 50,
right: 20,
child: IconButton(
onPressed: () => Navigator.pop(context),
icon:
const Icon(Icons.close, color: Colors.white, size: 30),
),
),
],
);
},
),
),
);
}
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'),
),
],
);
},
);
}
}