// =========================================================================== // FILE: lib/logic/game_controller.dart // =========================================================================== import 'dart:async'; import 'dart:math'; import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../models/game_board.dart'; export '../models/game_board.dart'; import 'ai_engine.dart'; import '../services/audio_service.dart'; import '../services/storage_service.dart'; import '../services/multiplayer_service.dart'; import '../core/app_colors.dart'; class CpuMatchSetup { final int radius; final ArenaShape shape; CpuMatchSetup(this.radius, this.shape); } class GameController extends ChangeNotifier { late GameBoard board; bool isVsCPU = false; bool isCPUThinking = false; bool isOnline = false; String? roomCode; bool isHost = false; StreamSubscription? _onlineSubscription; bool opponentLeft = false; bool _hasSavedResult = false; Timer? _blitzTimer; int timeLeft = 15; final int maxTime = 15; bool isTimeMode = true; String effectText = ''; Color effectColor = Colors.transparent; Timer? _effectTimer; String? myReaction; String? opponentReaction; Timer? _myReactionTimer; Timer? _oppReactionTimer; Timestamp? _lastOpponentReactionTime; bool rematchRequested = false; bool opponentWantsRematch = false; int lastMatchXP = 0; // --- NUOVA ROADMAP DINAMICA DEGLI SBLOCCHI --- // Aggiungi qui le tue future meccaniche! Il popup le leggerà in automatico. static const Map>> rewardsRoadmap = { 2: [{'title': 'Bomba & Oro', 'desc': 'Appaiono le caselle speciali: Oro (+2) e Bomba (-1)!', 'icon': Icons.stars, 'color': Colors.amber}], 3: [ {'title': 'Tema Cyberpunk', 'desc': 'Sbloccato un nuovo tema visivo nelle impostazioni.', 'icon': Icons.palette, 'color': Colors.tealAccent}, {'title': 'Arena a Croce', 'desc': 'Sbloccata una nuova forma arena più complessa.', 'icon': Icons.add_box, 'color': Colors.blueAccent} ], 5: [{'title': 'Scambio', 'desc': 'Nuova casella! Inverte istantaneamente i punteggi.', 'icon': Icons.swap_horiz, 'color': Colors.purpleAccent}], 7: [ {'title': 'Tema 8-Bit', 'desc': 'Sbloccato il nostalgico tema sala giochi.', 'icon': Icons.videogame_asset, 'color': Colors.greenAccent}, {'title': 'Arene Caos', 'desc': 'Generazione procedurale sbloccata. Nessuna partita sarà uguale!', 'icon': Icons.all_inclusive, 'color': Colors.redAccent} ], 10: [ {'title': 'Tema Grimorio', 'desc': 'Sbloccato il tema della magia antica.', 'icon': Icons.auto_stories, 'color': Colors.deepPurpleAccent}, {'title': 'Blocco di Ghiaccio', 'desc': 'Nuova meccanica! Il ghiaccio richiede due colpi per rompersi.', 'icon': Icons.ac_unit, 'color': Colors.cyanAccent} ], 15: [ {'title': 'Tema Musica', 'desc': 'Sbloccato il tema a tempo di beat.', 'icon': Icons.headphones, 'color': Colors.pinkAccent}, {'title': 'Moltiplicatore x2', 'desc': 'Nuova casella! Raddoppia i punti della tua prossima conquista.', 'icon': Icons.bolt, 'color': Colors.yellowAccent} ], }; bool hasLeveledUp = false; int newlyReachedLevel = 1; List> unlockedRewards = []; // Ora è una lista di mappe dinamiche bool isSetupPhase = true; bool myJokerPlaced = false; bool oppJokerPlaced = false; Player jokerTurn = Player.red; Player get myPlayer => isOnline ? (isHost ? Player.red : Player.blue) : Player.red; bool get isGameOver => board.isGameOver; int cpuLevel = 1; int currentMatchLevel = 1; int? currentSeed; AppThemeType _activeTheme = AppThemeType.doodle; String onlineHostName = "ROSSO"; String onlineGuestName = "BLU"; ArenaShape onlineShape = ArenaShape.classic; GameController({int radius = 3}) { cpuLevel = StorageService.instance.cpuLevel; startNewGame(radius); } CpuMatchSetup _getSetupForCpuLevel(int level) { final rand = Random(); if (level == 1) return CpuMatchSetup(3, ArenaShape.classic); if (level == 2) return CpuMatchSetup(4, ArenaShape.classic); if (level == 3) return CpuMatchSetup(4, ArenaShape.cross); if (level == 4) return CpuMatchSetup(4, ArenaShape.donut); if (level == 5) return CpuMatchSetup(5, ArenaShape.classic); if (level == 6) return CpuMatchSetup(4, ArenaShape.hourglass); if (level == 7) return CpuMatchSetup(5, ArenaShape.cross); if (level == 8) return CpuMatchSetup(5, ArenaShape.donut); if (level == 9) return CpuMatchSetup(5, ArenaShape.hourglass); List hardShapes = [ArenaShape.classic, ArenaShape.cross, ArenaShape.donut, ArenaShape.hourglass, ArenaShape.chaos]; ArenaShape chosenShape = hardShapes[rand.nextInt(hardShapes.length)]; int chosenRadius = (chosenShape == ArenaShape.chaos) ? (rand.nextInt(2) + 4) : (rand.nextInt(2) + 5); return CpuMatchSetup(chosenRadius, chosenShape); } void startNewGame(int radius, {bool vsCPU = false, bool isOnline = false, String? roomCode, bool isHost = false, ArenaShape shape = ArenaShape.classic, bool timeMode = true}) { _onlineSubscription?.cancel(); _onlineSubscription = null; _blitzTimer?.cancel(); _effectTimer?.cancel(); effectText = ''; _hasSavedResult = false; lastMatchXP = 0; hasLeveledUp = false; unlockedRewards.clear(); myReaction = null; opponentReaction = null; _lastOpponentReactionTime = null; rematchRequested = false; opponentWantsRematch = false; isSetupPhase = true; myJokerPlaced = false; oppJokerPlaced = false; jokerTurn = Player.red; this.isVsCPU = vsCPU; this.isOnline = isOnline; this.roomCode = roomCode; this.isHost = isHost; this.isTimeMode = timeMode; int finalRadius = radius; ArenaShape finalShape = shape; if (this.isVsCPU) { CpuMatchSetup setup = _getSetupForCpuLevel(cpuLevel); finalRadius = setup.radius; finalShape = setup.shape; } onlineShape = finalShape; int levelToUse = isOnline ? (currentMatchLevel == 1 ? 2 : currentMatchLevel) : cpuLevel; board = GameBoard(radius: finalRadius, level: levelToUse, seed: currentSeed, shape: finalShape); board.currentPlayer = Player.red; isCPUThinking = false; opponentLeft = false; if (this.isOnline && this.roomCode != null) { _listenToOnlineGame(this.roomCode!); } notifyListeners(); } void placeJoker(int bx, int by) { if (!isSetupPhase) return; Box? target; try { target = board.boxes.firstWhere((b) => b.x == bx && b.y == by); } catch(e) {} if (target == null || target.type == BoxType.invisible || target.hiddenJokerOwner != null) return; AudioService.instance.playLineSfx(_activeTheme); if (isOnline) { if (myJokerPlaced) return; target.hiddenJokerOwner = myPlayer; myJokerPlaced = true; String prefix = isHost ? 'p1' : 'p2'; FirebaseFirestore.instance.collection('games').doc(roomCode).update({ '${prefix}_joker': {'x': bx, 'y': by} }); } else { target.hiddenJokerOwner = jokerTurn; if (jokerTurn == Player.red) { jokerTurn = Player.blue; if (isVsCPU) { _placeCpuJoker(); } } else { jokerTurn = Player.red; } } notifyListeners(); _checkSetupComplete(); } void _placeCpuJoker() { var validBoxes = board.boxes.where((b) => b.type != BoxType.invisible && b.hiddenJokerOwner == null).toList(); if (validBoxes.isNotEmpty) { var b = validBoxes[Random().nextInt(validBoxes.length)]; b.hiddenJokerOwner = Player.blue; } jokerTurn = Player.red; _checkSetupComplete(); } void _checkSetupComplete() { if (isOnline) { if (myJokerPlaced && oppJokerPlaced) { isSetupPhase = false; _startTimer(); } } else { if (jokerTurn == Player.red) { isSetupPhase = false; _startTimer(); } } notifyListeners(); } void sendReaction(String reaction) { if (!isOnline || roomCode == null) return; MultiplayerService().sendReaction(roomCode!, isHost, reaction); _showReaction(true, reaction); } void requestRematch() { if (!isOnline || roomCode == null) return; rematchRequested = true; notifyListeners(); MultiplayerService().requestRematch(roomCode!, isHost); } void _showReaction(bool isMe, String reaction) { if (isMe) { myReaction = reaction; _myReactionTimer?.cancel(); _myReactionTimer = Timer(const Duration(seconds: 4), () { myReaction = null; notifyListeners(); }); } else { opponentReaction = reaction; _oppReactionTimer?.cancel(); _oppReactionTimer = Timer(const Duration(seconds: 4), () { opponentReaction = null; notifyListeners(); }); } notifyListeners(); } void triggerSpecialEffect(String text, Color color) { effectText = text; effectColor = color; notifyListeners(); _effectTimer?.cancel(); _effectTimer = Timer(const Duration(milliseconds: 1200), () { effectText = ''; notifyListeners(); }); } void _playEffects(List newClosed, {List newGhosts = const [], required bool isOpponent}) { if (newGhosts.isNotEmpty) { AudioService.instance.playBombSfx(); triggerSpecialEffect("TRAPPOLA!", Colors.grey.shade400); HapticFeedback.heavyImpact(); return; } bool isIceCracked = board.lastMove?.isIceCracked ?? false; if (isIceCracked) { AudioService.instance.playLineSfx(_activeTheme); triggerSpecialEffect("GHIACCIO INCRINATO!", Colors.cyanAccent); HapticFeedback.mediumImpact(); return; } if (newClosed.isEmpty) { AudioService.instance.playLineSfx(_activeTheme); if (!isOpponent) HapticFeedback.lightImpact(); } else { for (var b in newClosed) { if (b.isJokerRevealed) { if (b.owner == b.hiddenJokerOwner) { AudioService.instance.playBonusSfx(); triggerSpecialEffect("JOLLY! +2", Colors.greenAccent); } else { AudioService.instance.playBombSfx(); triggerSpecialEffect("JOLLY! -1", Colors.redAccent); } HapticFeedback.heavyImpact(); return; } } bool isGold = newClosed.any((b) => b.type == BoxType.gold); bool isBomb = newClosed.any((b) => b.type == BoxType.bomb); bool isSwap = newClosed.any((b) => b.type == BoxType.swap); bool isMultiplier = newClosed.any((b) => b.type == BoxType.multiplier); if (isSwap) { AudioService.instance.playBonusSfx(); triggerSpecialEffect("SCAMBIO!", Colors.purpleAccent); HapticFeedback.heavyImpact(); } else if (isMultiplier) { AudioService.instance.playBonusSfx(); triggerSpecialEffect("MOLTIPLICATORE x2!", Colors.yellowAccent); HapticFeedback.heavyImpact(); } else if (isGold) { AudioService.instance.playBonusSfx(); triggerSpecialEffect("+2", Colors.amber); HapticFeedback.heavyImpact(); } else if (isBomb) { AudioService.instance.playBombSfx(); triggerSpecialEffect("-1", Colors.redAccent); HapticFeedback.heavyImpact(); } else { AudioService.instance.playBoxSfx(_activeTheme); HapticFeedback.heavyImpact(); } } } void _startTimer() { _blitzTimer?.cancel(); if (isSetupPhase) return; timeLeft = maxTime; if (!isTimeMode) { notifyListeners(); return; } _blitzTimer = Timer.periodic(const Duration(seconds: 1), (timer) { if (isGameOver || isCPUThinking) { timer.cancel(); return; } if (timeLeft > 0) { timeLeft--; notifyListeners(); } else { timer.cancel(); if (!isOnline || board.currentPlayer == myPlayer) { _handleTimeOut(); } } }); } void _handleTimeOut() { if (!isTimeMode || isSetupPhase) return; if (isOnline && board.currentPlayer != myPlayer) return; List availableLines = board.lines.where((l) => l.owner == Player.none && l.isPlayable).toList(); if (availableLines.isEmpty) return; final random = Random(); Line randomMove = availableLines[random.nextInt(availableLines.length)]; handleLineTap(randomMove, _activeTheme, forced: true); } void disconnectOnlineGame() { _onlineSubscription?.cancel(); _onlineSubscription = null; _blitzTimer?.cancel(); _effectTimer?.cancel(); _myReactionTimer?.cancel(); _oppReactionTimer?.cancel(); _lastOpponentReactionTime = null; if (isOnline && roomCode != null) { FirebaseFirestore.instance.collection('games').doc(roomCode).update({'status': 'abandoned'}).catchError((e) => null); } isOnline = false; roomCode = null; currentMatchLevel = 1; currentSeed = null; } @override void dispose() { disconnectOnlineGame(); super.dispose(); } void _listenToOnlineGame(String code) { _onlineSubscription = FirebaseFirestore.instance.collection('games').doc(code).snapshots().listen((doc) { if (!doc.exists) return; var data = doc.data() as Map; onlineHostName = data['hostName'] ?? "ROSSO"; onlineGuestName = (data['guestName'] != null && data['guestName'] != '') ? data['guestName'] : "BLU"; if (data['status'] == 'abandoned' && !board.isGameOver && !opponentLeft) { opponentLeft = true; notifyListeners(); return; } String? p1React = data['p1_reaction']; Timestamp? p1Time = data['p1_reaction_time'] as Timestamp?; String? p2React = data['p2_reaction']; Timestamp? p2Time = data['p2_reaction_time'] as Timestamp?; if (isHost && p2React != null && p2Time != null && p2Time != _lastOpponentReactionTime) { _lastOpponentReactionTime = p2Time; _showReaction(false, p2React); } else if (!isHost && p1React != null && p1Time != null && p1Time != _lastOpponentReactionTime) { _lastOpponentReactionTime = p1Time; _showReaction(false, p1React); } bool p1Rematch = data['p1_rematch'] ?? false; bool p2Rematch = data['p2_rematch'] ?? false; opponentWantsRematch = isHost ? p2Rematch : p1Rematch; if (data['status'] == 'playing' && (data['moves'] as List).isEmpty && rematchRequested) { currentSeed = data['seed']; startNewGame(data['radius'], isOnline: true, roomCode: roomCode, isHost: isHost, shape: ArenaShape.values.firstWhere((e) => e.name == data['shape']), timeMode: data['timeMode']); return; } if (p1Rematch && p2Rematch && isHost && data['status'] != 'playing') { currentMatchLevel++; int newSeed = DateTime.now().millisecondsSinceEpoch % 1000000; final rand = Random(); int newRadius = rand.nextInt(4) + 3; ArenaShape newShape = ArenaShape.values[rand.nextInt(ArenaShape.values.length)]; MultiplayerService().resetMatch(roomCode!, newRadius, newShape.name, newSeed); } if (isSetupPhase) { if (!isHost && data['p1_joker'] != null && !oppJokerPlaced) { int jx = data['p1_joker']['x']; int jy = data['p1_joker']['y']; board.boxes.firstWhere((b) => b.x == jx && b.y == jy).hiddenJokerOwner = Player.red; oppJokerPlaced = true; _checkSetupComplete(); } if (isHost && data['p2_joker'] != null && !oppJokerPlaced) { int jx = data['p2_joker']['x']; int jy = data['p2_joker']['y']; board.boxes.firstWhere((b) => b.x == jx && b.y == jy).hiddenJokerOwner = Player.blue; oppJokerPlaced = true; _checkSetupComplete(); } } List moves = data['moves'] ?? []; int hostLevel = data['matchLevel'] ?? 1; int? hostSeed = data['seed']; int hostRadius = data['radius'] ?? board.radius; String shapeStr = data['shape'] ?? 'classic'; ArenaShape hostShape = ArenaShape.values.firstWhere((e) => e.name == shapeStr, orElse: () => ArenaShape.classic); onlineShape = hostShape; isTimeMode = data['timeMode'] ?? true; if (!rematchRequested && (hostLevel > currentMatchLevel || (isOnline && currentSeed == null && hostSeed != null) || (hostSeed != null && hostSeed != currentSeed))) { currentMatchLevel = hostLevel; currentSeed = hostSeed; int levelToUse = (currentMatchLevel == 1) ? 2 : currentMatchLevel; board = GameBoard(radius: hostRadius, level: levelToUse, seed: currentSeed, shape: onlineShape); board.currentPlayer = Player.red; isCPUThinking = false; notifyListeners(); return; } int firebaseMovesCount = moves.length; int localMovesCount = board.lines.where((l) => l.owner != Player.none).length; if (firebaseMovesCount == 0 && localMovesCount > 0 && !rematchRequested) { int levelToUse = (currentMatchLevel == 1) ? 2 : currentMatchLevel; board = GameBoard(radius: hostRadius, level: levelToUse, seed: currentSeed, shape: onlineShape); board.currentPlayer = Player.red; notifyListeners(); return; } if (firebaseMovesCount > localMovesCount) { bool newMovesApplied = false; for (int i = localMovesCount; i < firebaseMovesCount; i++) { var m = moves[i]; Line? lineToPlay; for (var line in board.lines) { if ((line.p1.x == m['x1'] && line.p1.y == m['y1'] && line.p2.x == m['x2'] && line.p2.y == m['y2']) || (line.p1.x == m['x2'] && line.p1.y == m['y2'] && line.p2.x == m['x1'] && line.p2.y == m['y1'])) { lineToPlay = line; break; } } if (lineToPlay != null && lineToPlay.owner == Player.none) { Player playerFromFirebase = (m['player'] == 'red') ? Player.red : Player.blue; bool isOpponentMove = (playerFromFirebase != myPlayer); List closedBefore = board.boxes.where((b) => b.owner != Player.none).toList(); List ghostsBefore = board.boxes.where((b) => b.type == BoxType.invisible && b.isRevealed).toList(); board.playMove(lineToPlay, forcedPlayer: playerFromFirebase); newMovesApplied = true; List newClosed = board.boxes.where((b) => b.owner != Player.none && !closedBefore.contains(b)).toList(); List newGhosts = board.boxes.where((b) => b.type == BoxType.invisible && b.isRevealed && !ghostsBefore.contains(b)).toList(); if (isOpponentMove) _playEffects(newClosed, newGhosts: newGhosts, isOpponent: true); } } if (newMovesApplied) { String expectedTurnStr = data['turn'] ?? 'red'; Player expectedTurn = expectedTurnStr == 'red' ? Player.red : Player.blue; if (!board.isGameOver && board.currentPlayer != expectedTurn) { board.currentPlayer = expectedTurn; } _startTimer(); } if (board.isGameOver) _saveMatchResult(); notifyListeners(); } }); } void handleLineTap(Line line, AppThemeType theme, {bool forced = false}) { if ((isSetupPhase || isCPUThinking || board.isGameOver || opponentLeft) && !forced) return; if (isOnline && board.currentPlayer != myPlayer && !forced) return; _activeTheme = theme; List closedBefore = board.boxes.where((b) => b.owner != Player.none).toList(); List ghostsBefore = board.boxes.where((b) => b.type == BoxType.invisible && b.isRevealed).toList(); if (board.playMove(line)) { List newClosed = board.boxes.where((b) => b.owner != Player.none && !closedBefore.contains(b)).toList(); List newGhosts = board.boxes.where((b) => b.type == BoxType.invisible && b.isRevealed && !ghostsBefore.contains(b)).toList(); if (!forced) _playEffects(newClosed, newGhosts: newGhosts, isOpponent: false); _startTimer(); notifyListeners(); if (isOnline && roomCode != null) { Map moveData = { 'x1': line.p1.x, 'y1': line.p1.y, 'x2': line.p2.x, 'y2': line.p2.y, 'player': myPlayer == Player.red ? 'red' : 'blue' }; String nextTurnStr = board.currentPlayer == Player.red ? 'red' : 'blue'; FirebaseFirestore.instance.collection('games').doc(roomCode).update({ 'moves': FieldValue.arrayUnion([moveData]), 'turn': nextTurnStr }).catchError((e) => debugPrint("Errore: $e")); if (board.isGameOver) { _saveMatchResult(); if (isHost) FirebaseFirestore.instance.collection('games').doc(roomCode).update({'status': 'finished'}); } } else { if (board.isGameOver) _saveMatchResult(); else if (isVsCPU && board.currentPlayer == Player.blue) _checkCPUTurn(); } } } void _checkCPUTurn() async { if (isVsCPU && board.currentPlayer == Player.blue && !board.isGameOver) { isCPUThinking = true; _blitzTimer?.cancel(); notifyListeners(); await Future.delayed(const Duration(milliseconds: 600)); if (!board.isGameOver) { List closedBefore = board.boxes.where((b) => b.owner != Player.none).toList(); List ghostsBefore = board.boxes.where((b) => b.type == BoxType.invisible && b.isRevealed).toList(); Line bestMove = AIEngine.getBestMove(board, cpuLevel); board.playMove(bestMove); List newClosed = board.boxes.where((b) => b.owner != Player.none && !closedBefore.contains(b)).toList(); List newGhosts = board.boxes.where((b) => b.type == BoxType.invisible && b.isRevealed && !ghostsBefore.contains(b)).toList(); _playEffects(newClosed, newGhosts: newGhosts, isOpponent: true); isCPUThinking = false; _startTimer(); notifyListeners(); if (board.isGameOver) _saveMatchResult(); else _checkCPUTurn(); } } } // --- LOGICA DI ESTRAZIONE SBLOCCHI DINAMICA --- List> _getUnlocks(int oldLevel, int newLevel) { List> unlocks = []; for(int i = oldLevel + 1; i <= newLevel; i++) { if (rewardsRoadmap.containsKey(i)) { unlocks.addAll(rewardsRoadmap[i]!); } } return unlocks; } void _saveMatchResult() { if (_hasSavedResult) return; _hasSavedResult = true; int calculatedXP = 0; bool isDraw = board.scoreRed == board.scoreBlue; String myRealName = StorageService.instance.playerName; if (myRealName.isEmpty) myRealName = "IO"; int oldLevel = StorageService.instance.playerLevel; if (isOnline) { bool isWin = isHost ? board.scoreRed > board.scoreBlue : board.scoreBlue > board.scoreRed; calculatedXP = isWin ? 20 : (isDraw ? 5 : 2); String oppName = isHost ? onlineGuestName : onlineHostName; int myScore = isHost ? board.scoreRed : board.scoreBlue; int oppScore = isHost ? board.scoreBlue : board.scoreRed; StorageService.instance.saveMatchToHistory(myName: myRealName, opponent: oppName, myScore: myScore, oppScore: oppScore, isOnline: true); if (isWin) StorageService.instance.updateQuestProgress(0, 1); } else if (isVsCPU) { int myScore = board.scoreRed; int cpuScore = board.scoreBlue; bool isWin = myScore > cpuScore; calculatedXP = isWin ? (10 + (cpuLevel * 2)) : (isDraw ? 5 : 2); if (isWin) { StorageService.instance.addWin(); StorageService.instance.updateQuestProgress(1, 1); } else if (cpuScore > myScore) { StorageService.instance.addLoss(); } StorageService.instance.saveMatchToHistory(myName: myRealName, opponent: "CPU (Liv. $cpuLevel)", myScore: myScore, oppScore: cpuScore, isOnline: false); } else { calculatedXP = 2; StorageService.instance.saveMatchToHistory(myName: myRealName, opponent: "Ospite (Locale)", myScore: board.scoreRed, oppScore: board.scoreBlue, isOnline: false); } if (board.shape != ArenaShape.classic) { StorageService.instance.updateQuestProgress(2, 1); } lastMatchXP = calculatedXP; StorageService.instance.addXP(calculatedXP); int newLevel = StorageService.instance.playerLevel; if (newLevel > oldLevel) { hasLeveledUp = true; newlyReachedLevel = newLevel; unlockedRewards = _getUnlocks(oldLevel, newLevel); } notifyListeners(); } void increaseLevelAndRestart() { cpuLevel++; StorageService.instance.saveCpuLevel(cpuLevel); startNewGame(board.radius, vsCPU: true, shape: board.shape, timeMode: isTimeMode); } }