Compare commits

...

2 Commits

Author SHA1 Message Date
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
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
16 changed files with 252 additions and 139 deletions

View File

@ -4,6 +4,8 @@
"id": 1,
"name": "Getting Started",
"description": "Learn the basics by reaching the target score",
"gridWidth": 8,
"gridHeight": 8,
"constraints": {
"targetScore": 1000
},
@ -23,6 +25,8 @@
"id": 2,
"name": "Move Master",
"description": "Reach the target score with limited moves",
"gridWidth": 7,
"gridHeight": 9,
"constraints": {
"targetScore": 1500,
"maxMoves": 20
@ -43,6 +47,8 @@
"id": 3,
"name": "Time Trial",
"description": "Beat the clock to reach your goal",
"gridWidth": 10,
"gridHeight": 6,
"constraints": {
"targetScore": 2000,
"timeLimit": 60
@ -63,6 +69,8 @@
"id": 4,
"name": "Color Focus",
"description": "Clear specific gem colors while reaching the score",
"gridWidth": 6,
"gridHeight": 10,
"constraints": {
"targetScore": 1800
},
@ -87,6 +95,8 @@
"id": 5,
"name": "Ultimate Challenge",
"description": "Master all skills in this combined challenge",
"gridWidth": 9,
"gridHeight": 8,
"constraints": {
"targetScore": 2500,
"maxMoves": 25

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;
}
}
@ -501,7 +516,7 @@ class GameBloc extends Bloc<GameEvent, GameState> {
return;
}
final grid = GameGrid();
final grid = GameGrid(width: level.gridWidth, height: level.gridHeight);
// Start timer if level has time limit
if (level.constraints.hasTimeLimit) {

View File

@ -24,7 +24,7 @@ class GemComponent extends RectangleComponent with TapCallbacks {
GemComponent({required this.gem, required this.gridComponent})
: super(
size: Vector2.all(GameConstants.gemSize - 4),
size: Vector2.all(gridComponent.gemSize - 4),
paint: Paint()..color = gemColors[gem.type % gemColors.length],
);
@ -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

@ -12,16 +12,22 @@ class GridComponent extends PositionComponent
late GameGrid gameGrid;
final List<List<GemComponent?>> gemComponents = [];
final MatchThreeGame game;
int? levelId;
final int levelId;
bool canInterract = false;
double gemSize = GameConstants.gemSize;
final int gridWidth; // Default grid width
final int gridHeight; // Default grid height
GridComponent(this.game, this.levelId) : super();
GridComponent(this.game, this.levelId, this.gridHeight, this.gridWidth)
: super();
@override
Future<void> onLoad() async {
// 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));
// Calculate dynamic gem size based on available screen space
// For now, use a reasonable default size that will be updated when we have screen dimensions
gemSize =
GameConstants.calculateGemSize(gridWidth, gridHeight, 600.0, 800.0);
game.gameBloc.add(StartLevel(levelId));
}
void _createGemComponents() {
@ -39,15 +45,15 @@ class GridComponent extends PositionComponent
}
}
for (int row = 0; row < GameConstants.gridHeight; row++) {
for (int row = 0; row < gridHeight; row++) {
gemComponents.add(<GemComponent?>[]);
for (int col = 0; col < GameConstants.gridWidth; col++) {
for (int col = 0; col < gridWidth; col++) {
final gem = gameGrid.getGem(row, col);
if (gem != null) {
final gemComponent = GemComponent(gem: gem, gridComponent: this);
gemComponent.position = Vector2(
col * GameConstants.gemSize + GameConstants.gridPadding,
row * GameConstants.gemSize + GameConstants.gridPadding,
col * gemSize + GameConstants.gridPadding,
row * gemSize + GameConstants.gridPadding,
);
gemComponents[row].add(gemComponent);
add(gemComponent);
@ -88,13 +94,12 @@ class GridComponent extends PositionComponent
}
// Update grid and create components for all GameLevelPlaying states
final levelState = state as GameLevelPlaying;
gameGrid = levelState.grid;
gameGrid = state.grid;
gameGrid.printGrid();
_createGemComponents();
await Future.delayed(const Duration(milliseconds: 100));
print("Updated state ${state.runtimeType.toString()}");
levelState.done();
state.done();
}
GemComponent? _findGemComponent(Gem gem) {
@ -137,8 +142,8 @@ class GridComponent extends PositionComponent
_animateDrop(GameGrid newGrid) async {
final effects = <Future>[];
for (int col = GameConstants.gridWidth - 1; col >= 0; col--) {
for (int row = GameConstants.gridHeight - 1; row >= 0; row--) {
for (int col = gridWidth - 1; col >= 0; col--) {
for (int row = gridHeight - 1; row >= 0; row--) {
final gem = gameGrid.getGem(row, col);
// if gem is removed - animate fall effect from top
@ -149,8 +154,8 @@ class GridComponent extends PositionComponent
final gemComponent = gemComponents[row][col];
if (gemAbove != null) {
final targetPosition = Vector2(
col * GameConstants.gemSize + GameConstants.gridPadding,
fallToRow * GameConstants.gemSize + GameConstants.gridPadding,
col * gemSize + GameConstants.gridPadding,
fallToRow * gemSize + GameConstants.gridPadding,
);
print(
"@@ Fall $row, $col -> $fallToRow, $col ${gemComponent == null ? "NULL" : "OK"}");
@ -172,12 +177,12 @@ class GridComponent extends PositionComponent
// Create gem component from gem and animate falling effect to proper location
final gemComponent = GemComponent(gem: gem, gridComponent: this);
final targetPosition = Vector2(
gem.col * GameConstants.gemSize + GameConstants.gridPadding,
gem.row * GameConstants.gemSize + GameConstants.gridPadding,
gem.col * gemSize + GameConstants.gridPadding,
gem.row * gemSize + GameConstants.gridPadding,
);
gemComponent.position.x = targetPosition.x;
gemComponent.position.y = -GameConstants.gemSize;
gemComponent.position.y = -gemSize;
add(gemComponent);
effects.add(gemComponent.animateFall(targetPosition));
}

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';
@ -12,18 +13,24 @@ const gridSize = 8 * 64.0 + 32.0; // 8 gems * 64px + padding
class MatchThreeGame extends FlameGame {
late BackgroundComponent backgroundComponent;
late GameBloc gameBloc;
final int? levelId;
final int levelId;
final int gridHeight;
final int gridWidth;
GridComponent? gridComponent;
Gem? selectedGem;
MatchThreeGame(this.levelId) : super();
MatchThreeGame(
{required this.levelId,
required this.gridHeight,
required this.gridWidth})
: super();
@override
Future<void> onLoad() async {
backgroundComponent = BackgroundComponent();
add(backgroundComponent);
gridComponent = GridComponent(this, levelId);
gridComponent = GridComponent(this, levelId, gridHeight, gridWidth);
add(gridComponent!);
}
@ -45,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

@ -5,16 +5,18 @@ import '../../utils/constants.dart';
class GameGrid {
late List<List<Gem?>> _grid;
final Random _random = Random();
final int width;
final int height;
GameGrid() {
GameGrid({required this.width, required this.height}) {
_initializeGrid();
}
void _initializeGrid() {
_grid = List.generate(
GameConstants.gridHeight,
height,
(row) => List.generate(
GameConstants.gridWidth,
width,
(col) => Gem(
type: _random.nextInt(GameConstants.gemTypes.length),
row: row,
@ -25,20 +27,14 @@ class GameGrid {
}
Gem? getGem(int row, int col) {
if (row < 0 ||
row >= GameConstants.gridHeight ||
col < 0 ||
col >= GameConstants.gridWidth) {
if (row < 0 || row >= height || col < 0 || col >= width) {
return null;
}
return _grid[row][col];
}
void setGem(int row, int col, Gem? gem) {
if (row >= 0 &&
row < GameConstants.gridHeight &&
col >= 0 &&
col < GameConstants.gridWidth) {
if (row >= 0 && row < height && col >= 0 && col < width) {
_grid[row][col] = gem;
}
}
@ -46,14 +42,11 @@ class GameGrid {
List<List<Gem?>> get grid => _grid;
bool isValidPosition(int row, int col) {
return row >= 0 &&
row < GameConstants.gridHeight &&
col >= 0 &&
col < GameConstants.gridWidth;
return row >= 0 && row < height && col >= 0 && col < width;
}
clone() {
final clonedGrid = GameGrid();
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++) {
final gem = getGem(row, col);

View File

@ -18,6 +18,8 @@ class Level extends Equatable {
final StarRating starRating;
final List<int> availableGemTypes;
final bool unlocked;
final int gridWidth;
final int gridHeight;
const Level({
required this.id,
@ -28,6 +30,9 @@ class Level extends Equatable {
required this.starRating,
required this.availableGemTypes,
this.unlocked = false,
this.gridWidth =
9, // Default to current grid size for backward compatibility
this.gridHeight = 8,
});
factory Level.fromJson(Map<String, dynamic> json) {
@ -40,6 +45,8 @@ class Level extends Equatable {
starRating: StarRating.fromJson(json['starRating']),
availableGemTypes: List<int>.from(json['availableGemTypes']),
unlocked: json['unlocked'] ?? false,
gridWidth: json['gridWidth'] ?? 9, // Default for backward compatibility
gridHeight: json['gridHeight'] ?? 8,
);
}
@ -53,6 +60,8 @@ class Level extends Equatable {
'starRating': starRating.toJson(),
'availableGemTypes': availableGemTypes,
'unlocked': unlocked,
'gridWidth': gridWidth,
'gridHeight': gridHeight,
};
}
@ -65,6 +74,8 @@ class Level extends Equatable {
StarRating? starRating,
List<int>? availableGemTypes,
bool? unlocked,
int? gridWidth,
int? gridHeight,
}) {
return Level(
id: id ?? this.id,
@ -75,6 +86,8 @@ class Level extends Equatable {
starRating: starRating ?? this.starRating,
availableGemTypes: availableGemTypes ?? this.availableGemTypes,
unlocked: unlocked ?? this.unlocked,
gridWidth: gridWidth ?? this.gridWidth,
gridHeight: gridHeight ?? this.gridHeight,
);
}
@ -88,6 +101,8 @@ class Level extends Equatable {
starRating,
availableGemTypes,
unlocked,
gridWidth,
gridHeight,
];
}

View File

@ -5,7 +5,7 @@ import '../../utils/constants.dart';
class GravitySystem {
static void applyGravity(GameGrid grid) {
for (int col = 0; col < GameConstants.gridWidth; col++) {
for (int col = 0; col < grid.width; col++) {
_dropColumn(grid, col);
}
}
@ -14,8 +14,8 @@ class GravitySystem {
// Fill empty spaces with new gems
final random = Random();
final List<Gem> newGems = [];
for (int row = 0; row < GameConstants.gridHeight; row++) {
for (int col = 0; col < GameConstants.gridWidth; col++) {
for (int row = 0; row < grid.height; row++) {
for (int col = 0; col < grid.width; col++) {
if (grid.getGem(row, col) == null) {
final gem = Gem(
row: row,
@ -34,7 +34,7 @@ class GravitySystem {
final gems = <Gem>[];
// Collect non-null gems from bottom to top
for (int row = GameConstants.gridHeight - 1; row >= 0; row--) {
for (int row = grid.height - 1; row >= 0; row--) {
final gem = grid.getGem(row, col);
if (gem != null) {
gems.add(gem);
@ -42,13 +42,13 @@ class GravitySystem {
}
// Clear column
for (int row = 0; row < GameConstants.gridHeight; row++) {
for (int row = 0; row < grid.height; row++) {
grid.setGem(row, col, null);
}
// Place gems at bottom
for (int i = 0; i < gems.length; i++) {
final newRow = GameConstants.gridHeight - 1 - i;
final newRow = grid.height - 1 - i;
grid.setGem(newRow, col, gems[i].copyWith(row: newRow, col: col));
}
}

View File

@ -8,14 +8,14 @@ class MatchDetector {
// Check matches
List<Gem> currentMatch = [];
for (int row = 0; row < GameConstants.gridHeight; row++) {
for (int col = 0; col < GameConstants.gridWidth; col++) {
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);
for (int i = row + 1; i < GameConstants.gridHeight; i++) {
for (int i = row + 1; i < grid.height; i++) {
final nextGem = grid.getGem(i, col);
if (nextGem == null || nextGem.type != gem.type) {
break;
@ -27,7 +27,7 @@ class MatchDetector {
}
currentMatch.clear();
currentMatch.add(gem);
for (int i = col + 1; i < GameConstants.gridWidth; i++) {
for (int i = col + 1; i < grid.width; i++) {
final nextGem = grid.getGem(row, i);
if (nextGem == null || nextGem.type != gem.type) {
break;

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

@ -5,11 +5,13 @@ import 'package:match_three/game/models/gem.dart';
import '../game/match_three_game.dart';
import '../bloc/game_bloc.dart';
// ignore: must_be_immutable
class GameScreen extends StatefulWidget {
int? levelId;
final int levelId;
final int gridHeight;
final int gridWidth;
GameScreen({super.key, this.levelId});
const GameScreen(
{super.key, this.levelId = 0, this.gridHeight = 5, this.gridWidth = 5});
@override
State<GameScreen> createState() => _GameScreenState();
@ -17,11 +19,15 @@ class GameScreen extends StatefulWidget {
class _GameScreenState extends State<GameScreen> {
late MatchThreeGame game;
final bool isDebug = true;
@override
void initState() {
super.initState();
game = MatchThreeGame(widget.levelId);
game = MatchThreeGame(
levelId: widget.levelId,
gridHeight: widget.gridHeight,
gridWidth: widget.gridWidth);
}
@override
@ -70,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(

View File

@ -164,7 +164,7 @@ class _LevelSelectionScreenState extends State<LevelSelectionScreen> {
final isCompleted = levelWithProgress.isCompleted;
return GestureDetector(
onTap: isUnlocked ? () => _startLevel(level.id) : null,
onTap: isUnlocked ? () => _startLevel(level) : null,
child: Container(
decoration: BoxDecoration(
color: isUnlocked ? Colors.white : Colors.grey.shade400,
@ -291,9 +291,9 @@ class _LevelSelectionScreenState extends State<LevelSelectionScreen> {
);
}
void _startLevel(int levelId) {
void _startLevel(Level level) {
// Start the level first
print("Starting Level $levelId");
print("Starting Level ${level.id}");
// Then navigate to game screen
Navigator.push(
@ -301,7 +301,11 @@ class _LevelSelectionScreenState extends State<LevelSelectionScreen> {
MaterialPageRoute(
builder: (context) => BlocProvider.value(
value: context.read<GameBloc>(),
child: GameScreen(levelId: levelId),
child: GameScreen(
levelId: level.id,
gridHeight: level.gridHeight,
gridWidth: level.gridWidth,
),
),
),
).then((_) {

View File

@ -20,6 +20,8 @@ class LevelService {
id: freePlayLevelId,
name: 'Free Play',
description: 'Play without constraints - match gems and score points!',
gridWidth: 5,
gridHeight: 5,
constraints: LevelConstraints(), // No constraints
objectives: LevelObjectives(), // No objectives
starRating: StarRating(

View File

@ -1,6 +1,6 @@
import 'dart:math' as math;
class GameConstants {
static const int gridWidth = 9;
static const int gridHeight = 8;
static const double gemSize = 64.0;
static const double gridPadding = 16.0;
static const int minMatchLength = 3;
@ -16,4 +16,18 @@ class GameConstants {
// Scoring
static const int baseScore = 100;
static const int comboMultiplier = 50;
// Dynamic gem size calculation
static double calculateGemSize(int gridWidth, int gridHeight,
double availableWidth, double availableHeight) {
// Calculate gem size based on available space and grid dimensions
final gemSizeByWidth = (availableWidth - (2 * gridPadding)) / gridWidth;
final gemSizeByHeight = (availableHeight - (2 * gridPadding)) / gridHeight;
// Use the smaller dimension to ensure the grid fits in both directions
final calculatedSize = math.min(gemSizeByWidth, gemSizeByHeight);
// Ensure minimum size for playability and maximum size for performance
return math.max(32.0, math.min(calculatedSize, 80.0));
}
}

View File

@ -6,7 +6,7 @@ import 'package:match_three/game/systems/match_detector.dart';
void main() {
group('Match Three Game Tests', () {
test('Grid initialization creates 8x8 grid', () {
final grid = GameGrid();
final grid = GameGrid(width: 8, height: 8);
for (int row = 0; row < 8; row++) {
for (int col = 0; col < 8; col++) {
@ -15,8 +15,22 @@ void main() {
}
});
test('Grid initialization creates custom size grid', () {
final grid = GameGrid(width: 5, height: 5);
for (int row = 0; row < 5; row++) {
for (int col = 0; col < 5; col++) {
expect(grid.getGem(row, col), isNotNull);
}
}
// Test that positions outside the grid return null
expect(grid.getGem(5, 0), isNull);
expect(grid.getGem(0, 5), isNull);
});
test('Match detector validates adjacent positions', () {
final grid = GameGrid();
final grid = GameGrid(width: 8, height: 8);
// Test adjacent positions (should be valid)
expect(MatchDetector.isValidSwap(grid, 0, 0, 0, 1), isTrue);