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
This commit is contained in:
savinmax 2025-09-28 13:00:45 +02:00
parent 3f12ce8d3f
commit eaa6947e93
8 changed files with 122 additions and 77 deletions

View File

@ -279,13 +279,16 @@ class GameBloc extends Bloc<GameEvent, GameState> {
currentState.grid, event.row1, event.col1, event.row2, event.col2)) { currentState.grid, event.row1, event.col1, event.row2, event.col2)) {
return; return;
} }
final newGrid = currentState.grid.clone(); GameGrid newGrid = currentState.grid.clone();
// Perform swap // Perform swap
final gem1 = newGrid.getGem(event.row1, event.col1); final gem1 = newGrid.getGem(event.row1, event.col1);
final gem2 = newGrid.getGem(event.row2, event.col2); final gem2 = newGrid.getGem(event.row2, event.col2);
if (gem1 != null && gem2 != null) { if (gem1 == null || gem2 == null) {
print("Gem1 or Gem2 is null, that should not be so");
return;
}
newGrid.setGem(event.row1, event.col1, newGrid.setGem(event.row1, event.col1,
gem2.copyWith(row: event.row1, col: event.col1)); gem2.copyWith(row: event.row1, col: event.col1));
newGrid.setGem(event.row2, event.col2, newGrid.setGem(event.row2, event.col2,
@ -343,6 +346,7 @@ class GameBloc extends Bloc<GameEvent, GameState> {
add(ProcessMatches()); add(ProcessMatches());
} else { } else {
// Revert swap if no matches // Revert swap if no matches
newGrid = newGrid.clone();
newGrid.setGem(event.row1, event.col1, gem1); newGrid.setGem(event.row1, event.col1, gem1);
newGrid.setGem(event.row2, event.col2, gem2); newGrid.setGem(event.row2, event.col2, gem2);
var done = Completer(); var done = Completer();
@ -358,8 +362,19 @@ class GameBloc extends Bloc<GameEvent, GameState> {
gem2: gem1, gem2: gem1,
done: done.complete, done: done.complete,
)); ));
} // Wait for swap-back animation
// Wait for swap animation await done.future;
done = Completer();
emit(GameLevelIdle(
grid: newGrid,
level: currentState.level,
score: currentState.score,
moves: currentState.moves,
timeElapsed: currentState.timeElapsed,
gemsCleared: currentState.gemsCleared,
done: done.complete,
));
await done.future; await done.future;
} }
} }

View File

