=== TETRAQ PROJECT BACKUP ===
=== PROJECT STRUCTURE (LIB & ASSETS) ===
assets/.DS_Store
assets/audio/sfx/cyber_box.wav
assets/audio/sfx/cyber_line.wav
assets/audio/sfx/doodle_box.wav
assets/audio/sfx/doodle_line.wav
assets/audio/sfx/minimal_box.wav
assets/audio/sfx/minimal_line.wav
assets/icon/icona_master.png
assets/images/.DS_Store
assets/images/doodle_bg.jpg
assets/images/doodle_bg1.jpg
assets/images/icona_big.jpeg
assets/images/wood_bg.jpg
lib/.DS_Store
lib/core/app_colors.dart
lib/core/constants.dart
lib/core/theme_manager.dart
lib/firebase_options.dart
lib/l10n/app_en.arb
lib/l10n/app_it.arb
lib/logic/ai_engine.dart
lib/logic/game_controller.dart
lib/main.dart
lib/models/game_board.dart
lib/models/player_info.dart
lib/services/audio_service.dart
lib/services/firebase_service.dart
lib/services/multiplayer_service.dart
lib/services/storage_service.dart
lib/ui/game/board_painter.dart
lib/ui/game/game_screen.dart
lib/ui/game/score_board.dart
lib/ui/home/history_screen.dart
lib/ui/home/home_screen.dart
lib/ui/multiplayer/lobby_screen.dart
lib/ui/settings/settings_screen.dart
lib/widgets/custom_button.dart
lib/widgets/custom_settings_button.dart
lib/widgets/game_over_dialog.dart
=== pubspec.yaml ===
name: tetraq
description: A new Flutter project.
publish_to: 'none'
version: 1.0.0+4
environment:
sdk: ^3.10.7
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.8
# I nostri "Superpoteri"
provider: ^6.1.2 # Per gestire lo stato (Temi, Punteggi)
shared_preferences: ^2.5.4 # Per salvare opzioni e record sul telefono
audioplayers: ^5.2.1 # Per la musica e gli effetti sonori
intl: ^0.20.2 # Necessario per le traduzioni
flutter_localizations: # Il sistema multilingua ufficiale
sdk: flutter
firebase_core: ^4.4.0
cloud_firestore: ^6.1.2
share_plus: ^12.0.1
app_links: ^7.0.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
flutter_launcher_icons: ^0.13.1
flutter:
uses-material-design: true
# Abilita la generazione automatica delle traduzioni
generate: true
# Dichiariamo le cartelle dove metteremo immagini e suoni
assets:
- assets/images/
- assets/audio/bgm/
- assets/audio/sfx/
- assets/images/
flutter_icons:
android: "ic_launcher"
ios: true
macos:
generate: true
image_path: "assets/icon/icona_master.png"
min_sdk_android: 21 # Serve per compatibilità con Android recenti
=== MAC OS CONFIG: Info.plist ===
LSApplicationCategoryType
public.app-category.puzzle-games
CFBundleDevelopmentRegion
$(DEVELOPMENT_LANGUAGE)
CFBundleExecutable
$(EXECUTABLE_NAME)
CFBundleIconFile
CFBundleIdentifier
$(PRODUCT_BUNDLE_IDENTIFIER)
CFBundleInfoDictionaryVersion
6.0
CFBundleName
$(PRODUCT_NAME)
CFBundlePackageType
APPL
CFBundleShortVersionString
$(FLUTTER_BUILD_NAME)
CFBundleVersion
$(FLUTTER_BUILD_NUMBER)
LSMinimumSystemVersion
$(MACOSX_DEPLOYMENT_TARGET)
NSHumanReadableCopyright
$(PRODUCT_COPYRIGHT)
NSMainNibFile
MainMenu
NSPrincipalClass
NSApplication
=== MAC OS CONFIG: DebugProfile.entitlements ===
com.apple.security.app-sandbox
com.apple.security.cs.allow-jit
com.apple.security.network.client
com.apple.security.network.server
=== IOS CONFIG: Info.plist ===
CFBundleDevelopmentRegion
$(DEVELOPMENT_LANGUAGE)
CFBundleDisplayName
Tetraq
CFBundleExecutable
$(EXECUTABLE_NAME)
CFBundleIdentifier
$(PRODUCT_BUNDLE_IDENTIFIER)
CFBundleInfoDictionaryVersion
6.0
CFBundleName
tetraq
CFBundlePackageType
APPL
CFBundleShortVersionString
$(FLUTTER_BUILD_NAME)
CFBundleSignature
????
CFBundleVersion
$(FLUTTER_BUILD_NUMBER)
LSRequiresIPhoneOS
UILaunchStoryboardName
LaunchScreen
UIMainStoryboardFile
Main
UISupportedInterfaceOrientations
UIInterfaceOrientationPortrait
UIInterfaceOrientationLandscapeLeft
UIInterfaceOrientationLandscapeRight
UISupportedInterfaceOrientations~ipad
UIInterfaceOrientationPortrait
UIInterfaceOrientationPortraitUpsideDown
UIInterfaceOrientationLandscapeLeft
UIInterfaceOrientationLandscapeRight
CADisableMinimumFrameDurationOnPhone
UIApplicationSupportsIndirectInputEvents
=== ANDROID CONFIG: AndroidManifest.xml ===
=== ANDROID CONFIG: build.gradle.kts (app) ===
plugins {
id("com.android.application")
// START: FlutterFire Configuration
id("com.google.gms.google-services")
// END: FlutterFire Configuration
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.sanza.tetraq"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.sanza.tetraq"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
flutter {
source = "../.."
}
=== SOURCE CODE (lib/) ===
// ===========================================================================
// FILE: lib/core/app_colors.dart
// ===========================================================================
import 'package:flutter/material.dart';
enum AppThemeType { minimal, doodle, cyberpunk, wood }
class ThemeColors {
final Color background;
final Color gridLine;
final Color playerRed;
final Color playerBlue;
final Color text;
const ThemeColors({
required this.background,
required this.gridLine,
required this.playerRed,
required this.playerBlue,
required this.text,
});
}
class AppColors {
static const ThemeColors minimal = ThemeColors(
background: Color(0xFFF5F7FA), gridLine: Color(0xFFCFD8DC),
playerRed: Color(0xFFE53935), playerBlue: Color(0xFF1E88E5), text: Color(0xFF263238),
);
static const ThemeColors doodle = ThemeColors(
background: Color(0xFFFFF9E6), gridLine: Color(0xFFB0BEC5),
playerRed: Color(0xFFD32F2F), playerBlue: Color(0xFF1565C0), text: Color(0xFF37474F),
);
// --- TEMA CYBERPUNK AGGIORNATO ---
static const ThemeColors cyberpunk = ThemeColors(
background: Color(0xFF0A001A), // Sfondo notte profonda
gridLine: Color(0xFF6200EA), // Viola scuro elettrico (non fa confusione con le mosse)
playerRed: Color(0xFFFF007F), // Rosa Neon (invariato)
playerBlue: Color(0xFF69F0AE), // Verde Fluo brillante! (Green Accent)
text: Color(0xFFFFFFFF),
);
// --- TEMA LEGNO POTENZIATO ---
static const ThemeColors wood = ThemeColors(
background: Color(0xFF905D3B), // Marrone caldo e ricco (vero legno)
gridLine: Color(0xFF4A301E), // Marrone scurissimo per i solchi
playerRed: Color(0xFFE53935), // Rosso acceso per i fiammiferi
playerBlue: Color(0xFF29B6F6), // Azzurro acceso per i fiammiferi
text: Color(0xFFFBE9E7), // Panna chiaro per contrastare lo scuro
);
static ThemeColors getTheme(AppThemeType type) {
switch (type) {
case AppThemeType.minimal: return minimal;
case AppThemeType.doodle: return doodle;
case AppThemeType.cyberpunk: return cyberpunk;
case AppThemeType.wood: return wood;
}
}
}
// ===========================================================================
// FILE: lib/core/constants.dart
// ===========================================================================
class Constants {
// Chiavi per salvare i dati sul telefono
static const String prefThemeKey = 'selected_theme';
static const String prefLanguageKey = 'selected_language';
static const String prefBoardSizeKey = 'board_size';
// Impostazioni della scacchiera a rombo - RAGGI INCREMENTATI
static const int minBoardRadius = 2; // Ex Normale, ora è Piccola
static const int maxBoardRadius = 5; // Formato MAX, enorme
static const int defaultBoardRadius = 3; // Ora il default è più grande
}
// ===========================================================================
// FILE: lib/core/theme_manager.dart
// ===========================================================================
import 'package:flutter/material.dart';
import 'app_colors.dart';
import '../services/storage_service.dart';
class ThemeManager extends ChangeNotifier {
late AppThemeType _currentThemeType;
ThemeManager() {
// Quando l'app parte, legge il tema dalla memoria!
_currentThemeType = AppThemeType.values[StorageService.instance.savedThemeIndex];
}
AppThemeType get currentThemeType => _currentThemeType;
ThemeColors get currentColors => AppColors.getTheme(_currentThemeType);
void setTheme(AppThemeType type) {
_currentThemeType = type;
StorageService.instance.saveTheme(type); // Salva la scelta nel "disco fisso"
notifyListeners();
}
}
// ===========================================================================
// FILE: lib/firebase_options.dart
// ===========================================================================
// File generated by FlutterFire CLI.
// ignore_for_file: type=lint
import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
import 'package:flutter/foundation.dart'
show defaultTargetPlatform, kIsWeb, TargetPlatform;
/// Default [FirebaseOptions] for use with your Firebase apps.
///
/// Example:
/// ```dart
/// import 'firebase_options.dart';
/// // ...
/// await Firebase.initializeApp(
/// options: DefaultFirebaseOptions.currentPlatform,
/// );
/// ```
class DefaultFirebaseOptions {
static FirebaseOptions get currentPlatform {
if (kIsWeb) {
throw UnsupportedError(
'DefaultFirebaseOptions have not been configured for web - '
'you can reconfigure this by running the FlutterFire CLI again.',
);
}
switch (defaultTargetPlatform) {
case TargetPlatform.android:
return android;
case TargetPlatform.iOS:
return ios;
case TargetPlatform.macOS:
return macos;
case TargetPlatform.windows:
throw UnsupportedError(
'DefaultFirebaseOptions have not been configured for windows - '
'you can reconfigure this by running the FlutterFire CLI again.',
);
case TargetPlatform.linux:
throw UnsupportedError(
'DefaultFirebaseOptions have not been configured for linux - '
'you can reconfigure this by running the FlutterFire CLI again.',
);
default:
throw UnsupportedError(
'DefaultFirebaseOptions are not supported for this platform.',
);
}
}
static const FirebaseOptions android = FirebaseOptions(
apiKey: 'AIzaSyBsXO595xVITDPrRnXrW8HPQLOe7Rz4Gg4',
appId: '1:705460445314:android:4d35fef29cfd63727b949b',
messagingSenderId: '705460445314',
projectId: 'tetraq-32a4a',
storageBucket: 'tetraq-32a4a.firebasestorage.app',
);
static const FirebaseOptions ios = FirebaseOptions(
apiKey: 'AIzaSyB77j18Jgeb9gBAEwp-uyOQvr4m-RJ_rAE',
appId: '1:705460445314:ios:da11cbca5d1f6bc27b949b',
messagingSenderId: '705460445314',
projectId: 'tetraq-32a4a',
storageBucket: 'tetraq-32a4a.firebasestorage.app',
iosBundleId: 'com.sanza.tetraq',
);
static const FirebaseOptions macos = FirebaseOptions(
apiKey: 'AIzaSyB77j18Jgeb9gBAEwp-uyOQvr4m-RJ_rAE',
appId: '1:705460445314:ios:da11cbca5d1f6bc27b949b',
messagingSenderId: '705460445314',
projectId: 'tetraq-32a4a',
storageBucket: 'tetraq-32a4a.firebasestorage.app',
iosBundleId: 'com.sanza.tetraq',
);
}
// ===========================================================================
// FILE: lib/logic/ai_engine.dart
// ===========================================================================
import 'dart:math';
import '../models/game_board.dart';
class AIEngine {
static Line getBestMove(GameBoard board, int level) {
// IL FIX FONDAMENTALE È QUI:
// Filtriamo le linee in modo che la CPU veda SOLO quelle libere E giocabili!
List availableLines = board.lines.where((l) => l.owner == Player.none && l.isPlayable).toList();
final random = Random();
// Se per qualche assurdo motivo non ci sono mosse, restituisce la prima linea a caso
// (Non dovrebbe mai succedere, ma ci protegge dai crash)
if (availableLines.isEmpty) return board.lines.first;
// --- FORMULA DI DIFFICOLTÀ ---
double smartChance = 0.50 + ((level - 1) * 0.10);
if (smartChance > 1.0) smartChance = 1.0;
bool beSmart = random.nextDouble() < smartChance;
// --- REGOLA 1: Chiudere i quadrati ---
List closingMoves = [];
for (var line in availableLines) {
if (_willCloseBox(board, line)) {
closingMoves.add(line);
}
}
if (closingMoves.isNotEmpty) {
if (beSmart || random.nextDouble() < 0.70) {
return closingMoves[random.nextInt(closingMoves.length)];
}
}
// --- REGOLA 2: Mosse Sicure ---
List safeMoves = [];
for (var line in availableLines) {
if (_isSafeMove(board, line)) {
safeMoves.add(line);
}
}
if (safeMoves.isNotEmpty) {
if (beSmart) {
return safeMoves[random.nextInt(safeMoves.length)];
} else {
if (random.nextDouble() < 0.5) {
return safeMoves[random.nextInt(safeMoves.length)];
}
}
}
// --- REGOLA 3: Mossa a caso ---
return availableLines[random.nextInt(availableLines.length)];
}
static bool _willCloseBox(GameBoard board, Line line) {
for (var box in board.boxes) {
// Ignora i box invisibili!
if (box.type == BoxType.invisible) continue;
if (box.top == line || box.bottom == line || box.left == line || box.right == line) {
int linesCount = 0;
if (box.top.owner != Player.none || box.top == line) linesCount++;
if (box.bottom.owner != Player.none || box.bottom == line) linesCount++;
if (box.left.owner != Player.none || box.left == line) linesCount++;
if (box.right.owner != Player.none || box.right == line) linesCount++;
if (linesCount == 4) return true;
}
}
return false;
}
static bool _isSafeMove(GameBoard board, Line line) {
for (var box in board.boxes) {
// Ignora i box invisibili!
if (box.type == BoxType.invisible) continue;
if (box.top == line || box.bottom == line || box.left == line || box.right == line) {
int currentLinesCount = 0;
if (box.top.owner != Player.none) currentLinesCount++;
if (box.bottom.owner != Player.none) currentLinesCount++;
if (box.left.owner != Player.none) currentLinesCount++;
if (box.right.owner != Player.none) currentLinesCount++;
if (currentLinesCount == 2) return false;
}
}
return true;
}
}
// ===========================================================================
// FILE: lib/logic/game_controller.dart
// ===========================================================================
import 'dart:async';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/game_board.dart';
import 'ai_engine.dart';
import '../services/audio_service.dart';
import '../services/storage_service.dart';
import '../core/app_colors.dart';
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;
String effectText = '';
Color effectColor = Colors.transparent;
Timer? _effectTimer;
Player get myPlayer => isHost ? Player.red : Player.blue;
bool get isGameOver => board.isGameOver;
int cpuLevel = 1;
int currentMatchLevel = 1;
int? currentSeed;
AppThemeType _activeTheme = AppThemeType.cyberpunk;
String onlineHostName = "ROSSO";
String onlineGuestName = "BLU";
ArenaShape onlineShape = ArenaShape.classic;
GameController({int radius = 3}) {
cpuLevel = StorageService.instance.cpuLevel;
startNewGame(radius);
}
void startNewGame(int radius, {bool vsCPU = false, bool isOnline = false, String? roomCode, bool isHost = false, ArenaShape shape = ArenaShape.classic}) {
_onlineSubscription?.cancel();
_onlineSubscription = null;
_blitzTimer?.cancel();
_effectTimer?.cancel();
effectText = '';
_hasSavedResult = false;
this.isVsCPU = vsCPU;
this.isOnline = isOnline;
this.roomCode = roomCode;
this.isHost = isHost;
// Salviamo la forma scelta anche se siamo offline
onlineShape = shape;
int levelToUse = isOnline ? (currentMatchLevel == 1 ? 2 : currentMatchLevel) : cpuLevel;
// PASSIAMO LA FORMA AL MOTORE!
board = GameBoard(radius: radius, level: levelToUse, seed: currentSeed, shape: onlineShape);
isCPUThinking = false;
opponentLeft = false;
if (this.isOnline && this.roomCode != null) {
_listenToOnlineGame(this.roomCode!);
}
_startTimer();
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, {required bool isOpponent}) {
if (newClosed.isEmpty) {
AudioService.instance.playLineSfx(_activeTheme);
if (!isOpponent) HapticFeedback.lightImpact();
} else {
bool isGold = newClosed.any((b) => b.type == BoxType.gold);
bool isBomb = newClosed.any((b) => b.type == BoxType.bomb);
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();
}
}
}
// --- FIX TIMER: SCENDE PER ENTRAMBI, MA SCADE SOLO PER CHI HA IL TURNO ---
void _startTimer() {
_blitzTimer?.cancel();
timeLeft = maxTime;
_blitzTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (isGameOver || isCPUThinking) { timer.cancel(); return; }
if (timeLeft > 0) {
timeLeft--;
notifyListeners();
} else {
timer.cancel();
// IL TIMEOUT FORZA LA MOSSA SOLO SE È IL MIO TURNO
// Evita che il telefono in attesa forzi mosse per conto dell'avversario
if (!isOnline || board.currentPlayer == myPlayer) {
_handleTimeOut();
}
}
});
}
void _handleTimeOut() {
if (isOnline) {
Line randomMove = AIEngine.getBestMove(board, 5);
handleLineTap(randomMove, _activeTheme, forced: true);
}
else if (isVsCPU && board.currentPlayer == Player.red) {
Line randomMove = AIEngine.getBestMove(board, cpuLevel);
handleLineTap(randomMove, _activeTheme, forced: true);
}
else if (!isVsCPU) {
Line randomMove = AIEngine.getBestMove(board, 5);
handleLineTap(randomMove, _activeTheme, forced: true);
}
}
void disconnectOnlineGame() {
_onlineSubscription?.cancel();
_onlineSubscription = null;
_blitzTimer?.cancel();
_effectTimer?.cancel();
if (isOnline && roomCode != null) {
FirebaseFirestore.instance.collection('games').doc(roomCode).update({'status': 'abandoned'}).catchError((e) => null);
}
isOnline = false;
roomCode = null;
currentMatchLevel = 1;
currentSeed = null;
}
void requestRematch() {
if (isOnline && roomCode != null) {
currentMatchLevel++;
int newSeed = DateTime.now().millisecondsSinceEpoch % 1000000;
FirebaseFirestore.instance.collection('games').doc(roomCode).update({
'moves': [],
'matchLevel': currentMatchLevel,
'status': 'playing',
'seed': newSeed,
'shape': onlineShape.name
});
}
}
@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;
}
List moves = data['moves'] ?? [];
int hostLevel = data['matchLevel'] ?? 1;
int? hostSeed = data['seed'];
String shapeStr = data['shape'] ?? 'classic';
ArenaShape hostShape = ArenaShape.values.firstWhere((e) => e.name == shapeStr, orElse: () => ArenaShape.classic);
onlineShape = hostShape;
if (hostLevel > currentMatchLevel || (isOnline && currentSeed == null && hostSeed != null) || (hostSeed != null && hostSeed != currentSeed)) {
currentMatchLevel = hostLevel; currentSeed = hostSeed;
int levelToUse = (currentMatchLevel == 1) ? 2 : currentMatchLevel;
board = GameBoard(radius: board.radius, level: levelToUse, seed: currentSeed, shape: onlineShape);
isCPUThinking = false; notifyListeners(); return;
}
int firebaseMovesCount = moves.length;
int localMovesCount = board.lines.where((l) => l.owner != Player.none).length;
if (firebaseMovesCount == 0 && localMovesCount > 0) {
int levelToUse = (currentMatchLevel == 1) ? 2 : currentMatchLevel;
board = GameBoard(radius: board.radius, level: levelToUse, seed: currentSeed, shape: onlineShape);
notifyListeners(); return;
}
if (firebaseMovesCount > localMovesCount) {
bool newMovesApplied = false; // <-- FIX: Ci segniamo se abbiamo ricevuto mosse
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();
board.playMove(lineToPlay, forcedPlayer: playerFromFirebase);
newMovesApplied = true;
List newClosed = board.boxes.where((b) => b.owner != Player.none && !closedBefore.contains(b)).toList();
if (isOpponentMove) _playEffects(newClosed, isOpponent: true);
}
}
// --- FIX TIMER: SE L'AVVERSARIO HA MOSSO, RESETTIAMO IL TIMER ---
if (newMovesApplied) {
_startTimer();
}
if (board.isGameOver) _saveMatchResult();
notifyListeners();
}
});
}
void handleLineTap(Line line, AppThemeType theme, {bool forced = false}) {
if ((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();
if (board.playMove(line)) {
List newClosed = board.boxes.where((b) => b.owner != Player.none && !closedBefore.contains(b)).toList();
if (!forced) _playEffects(newClosed, 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'
};
FirebaseFirestore.instance.collection('games').doc(roomCode).update({
'moves': FieldValue.arrayUnion([moveData])
}).catchError((e) => debugPrint("Errore: $e"));
if (board.isGameOver) _saveMatchResult();
} 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();
Line bestMove = AIEngine.getBestMove(board, cpuLevel);
board.playMove(bestMove);
List newClosed = board.boxes.where((b) => b.owner != Player.none && !closedBefore.contains(b)).toList();
_playEffects(newClosed, isOpponent: true);
isCPUThinking = false; _startTimer(); notifyListeners();
if (board.isGameOver) _saveMatchResult();
else _checkCPUTurn();
}
}
}
void _saveMatchResult() {
if (_hasSavedResult) return;
_hasSavedResult = true;
String myRealName = StorageService.instance.playerName;
if (myRealName.isEmpty) myRealName = "IO";
if (isOnline) {
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);
} else if (isVsCPU) {
int myScore = board.scoreRed; int cpuScore = board.scoreBlue;
if (myScore > cpuScore) StorageService.instance.addWin();
else if (cpuScore > myScore) StorageService.instance.addLoss();
StorageService.instance.saveMatchToHistory(myName: myRealName, opponent: "CPU (Liv. $cpuLevel)", myScore: myScore, oppScore: cpuScore, isOnline: false);
} else {
StorageService.instance.saveMatchToHistory(myName: myRealName, opponent: "Ospite (Locale)", myScore: board.scoreRed, oppScore: board.scoreBlue, isOnline: false);
}
}
void increaseLevelAndRestart() {
cpuLevel++; StorageService.instance.saveCpuLevel(cpuLevel);
startNewGame(board.radius, vsCPU: true, shape: board.shape);
}
}
// ===========================================================================
// FILE: lib/main.dart
// ===========================================================================
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'core/theme_manager.dart';
import 'logic/game_controller.dart';
import 'ui/home/home_screen.dart';
import 'services/storage_service.dart'; // <-- Importiamo il servizio
import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';
void main() async {
// Assicuriamoci che i motori di Flutter siano pronti
WidgetsFlutterBinding.ensureInitialized();
// 1. Accendiamo Firebase! (Questo ti era sfuggito)
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
// 2. Accendiamo la Memoria Locale!
await StorageService.instance.init();
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => ThemeManager()),
ChangeNotifierProvider(create: (_) => GameController()),
],
child: const TetraQApp(),
),
);
}
class TetraQApp extends StatelessWidget {
const TetraQApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'TetraQ',
debugShowCheckedModeBanner: false,
theme: ThemeData(
fontFamily: 'Roboto',
useMaterial3: true,
),
home: const HomeScreen(),
);
}
}
// ===========================================================================
// FILE: lib/models/game_board.dart
// ===========================================================================
import 'dart:math';
enum Player { red, blue, none }
enum BoxType { normal, gold, bomb, invisible }
enum ArenaShape { classic, cross, donut, hourglass }
class Dot {
final int x;
final int y;
Dot(this.x, this.y);
@override
bool operator ==(Object other) => identical(this, other) || other is Dot && runtimeType == other.runtimeType && x == other.x && y == other.y;
@override
int get hashCode => x.hashCode ^ y.hashCode;
}
class Line {
final Dot p1;
final Dot p2;
Player owner = Player.none;
bool isPlayable = false;
Line(this.p1, this.p2);
bool connects(Dot a, Dot b) { return (p1 == a && p2 == b) || (p1 == b && p2 == a); }
}
class Box {
final int x;
final int y;
Player owner = Player.none;
late Line top, bottom, left, right;
BoxType type = BoxType.normal;
Box(this.x, this.y);
bool isClosed() {
if (type == BoxType.invisible) return false;
return top.owner != Player.none && bottom.owner != Player.none && left.owner != Player.none && right.owner != Player.none;
}
int get value {
if (type == BoxType.gold) return 2;
if (type == BoxType.bomb) return -1;
return 1;
}
}
class GameBoard {
final int radius;
final int level;
final int? seed;
final ArenaShape shape;
List dots = [];
List lines = [];
List boxes = [];
Player currentPlayer = Player.red;
int scoreRed = 0;
int scoreBlue = 0;
bool isGameOver = false;
GameBoard({required this.radius, this.level = 1, this.seed, this.shape = ArenaShape.classic}) {
_generateBoard();
}
void _generateBoard() {
int size = radius * 2 + 1;
final random = seed != null ? Random(seed) : Random();
dots.clear();
lines.clear();
boxes.clear();
for (int y = 0; y < size; y++) {
for (int x = 0; x < size; x++) {
var box = Box(x, y);
// Distanze dal centro della scacchiera
int dx = (x - radius).abs();
int dy = (y - radius).abs();
// 1. Controllo base: Deve appartenere al Rombo primario
bool isVisible = (dx + dy) <= radius;
// 2. LOGICA FORME (Scolpiamo i blocchi dall'interno)
if (isVisible) {
switch (shape) {
case ArenaShape.classic:
break; // Nessun taglio
case ArenaShape.cross:
// Per fare una croce *all'interno* di un rombo, dobbiamo svuotare
// 4 "triangolini" vicini agli assi mediani.
// La logica è: se non sei sull'asse centrale verticale o orizzontale, sparisci.
// Aumentiamo lo spessore del braccio se il raggio è grande.
int spessoreBraccio = radius > 3 ? 1 : 0;
if (dx > spessoreBraccio && dy > spessoreBraccio) {
isVisible = false;
}
break;
case ArenaShape.donut:
// Il Buco Nero (Donut). Svuotiamo il nucleo centrale.
int dimensioneBuco = radius > 3 ? 2 : 1;
if ((dx + dy) <= dimensioneBuco) {
isVisible = false;
}
break;
case ArenaShape.hourglass:
// La Clessidra. Svuotiamo i due lobi laterali (sinistro e destro).
// Vogliamo mantenere solo l'alto e il basso.
if (dx > dy) {
isVisible = false;
}
// Manteniamo una singola scatola di giunzione al centro esatto
if (x == radius && y == radius) {
isVisible = true;
}
break;
}
}
if (!isVisible) {
box.type = BoxType.invisible;
} else if (level > 1) {
double chance = random.nextDouble();
if (chance < 0.10) { box.type = BoxType.gold; }
else if (chance > 0.90) { box.type = BoxType.bomb; }
}
boxes.add(box);
}
}
// --- PULIZIA DEFINITIVA: ASSEGNAZIONE DELLE LINEE ---
for (var box in boxes) {
Dot tl = _getOrAddDot(box.x, box.y);
Dot tr = _getOrAddDot(box.x + 1, box.y);
Dot bl = _getOrAddDot(box.x, box.y + 1);
Dot br = _getOrAddDot(box.x + 1, box.y + 1);
box.top = _getOrAddLine(tl, tr);
box.bottom = _getOrAddLine(bl, br);
box.left = _getOrAddLine(tl, bl);
box.right = _getOrAddLine(tr, br);
// Le linee sono "giocabili" SOLO se appartengono a una scatola VISIBILE
if (box.type != BoxType.invisible) {
box.top.isPlayable = true;
box.bottom.isPlayable = true;
box.left.isPlayable = true;
box.right.isPlayable = true;
}
}
}
Dot _getOrAddDot(int x, int y) {
for (var dot in dots) { if (dot.x == x && dot.y == y) return dot; }
var newDot = Dot(x, y);
dots.add(newDot);
return newDot;
}
Line _getOrAddLine(Dot a, Dot b) {
for (var line in lines) { if (line.connects(a, b)) return line; }
var newLine = Line(a, b);
lines.add(newLine);
return newLine;
}
bool playMove(Line lineToPlay, {Player? forcedPlayer}) {
if (isGameOver) return false;
Player playerMakingMove = forcedPlayer ?? currentPlayer;
Line? actualLine;
for (var l in lines) {
if (l.connects(lineToPlay.p1, lineToPlay.p2)) {
actualLine = l; break;
}
}
if (actualLine == null || actualLine.owner != Player.none || !actualLine.isPlayable) return false;
actualLine.owner = playerMakingMove;
bool boxedClosed = false;
for (var box in boxes) {
if (box.owner == Player.none && box.isClosed()) {
box.owner = playerMakingMove;
boxedClosed = true;
if (playerMakingMove == Player.red) { scoreRed += box.value; }
else { scoreBlue += box.value; }
}
}
if (lines.where((l) => l.isPlayable).every((l) => l.owner != Player.none)) { isGameOver = true; }
if (forcedPlayer == null) {
if (!boxedClosed && !isGameOver) { currentPlayer = (currentPlayer == Player.red) ? Player.blue : Player.red; }
} else {
if (!boxedClosed && !isGameOver) { currentPlayer = (forcedPlayer == Player.red) ? Player.blue : Player.red; }
else { currentPlayer = forcedPlayer; }
}
return true;
}
}
// ===========================================================================
// FILE: lib/models/player_info.dart
// ===========================================================================
// ===========================================================================
// FILE: lib/services/audio_service.dart
// ===========================================================================
import 'package:flutter/material.dart';
import 'package:audioplayers/audioplayers.dart';
import '../core/app_colors.dart';
class AudioService extends ChangeNotifier {
static final AudioService instance = AudioService._internal();
AudioService._internal();
bool isMuted = false;
final AudioPlayer _sfxPlayer = AudioPlayer();
void toggleMute() {
isMuted = !isMuted;
notifyListeners();
}
void playLineSfx(AppThemeType theme) async {
if (isMuted) return;
String file = '';
switch (theme) {
case AppThemeType.minimal: file = 'minimal_line.wav'; break;
case AppThemeType.doodle:
case AppThemeType.wood:
file = 'doodle_line.wav'; break;
case AppThemeType.cyberpunk: file = 'cyber_line.wav'; break;
}
await _sfxPlayer.play(AssetSource('audio/sfx/$file'));
}
void playBoxSfx(AppThemeType theme) async {
if (isMuted) return;
String file = '';
switch (theme) {
case AppThemeType.minimal: file = 'minimal_box.wav'; break;
case AppThemeType.doodle:
case AppThemeType.wood:
file = 'doodle_box.wav'; break;
case AppThemeType.cyberpunk: file = 'cyber_box.wav'; break;
}
await _sfxPlayer.play(AssetSource('audio/sfx/$file'));
}
// --- NUOVI EFFETTI SPECIALI ---
void playBonusSfx() async {
if (isMuted) return;
// Assicurati di aggiungere questo file nella cartella assets/audio/sfx/
await _sfxPlayer.play(AssetSource('audio/sfx/bonus.wav'));
}
void playBombSfx() async {
if (isMuted) return;
// Assicurati di aggiungere questo file nella cartella assets/audio/sfx/
await _sfxPlayer.play(AssetSource('audio/sfx/bomb.wav'));
}
}
// ===========================================================================
// FILE: lib/services/firebase_service.dart
// ===========================================================================
// ===========================================================================
// FILE: lib/services/multiplayer_service.dart
// ===========================================================================
import 'dart:math';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:share_plus/share_plus.dart';
class MultiplayerService {
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
CollectionReference get _gamesCollection => _firestore.collection('games');
// AGGIUNTO shapeName
Future createGameRoom(int boardRadius, String hostName, String shapeName) async {
String roomCode = _generateRoomCode();
int randomSeed = Random().nextInt(1000000);
await _gamesCollection.doc(roomCode).set({
'status': 'waiting',
'radius': boardRadius,
'createdAt': FieldValue.serverTimestamp(),
'players': ['host'],
'turn': 'host',
'moves': [],
'seed': randomSeed,
'hostName': hostName,
'guestName': '',
'shape': shapeName, // <-- SALVIAMO LA FORMA DELL'ARENA SU FIREBASE
});
return roomCode;
}
Future