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

View File

@ -279,20 +279,78 @@ 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) {
newGrid.setGem(event.row1, event.col1, print("Gem1 or Gem2 is null, that should not be so");
gem2.copyWith(row: event.row1, col: event.col1)); return;
newGrid.setGem(event.row2, event.col2, }
gem1.copyWith(row: event.row2, col: event.col2)); 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 // Emit state for swap animation
var done = Completer();
emit(GameLevelSwap(
grid: newGrid,
level: currentState.level,
score: currentState.score,
moves: currentState.moves,
timeElapsed: currentState.timeElapsed,
gemsCleared: currentState.gemsCleared,
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) {
final newMoves = currentState.moves + 1;
// Check if move limit exceeded
if (currentState.level.constraints.hasMoveLimit &&
newMoves >= currentState.level.constraints.maxMoves!) {
// This is the last move, check if level can be completed
final updatedGemsCleared =
_updateGemsCleared(currentState.gemsCleared, matches);
if (!_levelService.isLevelCompleted(
currentState.level,
currentState.score,
newMoves,
currentState.timeElapsed,
updatedGemsCleared,
)) {
add(FailLevel());
return;
}
}
emit(_GameLevelPlayingMatch(
grid: newGrid,
level: currentState.level,
score: currentState.score,
moves: newMoves,
timeElapsed: currentState.timeElapsed,
gemsCleared: currentState.gemsCleared,
matches: matches,
));
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(); var done = Completer();
emit(GameLevelSwap( emit(GameLevelSwap(
grid: newGrid, grid: newGrid,
level: currentState.level, level: currentState.level,
@ -300,66 +358,23 @@ class GameBloc extends Bloc<GameEvent, GameState> {
moves: currentState.moves, moves: currentState.moves,
timeElapsed: currentState.timeElapsed, timeElapsed: currentState.timeElapsed,
gemsCleared: currentState.gemsCleared, gemsCleared: currentState.gemsCleared,
gem1: gem1, gem1: gem2,
gem2: gem2, gem2: gem1,
done: done.complete, done: done.complete,
)); ));
// Wait for swap-back animation
// Wait for swap animation
await done.future; await done.future;
// Check for matches done = Completer();
final matches = MatchDetector.findMatches(newGrid); emit(GameLevelIdle(
if (matches.isNotEmpty) { grid: newGrid,
final newMoves = currentState.moves + 1; level: currentState.level,
score: currentState.score,
// Check if move limit exceeded moves: currentState.moves,
if (currentState.level.constraints.hasMoveLimit && timeElapsed: currentState.timeElapsed,
newMoves >= currentState.level.constraints.maxMoves!) { gemsCleared: currentState.gemsCleared,
// This is the last move, check if level can be completed done: done.complete,
final updatedGemsCleared = ));
_updateGemsCleared(currentState.gemsCleared, matches);
if (!_levelService.isLevelCompleted(
currentState.level,
currentState.score,
newMoves,
currentState.timeElapsed,
updatedGemsCleared,
)) {
add(FailLevel());
return;
}
}
emit(_GameLevelPlayingMatch(
grid: newGrid,
level: currentState.level,
score: currentState.score,
moves: newMoves,
timeElapsed: currentState.timeElapsed,
gemsCleared: currentState.gemsCleared,
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(GameLevelSwap(
grid: newGrid,
level: currentState.level,
score: currentState.score,
moves: currentState.moves,
timeElapsed: currentState.timeElapsed,
gemsCleared: currentState.gemsCleared,
gem1: gem2,
gem2: gem1,
done: done.complete,
));
}
// Wait for swap animation
await done.future; await done.future;
} }
} }
@ -501,7 +516,7 @@ class GameBloc extends Bloc<GameEvent, GameState> {
return; return;
} }
final grid = GameGrid(); final grid = GameGrid(width: level.gridWidth, height: level.gridHeight);
// Start timer if level has time limit // Start timer if level has time limit
if (level.constraints.hasTimeLimit) { if (level.constraints.hasTimeLimit) {

View File

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

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
import 'dart:math' as math;
class GameConstants { class GameConstants {
static const int gridWidth = 9;
static const int gridHeight = 8;
static const double gemSize = 64.0; static const double gemSize = 64.0;
static const double gridPadding = 16.0; static const double gridPadding = 16.0;
static const int minMatchLength = 3; static const int minMatchLength = 3;
@ -16,4 +16,18 @@ class GameConstants {
// Scoring // Scoring
static const int baseScore = 100; static const int baseScore = 100;
static const int comboMultiplier = 50; 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() { void main() {
group('Match Three Game Tests', () { group('Match Three Game Tests', () {
test('Grid initialization creates 8x8 grid', () { 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 row = 0; row < 8; row++) {
for (int col = 0; col < 8; col++) { 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', () { test('Match detector validates adjacent positions', () {
final grid = GameGrid(); final grid = GameGrid(width: 8, height: 8);
// Test adjacent positions (should be valid) // Test adjacent positions (should be valid)
expect(MatchDetector.isValidSwap(grid, 0, 0, 0, 1), isTrue); expect(MatchDetector.isValidSwap(grid, 0, 0, 0, 1), isTrue);