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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
import 'dart:ui';
import 'package:flame/game.dart';
import 'package:match_three/game/components/gem_component.dart';
import 'package:match_three/game/systems/match_detector.dart';
import 'components/grid_component.dart';
import 'components/background_component.dart';
@ -51,23 +52,32 @@ class MatchThreeGame extends FlameGame {
selectedGem = gem;
return;
}
if (MatchDetector.isValidSwap(gridComponent!.gameGrid, selectedGem!.row,
selectedGem!.col, gem.row, gem.col)) {
var theGem = selectedGem;
selectedGem = null;
if (MatchDetector.isValidSwap(
gridComponent!.gameGrid, theGem!.row, theGem.col, gem.row, gem.col)) {
// Attempt swap
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(
selectedGem!.row,
selectedGem!.col,
theGem.row,
theGem.col,
gem.row,
gem.col,
));
} else {
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) {

View File

@ -45,7 +45,7 @@ class GameGrid {
return row >= 0 && row < height && col >= 0 && col < width;
}
clone() {
GameGrid clone() {
final clonedGrid = GameGrid(width: width, height: height);
for (int row = 0; row < _grid.length; row++) {
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 col = 0; col < grid.width; col++) {
final gem = grid.getGem(row, col);
if (gem == null || matches.contains(gem)) {
if (gem == null) {
continue;
}
currentMatch.add(gem);

View File

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flutter/material.dart';
@ -38,17 +40,23 @@ class PhysicsSystem {
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
gem.add(ScaleEffect.to(
Vector2.zero(),
EffectController(duration: GameConstants.matchDuration),
onComplete: () => scaleDone.complete(),
));
gem.add(OpacityEffect.to(
0.0,
EffectController(duration: GameConstants.matchDuration),
onComplete: () => opacityDone.complete(),
));
await Future.wait([scaleDone.future, opacityDone.future]);
}
static void animatePop(GemComponent gem) {

View File

@ -19,6 +19,7 @@ class GameScreen extends StatefulWidget {
class _GameScreenState extends State<GameScreen> {
late MatchThreeGame game;
final bool isDebug = true;
@override
void initState() {
@ -75,6 +76,16 @@ class _GameScreenState extends State<GameScreen> {
),
),
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(