@ -55,7 +55,7 @@ class GemComponent extends RectangleComponent with TapCallbacks {
parent?.add(explosion); parent?.add(explosion);
// Animate gem destruction // Animate gem destruction
PhysicsSystem.animateMatch(this); await PhysicsSystem.animateMatch(this);
// Remove after animation // Remove after animation
await Future.delayed( await Future.delayed(

View File

@ -9,11 +9,12 @@ class ParticleSystem {
return ParticleSystemComponent( return ParticleSystemComponent(
priority: 1, priority: 1,
particle: Particle.generate( particle: Particle.generate(
count: 15, count: 50,
lifespan: 1.0, lifespan: 1.0,
generator: (i) => AcceleratedParticle( generator: (i) => AcceleratedParticle(
acceleration: Vector2(0, 200), acceleration:
speed: Vector2.random(Random()) * 100, (Vector2.random(Random()) - Vector2.random(Random())) * 300,
speed: (Vector2.random(Random()) - Vector2.random(Random())) * 500,
position: Vector2.zero(), position: Vector2.zero(),
child: CircleParticle( child: CircleParticle(
radius: Random().nextDouble() * 3 + 2, radius: Random().nextDouble() * 3 + 2,

View File

@ -1,6 +1,7 @@
import 'dart:ui'; import 'dart:ui';
import 'package:flame/game.dart'; import 'package:flame/game.dart';
import 'package:match_three/game/components/gem_component.dart';
import 'package:match_three/game/systems/match_detector.dart'; import 'package:match_three/game/systems/match_detector.dart';
import 'components/grid_component.dart'; import 'components/grid_component.dart';
import 'components/background_component.dart'; import 'components/background_component.dart';
@ -51,23 +52,32 @@ class MatchThreeGame extends FlameGame {
selectedGem = gem; selectedGem = gem;
return; return;
} }
if (MatchDetector.isValidSwap(gridComponent!.gameGrid, selectedGem!.row, var theGem = selectedGem;
selectedGem!.col, gem.row, gem.col)) { selectedGem = null;
if (MatchDetector.isValidSwap(
gridComponent!.gameGrid, theGem!.row, theGem.col, gem.row, gem.col)) {
// Attempt swap // Attempt swap
print( print(
")) Valid swapping ${selectedGem!.row}, ${selectedGem!.col} with ${gem.row}, ${gem.col}"); ")) Valid swapping ${theGem.row}, ${theGem.col} with ${gem.row}, ${gem.col}");
gameBloc.add(SwapGems( gameBloc.add(SwapGems(
selectedGem!.row, theGem.row,
selectedGem!.col, theGem.col,
gem.row, gem.row,
gem.col, gem.col,
)); ));
} else { } else {
print( print(
"(( Invalid swapping ${selectedGem!.row}, ${selectedGem!.col} with ${gem.row}, ${gem.col}"); "(( Invalid swapping ${theGem.row}, ${theGem.col} with ${gem.row}, ${gem.col}");
}
if (gridComponent == null) {
return;
}
for (var comp in gridComponent!.children) {
if (comp is GemComponent && comp.gem != gem) {
comp.isSelected = false;
}
} }
selectedGem = null;
} }
void setGameBloc(GameBloc bloc) { void setGameBloc(GameBloc bloc) {

View File

@ -45,7 +45,7 @@ class GameGrid {
return row >= 0 && row < height && col >= 0 && col < width; return row >= 0 && row < height && col >= 0 && col < width;
} }
clone() { GameGrid clone() {
final clonedGrid = GameGrid(width: width, height: height); final clonedGrid = GameGrid(width: width, height: height);
for (int row = 0; row < _grid.length; row++) { for (int row = 0; row < _grid.length; row++) {
for (int col = 0; col < _grid[row].length; col++) { for (int col = 0; col < _grid[row].length; col++) {

View File

@ -11,7 +11,7 @@ class MatchDetector {
for (int row = 0; row < grid.height; row++) { for (int row = 0; row < grid.height; row++) {
for (int col = 0; col < grid.width; col++) { for (int col = 0; col < grid.width; col++) {
final gem = grid.getGem(row, col); final gem = grid.getGem(row, col);
if (gem == null || matches.contains(gem)) { if (gem == null) {
continue; continue;
} }
currentMatch.add(gem); currentMatch.add(gem);

View File

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flame/components.dart'; import 'package:flame/components.dart';
import 'package:flame/effects.dart'; import 'package:flame/effects.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -38,17 +40,23 @@ class PhysicsSystem {
await Future.delayed(Duration(milliseconds: (fallTime * 1000).toInt())); await Future.delayed(Duration(milliseconds: (fallTime * 1000).toInt()));
} }
static void animateMatch(GemComponent gem) { static Future<void> animateMatch(GemComponent gem) async {
final scaleDone = Completer();
final opacityDone = Completer();
// Scale down and fade out // Scale down and fade out
gem.add(ScaleEffect.to( gem.add(ScaleEffect.to(
Vector2.zero(), Vector2.zero(),
EffectController(duration: GameConstants.matchDuration), EffectController(duration: GameConstants.matchDuration),
onComplete: () => scaleDone.complete(),
)); ));
gem.add(OpacityEffect.to( gem.add(OpacityEffect.to(
0.0, 0.0,
EffectController(duration: GameConstants.matchDuration), EffectController(duration: GameConstants.matchDuration),
onComplete: () => opacityDone.complete(),
)); ));
await Future.wait([scaleDone.future, opacityDone.future]);
} }
static void animatePop(GemComponent gem) { static void animatePop(GemComponent gem) {

View File

@ -19,6 +19,7 @@ class GameScreen extends StatefulWidget {
class _GameScreenState extends State<GameScreen> { class _GameScreenState extends State<GameScreen> {
late MatchThreeGame game; late MatchThreeGame game;
final bool isDebug = true;
@override @override
void initState() { void initState() {
@ -75,6 +76,16 @@ class _GameScreenState extends State<GameScreen> {
), ),
), ),
const SizedBox(height: 4), 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( Text(
'Score: ${state.score}', 'Score: ${state.score}',
style: const TextStyle( style: const TextStyle(