import 'dart:math' as math; import 'package:flame/components.dart'; import 'package:flame/game.dart'; import 'package:flutter/material.dart'; import '../audio/audio_manager.dart'; import '../game/game_controller.dart'; import '../game/levels.dart'; import '../game/music.dart'; import '../game/types.dart'; import 'memory_card_component.dart'; class MemoryMatchFlameGame extends FlameGame { MemoryMatchFlameGame({ required this.level, required this.onStateChanged, required this.onResult, }); LevelConfig level; final VoidCallback onStateChanged; final void Function(GameStatus status, int score) onResult; late GameController controller; bool _ready = false; bool get isReady => _ready; final Map _cardViews = {}; double _countdownAccumulator = 0; late final SpriteComponent _background; late final PositionComponent _boardRoot; bool pausedByUser = false; bool showResult = false; bool mutedMusic = false; bool mutedSfx = false; int timeLeft = 0; int bestScore = 0; String resultStatus = 'lost'; int finalScore = 0; bool _boardEntryAnimating = false; int _boardEntryToken = 0; final math.Random _entryRandom = math.Random(); int _lastMismatchToken = 0; int _lastStreak = 0; GameStatus _lastStatus = GameStatus.playing; String get timeText { final m = (timeLeft ~/ 60).toString().padLeft(2, '0'); final s = (timeLeft % 60).toString().padLeft(2, '0'); return '$m:$s'; } @override Color backgroundColor() => const Color(0x00000000); @override Future onLoad() async { await super.onLoad(); _background = SpriteComponent( sprite: await loadSprite('a5c6f3813d0d23b4042dd130542753cc.png'), size: size, position: Vector2.zero(), priority: -100, ); add(_background); _boardRoot = PositionComponent(anchor: Anchor.topLeft); add(_boardRoot); controller = GameController(level: level, allFaceIds: allFaceIds) ..addListener(_onGameChanged); timeLeft = level.timeSec ?? 120; _lastStatus = controller.status; _lastMismatchToken = controller.mismatch.token; _lastStreak = controller.streak; _ready = true; await AudioManager.instance.playBgm(); _syncBoard(rebuildAll: true, animateEntry: true); _startTicker(); } @override void onGameResize(Vector2 canvasSize) { super.onGameResize(canvasSize); if (!isLoaded) return; _background.size = canvasSize; _layoutBoard(); } @override void update(double dt) { super.update(dt); if (!_ready) return; if (showResult || pausedByUser || controller.status != GameStatus.playing) { return; } if (timeLeft <= 0) return; _countdownAccumulator += dt; while (_countdownAccumulator >= 1.0) { _countdownAccumulator -= 1.0; if (showResult || pausedByUser || controller.status != GameStatus.playing) { return; } if (timeLeft <= 0) return; timeLeft -= 1; onStateChanged(); if (timeLeft == 0 && !showResult && controller.status == GameStatus.playing) { _finishAsLose(); return; } } } void _startTicker() { _countdownAccumulator = 0; } Future onCardTapped(String cardId) async { if (!_ready) return; if (showResult || pausedByUser || _boardEntryAnimating || controller.status != GameStatus.playing) { return; } await AudioManager.instance.playSfx(SfxKey.click); controller.flip(cardId); } Future _onGameChanged() async { if (!_ready) return; if (controller.mismatch.token > _lastMismatchToken) { _lastMismatchToken = controller.mismatch.token; await AudioManager.instance.playSfx(SfxKey.mismatch); } if (controller.streak > _lastStreak) { _lastStreak = controller.streak; await AudioManager.instance.playSfx(SfxKey.match); } if (controller.streak == 0) { _lastStreak = 0; } if (controller.status != _lastStatus) { _lastStatus = controller.status; if (controller.status == GameStatus.won) { await AudioManager.instance.playSfx(SfxKey.win); } else if (controller.status == GameStatus.lost) { await AudioManager.instance.playSfx(SfxKey.lose); } } if (!showResult && !pausedByUser && controller.status == GameStatus.playing && level.maxMistakes != null && controller.mistakes >= level.maxMistakes!) { _finishAsLose(); } if (!showResult && controller.status == GameStatus.won) { final score = controller.score + timeLeft * 10; resultStatus = 'won'; finalScore = score; bestScore = math.max(bestScore, score); showResult = true; paused = true; onResult(GameStatus.won, score); } _syncBoard(); onStateChanged(); } void _finishAsLose() { resultStatus = 'lost'; finalScore = controller.score; bestScore = math.max(bestScore, controller.score); showResult = true; paused = true; onResult(GameStatus.lost, finalScore); } void _syncBoard({bool rebuildAll = false, bool animateEntry = false}) { if (!_ready) return; if (rebuildAll && animateEntry) { _boardEntryAnimating = true; } final disablePlay = showResult || controller.status != GameStatus.playing || pausedByUser || _boardEntryAnimating; if (rebuildAll) { _boardRoot.removeAll(_boardRoot.children.toList()); _cardViews.clear(); for (final card in controller.cards) { final component = MemoryCardComponent( model: card, frontAsset: 'faces/b1c14934b04bf12e674abfffcb87cf6e.png', backAsset: faceMap[card.faceId]!, disabled: disablePlay || card.isMatched, ); _cardViews[card.id] = component; _boardRoot.add(component); } _layoutBoard(animateEntry: animateEntry); return; } for (final card in controller.cards) { _cardViews[card.id]?.updateFromModel( card, disablePlay: disablePlay, shouldShake: controller.mismatch.ids.contains(card.id), shakeToken: controller.mismatch.token, ); } } void _layoutBoard({bool animateEntry = false}) { if (_cardViews.isEmpty || size.x <= 0 || size.y <= 0) return; final topReserve = size.y * 0.30; final bottomReserve = size.y * 0.18; final horizontalPadding = 18.0; final rows = level.rowPattern.length; final widest = level.rowPattern.reduce((a, b) => a > b ? a : b); final isTabletWide = size.x >= 768; final gap = isTabletWide ? 10.0 : 12.0; final usableWidth = size.x - horizontalPadding * 2; final usableHeight = size.y - topReserve - bottomReserve; final cellByWidth = (usableWidth - gap * (widest - 1)) / widest; final cellByHeight = (usableHeight - gap * (rows - 1)) / rows; final cell = math .min(cellByWidth, cellByHeight) .clamp(38.0, isTabletWide ? 120.0 : 110.0); final gridWidth = widest * cell + (widest - 1) * gap; final gridHeight = rows * cell + (rows - 1) * gap; final boardLeft = (size.x - gridWidth) / 2; final boardTop = topReserve + (usableHeight - gridHeight) / 2; _boardRoot.position = Vector2(boardLeft, boardTop); _boardRoot.size = Vector2(gridWidth, gridHeight); int cardIndex = 0; final cardLayouts = <({ MemoryCardComponent view, Vector2 target, Vector2 start, int row, int indexInRow, })>[]; for (int row = 0; row < rows; row++) { final count = level.rowPattern[row]; final rowWidth = count * cell + (count - 1) * gap; final startX = (gridWidth - rowWidth) / 2; final y = row * (cell + gap); for (int i = 0; i < count; i++) { final card = controller.cards[cardIndex++]; final view = _cardViews[card.id]!; final target = Vector2(startX + i * (cell + gap), y); view.updateCardSize(Vector2.all(cell)); cardLayouts.add(( view: view, target: target, start: _randomEntryStartPosition(target, gridWidth, gridHeight, cell), row: row, indexInRow: i, )); } } if (animateEntry) { _playBoardEntryAnimation(cardLayouts); return; } for (final layout in cardLayouts) { layout.view.position = layout.target; layout.view.resetEntranceVisual(); } } void _playBoardEntryAnimation( List< ({ MemoryCardComponent view, Vector2 target, Vector2 start, int row, int indexInRow, }) > cardLayouts, ) { _boardEntryToken += 1; final token = _boardEntryToken; const rowDelayStep = 0.12; const itemDelayStep = 0.035; const duration = 0.42; for (final layout in cardLayouts) { layout.view.playEntranceEffect( startPosition: layout.start, targetPosition: layout.target, delay: layout.row * rowDelayStep + layout.indexInRow * itemDelayStep, ); } onStateChanged(); final totalSeconds = (level.rowPattern.length - 1) * rowDelayStep + (widestRowCount() - 1) * itemDelayStep + duration + 0.04; Future.delayed( Duration(milliseconds: (totalSeconds * 1000).round()), () { if (!_ready || token != _boardEntryToken) return; _boardEntryAnimating = false; _syncBoard(); onStateChanged(); } ); } int widestRowCount() { return level.rowPattern.reduce((a, b) => a > b ? a : b); } Vector2 _randomEntryStartPosition( Vector2 target, double gridWidth, double gridHeight, double cell, ) { final side = _entryRandom.nextInt(4); final extraX = cell * (1.2 + _entryRandom.nextDouble() * 1.8); final extraY = cell * (1.0 + _entryRandom.nextDouble() * 2.2); switch (side) { case 0: return Vector2( -extraX, target.y - extraY - _entryRandom.nextDouble() * cell, ); case 1: return Vector2( gridWidth + extraX, target.y - extraY - _entryRandom.nextDouble() * cell, ); case 2: return Vector2( target.x + (_entryRandom.nextDouble() - 0.5) * cell * 1.8, -extraY, ); default: return Vector2( target.x + (_entryRandom.nextDouble() - 0.5) * cell * 1.6, gridHeight + extraY, ); } } Future restartSameLevel() async { if (!_ready) return; showResult = false; paused = false; pausedByUser = false; timeLeft = level.timeSec ?? 120; _countdownAccumulator = 0; controller.reset(); _lastStatus = controller.status; _lastMismatchToken = controller.mismatch.token; _lastStreak = controller.streak; _syncBoard(rebuildAll: true, animateEntry: true); _startTicker(); onStateChanged(); } Future nextLevel(LevelConfig next) async { if (!_ready) return; level = next; showResult = false; paused = false; pausedByUser = false; _countdownAccumulator = 0; controller.removeListener(_onGameChanged); controller.dispose(); controller = GameController(level: level, allFaceIds: allFaceIds) ..addListener(_onGameChanged); timeLeft = level.timeSec ?? 120; _lastStatus = controller.status; _lastMismatchToken = controller.mismatch.token; _lastStreak = controller.streak; _syncBoard(rebuildAll: true, animateEntry: true); _startTicker(); onStateChanged(); } Future reviveWithReward() async { if (!_ready) return 0; final sec = controller.reviveWithReward( baseSeconds: 30, penaltyPerMove: 10, rollbackMistakes: controller.mistakes, ); if (sec > 0) { timeLeft += sec; _countdownAccumulator = 0; showResult = false; paused = false; resultStatus = 'lost'; finalScore = 0; _syncBoard(); onStateChanged(); } return sec; } Future togglePause() async { if (!_ready) return; pausedByUser = !pausedByUser; paused = pausedByUser; onStateChanged(); } Future toggleMusic() async { mutedMusic = !mutedMusic; await AudioManager.instance.setBgmMuted(mutedMusic); onStateChanged(); } void toggleSfx() { mutedSfx = !mutedSfx; AudioManager.instance.setSfxMuted(mutedSfx); onStateChanged(); } @override void onRemove() { if (_ready) { controller.removeListener(_onGameChanged); controller.dispose(); } super.onRemove(); } }