3750 lines
No EOL
139 KiB
Text
3750 lines
No EOL
139 KiB
Text
=== 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 ===
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>LSApplicationCategoryType</key>
|
|
<string>public.app-category.puzzle-games</string>
|
|
<key>CFBundleDevelopmentRegion</key>
|
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
|
<key>CFBundleExecutable</key>
|
|
<string>$(EXECUTABLE_NAME)</string>
|
|
<key>CFBundleIconFile</key>
|
|
<string></string>
|
|
<key>CFBundleIdentifier</key>
|
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
|
<key>CFBundleInfoDictionaryVersion</key>
|
|
<string>6.0</string>
|
|
<key>CFBundleName</key>
|
|
<string>$(PRODUCT_NAME)</string>
|
|
<key>CFBundlePackageType</key>
|
|
<string>APPL</string>
|
|
<key>CFBundleShortVersionString</key>
|
|
<string>$(FLUTTER_BUILD_NAME)</string>
|
|
<key>CFBundleVersion</key>
|
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
|
<key>LSMinimumSystemVersion</key>
|
|
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
|
<key>NSHumanReadableCopyright</key>
|
|
<string>$(PRODUCT_COPYRIGHT)</string>
|
|
<key>NSMainNibFile</key>
|
|
<string>MainMenu</string>
|
|
<key>NSPrincipalClass</key>
|
|
<string>NSApplication</string>
|
|
</dict>
|
|
</plist>
|
|
=== MAC OS CONFIG: DebugProfile.entitlements ===
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>com.apple.security.app-sandbox</key>
|
|
<true/>
|
|
<key>com.apple.security.cs.allow-jit</key>
|
|
<true/>
|
|
<key>com.apple.security.network.client</key>
|
|
<true/>
|
|
<key>com.apple.security.network.server</key>
|
|
<true/>
|
|
</dict>
|
|
</plist>
|
|
=== IOS CONFIG: Info.plist ===
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>CFBundleDevelopmentRegion</key>
|
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
|
<key>CFBundleDisplayName</key>
|
|
<string>Tetraq</string>
|
|
<key>CFBundleExecutable</key>
|
|
<string>$(EXECUTABLE_NAME)</string>
|
|
<key>CFBundleIdentifier</key>
|
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
|
<key>CFBundleInfoDictionaryVersion</key>
|
|
<string>6.0</string>
|
|
<key>CFBundleName</key>
|
|
<string>tetraq</string>
|
|
<key>CFBundlePackageType</key>
|
|
<string>APPL</string>
|
|
<key>CFBundleShortVersionString</key>
|
|
<string>$(FLUTTER_BUILD_NAME)</string>
|
|
<key>CFBundleSignature</key>
|
|
<string>????</string>
|
|
<key>CFBundleVersion</key>
|
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
|
<key>LSRequiresIPhoneOS</key>
|
|
<true/>
|
|
<key>UILaunchStoryboardName</key>
|
|
<string>LaunchScreen</string>
|
|
<key>UIMainStoryboardFile</key>
|
|
<string>Main</string>
|
|
<key>UISupportedInterfaceOrientations</key>
|
|
<array>
|
|
<string>UIInterfaceOrientationPortrait</string>
|
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
|
</array>
|
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
|
<array>
|
|
<string>UIInterfaceOrientationPortrait</string>
|
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
|
</array>
|
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
|
<true/>
|
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
|
<true/>
|
|
</dict>
|
|
</plist>
|
|
|
|
=== ANDROID CONFIG: AndroidManifest.xml ===
|
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
|
|
<uses-permission android:name="android.permission.INTERNET" />
|
|
|
|
<application
|
|
android:label="tetraq"
|
|
android:name="${applicationName}"
|
|
android:icon="@mipmap/ic_launcher">
|
|
<activity
|
|
android:name=".MainActivity"
|
|
android:exported="true"
|
|
android:launchMode="singleTop"
|
|
android:taskAffinity=""
|
|
android:theme="@style/LaunchTheme"
|
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
|
android:hardwareAccelerated="true"
|
|
android:windowSoftInputMode="adjustResize">
|
|
<meta-data
|
|
android:name="io.flutter.embedding.android.NormalTheme"
|
|
android:resource="@style/NormalTheme"
|
|
/>
|
|
<intent-filter>
|
|
<action android:name="android.intent.action.MAIN"/>
|
|
<category android:name="android.intent.category.LAUNCHER"/>
|
|
</intent-filter>
|
|
<intent-filter>
|
|
<action android:name="android.intent.action.VIEW" />
|
|
<category android:name="android.intent.category.DEFAULT" />
|
|
<category android:name="android.intent.category.BROWSABLE" />
|
|
<data android:scheme="tetraq" android:host="join" />
|
|
</intent-filter>
|
|
</activity>
|
|
<meta-data
|
|
android:name="flutterEmbedding"
|
|
android:value="2" />
|
|
</application>
|
|
<queries>
|
|
<intent>
|
|
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
|
<data android:mimeType="text/plain"/>
|
|
</intent>
|
|
</queries>
|
|
</manifest>
|
|
=== 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<Line> 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<Line> 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<Line> 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<DocumentSnapshot>? _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<Box> 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<String, dynamic>;
|
|
|
|
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<dynamic> 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<Box> closedBefore = board.boxes.where((b) => b.owner != Player.none).toList();
|
|
|
|
board.playMove(lineToPlay, forcedPlayer: playerFromFirebase);
|
|
newMovesApplied = true;
|
|
|
|
List<Box> 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<Box> closedBefore = board.boxes.where((b) => b.owner != Player.none).toList();
|
|
|
|
if (board.playMove(line)) {
|
|
List<Box> 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<String, dynamic> 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<Box> closedBefore = board.boxes.where((b) => b.owner != Player.none).toList();
|
|
Line bestMove = AIEngine.getBestMove(board, cpuLevel);
|
|
board.playMove(bestMove);
|
|
|
|
List<Box> 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<Dot> dots = [];
|
|
List<Line> lines = [];
|
|
List<Box> 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<String> 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<Map<String, dynamic>?> joinGameRoom(String roomCode, String guestName) async {
|
|
DocumentSnapshot doc = await _gamesCollection.doc(roomCode).get();
|
|
|
|
if (doc.exists && doc['status'] == 'waiting') {
|
|
await _gamesCollection.doc(roomCode).update({
|
|
'status': 'playing',
|
|
'players': FieldValue.arrayUnion(['guest']),
|
|
'guestName': guestName,
|
|
});
|
|
return doc.data() as Map<String, dynamic>;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
void shareInviteLink(String roomCode) {
|
|
String message = "Ehi! Giochiamo a TetraQ? 🎮\nCopia questo intero messaggio e apri l'app per entrare direttamente, oppure inserisci manualmente il codice: $roomCode";
|
|
Share.share(message);
|
|
}
|
|
|
|
Stream<DocumentSnapshot> listenToRoom(String roomCode) {
|
|
return _gamesCollection.doc(roomCode).snapshots();
|
|
}
|
|
|
|
String _generateRoomCode() {
|
|
const chars = 'ACDEFGHJKLMNPQRSTUVWXYZ2345679';
|
|
final random = Random();
|
|
return String.fromCharCodes(Iterable.generate(
|
|
5, (_) => chars.codeUnitAt(random.nextInt(chars.length)),
|
|
));
|
|
}
|
|
}
|
|
// ===========================================================================
|
|
// FILE: lib/services/storage_service.dart
|
|
// ===========================================================================
|
|
|
|
import 'dart:convert';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import '../core/app_colors.dart';
|
|
|
|
class StorageService {
|
|
static final StorageService instance = StorageService._internal();
|
|
StorageService._internal();
|
|
|
|
late SharedPreferences _prefs;
|
|
|
|
// Si avvia quando apriamo l'app
|
|
Future<void> init() async {
|
|
_prefs = await SharedPreferences.getInstance();
|
|
}
|
|
|
|
// --- IMPOSTAZIONI ---
|
|
int get savedThemeIndex => _prefs.getInt('theme') ?? AppThemeType.cyberpunk.index;
|
|
Future<void> saveTheme(AppThemeType theme) async => await _prefs.setInt('theme', theme.index);
|
|
|
|
int get savedRadius => _prefs.getInt('radius') ?? 2;
|
|
Future<void> saveRadius(int radius) async => await _prefs.setInt('radius', radius);
|
|
|
|
bool get isMuted => _prefs.getBool('isMuted') ?? false;
|
|
Future<void> saveMuted(bool muted) async => await _prefs.setBool('isMuted', muted);
|
|
|
|
// --- STATISTICHE VS CPU ---
|
|
int get wins => _prefs.getInt('wins') ?? 0;
|
|
Future<void> addWin() async => await _prefs.setInt('wins', wins + 1);
|
|
|
|
int get losses => _prefs.getInt('losses') ?? 0;
|
|
Future<void> addLoss() async => await _prefs.setInt('losses', losses + 1);
|
|
|
|
int get cpuLevel => _prefs.getInt('cpuLevel') ?? 1;
|
|
Future<void> saveCpuLevel(int level) async => await _prefs.setInt('cpuLevel', level);
|
|
|
|
// --- MULTIPLAYER ---
|
|
String get playerName => _prefs.getString('playerName') ?? '';
|
|
Future<void> savePlayerName(String name) async => await _prefs.setString('playerName', name);
|
|
|
|
// --- STORICO PARTITE ---
|
|
List<Map<String, dynamic>> get matchHistory {
|
|
List<String> history = _prefs.getStringList('matchHistory') ?? [];
|
|
return history.map((e) => jsonDecode(e) as Map<String, dynamic>).toList();
|
|
}
|
|
|
|
// Salviamo sia il nostro nome che quello dell'avversario
|
|
Future<void> saveMatchToHistory({required String myName, required String opponent, required int myScore, required int oppScore, required bool isOnline}) async {
|
|
List<String> history = _prefs.getStringList('matchHistory') ?? [];
|
|
|
|
Map<String, dynamic> match = {
|
|
'date': DateTime.now().toIso8601String(),
|
|
'myName': myName,
|
|
'opponent': opponent,
|
|
'myScore': myScore,
|
|
'oppScore': oppScore,
|
|
'isOnline': isOnline,
|
|
};
|
|
|
|
// Aggiungiamo in cima (il più recente per primo)
|
|
history.insert(0, jsonEncode(match));
|
|
|
|
// Teniamo solo le ultime 50 partite per non intasare la memoria
|
|
if (history.length > 50) {
|
|
history = history.sublist(0, 50);
|
|
}
|
|
|
|
await _prefs.setStringList('matchHistory', history);
|
|
}
|
|
}
|
|
// ===========================================================================
|
|
// FILE: lib/ui/game/board_painter.dart
|
|
// ===========================================================================
|
|
|
|
import 'dart:math';
|
|
import 'package:flutter/material.dart';
|
|
import '../../models/game_board.dart';
|
|
import '../../core/app_colors.dart';
|
|
|
|
class BoardPainter extends CustomPainter {
|
|
final GameBoard board;
|
|
final ThemeColors theme;
|
|
final AppThemeType themeType;
|
|
|
|
BoardPainter({required this.board, required this.theme, required this.themeType});
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
if (themeType == AppThemeType.doodle) {
|
|
final Paint paperGridPaint = Paint()
|
|
..color = Colors.grey.withOpacity(0.3)
|
|
..strokeWidth = 1.0
|
|
..style = PaintingStyle.stroke;
|
|
|
|
double paperStep = 20.0;
|
|
for (double i = 0; i <= size.width; i += paperStep) {
|
|
canvas.drawLine(Offset(i, 0), Offset(i, size.height), paperGridPaint);
|
|
}
|
|
for (double i = 0; i <= size.height; i += paperStep) {
|
|
canvas.drawLine(Offset(0, i), Offset(size.width, i), paperGridPaint);
|
|
}
|
|
}
|
|
|
|
int gridPoints = board.radius * 2 + 2;
|
|
double spacing = size.width / gridPoints;
|
|
double offset = spacing / 2;
|
|
Offset getScreenPos(int x, int y) => Offset(x * spacing + offset, y * spacing + offset);
|
|
|
|
// --- 1. DISEGNO AREE CONQUISTATE E ICONE ---
|
|
for (var box in board.boxes) {
|
|
// Ignoriamo totalmente le scatole invisibili (tagliate via dalle nuove forme)
|
|
if (box.type == BoxType.invisible) continue;
|
|
|
|
Offset p1 = getScreenPos(box.x, box.y);
|
|
Offset p2 = getScreenPos(box.x + 1, box.y + 1);
|
|
Rect rect = Rect.fromPoints(p1, p2);
|
|
|
|
if (box.owner != Player.none) {
|
|
final boxPaint = Paint()
|
|
..style = PaintingStyle.fill
|
|
..color = box.owner == Player.red ? theme.playerRed.withOpacity(0.6) : theme.playerBlue.withOpacity(0.6);
|
|
|
|
if (themeType == AppThemeType.wood) {
|
|
_drawFlameBox(canvas, rect, box.owner == Player.red);
|
|
} else if (themeType == AppThemeType.doodle) {
|
|
Color penColor = box.owner == Player.red ? Colors.redAccent.shade700 : Colors.blueAccent.shade700;
|
|
_drawScribbleBox(canvas, rect, penColor);
|
|
} else {
|
|
canvas.drawRect(rect, boxPaint);
|
|
}
|
|
}
|
|
|
|
if (box.type == BoxType.gold) {
|
|
_drawIconInBox(canvas, rect, Icons.star_rounded, Colors.amber);
|
|
} else if (box.type == BoxType.bomb) {
|
|
_drawIconInBox(canvas, rect, Icons.mood_bad_rounded, themeType == AppThemeType.cyberpunk ? Colors.greenAccent : Colors.deepPurple);
|
|
}
|
|
}
|
|
|
|
// --- 2. DISEGNO LINEE ---
|
|
for (var line in board.lines) {
|
|
// FIX FONDAMENTALE PER LE FORME: Non disegnare linee non giocabili!
|
|
if (!line.isPlayable) continue;
|
|
|
|
Offset p1 = getScreenPos(line.p1.x, line.p1.y);
|
|
Offset p2 = getScreenPos(line.p2.x, line.p2.y);
|
|
|
|
Color lineColor = line.owner == Player.none
|
|
? theme.gridLine.withOpacity(0.4)
|
|
: (line.owner == Player.red ? theme.playerRed : theme.playerBlue);
|
|
|
|
if (themeType == AppThemeType.wood) {
|
|
if (line.owner == Player.none) {
|
|
canvas.drawLine(p1, p2, Paint()..color = const Color(0xFF3E2723).withOpacity(0.3)..strokeWidth = 4.5..strokeCap = StrokeCap.round);
|
|
} else {
|
|
_drawRealisticMatch(canvas, p1, p2, lineColor);
|
|
}
|
|
} else if (themeType == AppThemeType.cyberpunk) {
|
|
_drawNeonLine(canvas, p1, p2, lineColor, line.owner != Player.none);
|
|
} else if (themeType == AppThemeType.doodle) {
|
|
Color doodleColor = line.owner == Player.none ? Colors.black.withOpacity(0.05) : lineColor;
|
|
_drawWobblyLine(canvas, p1, p2, doodleColor, line.owner != Player.none);
|
|
} else {
|
|
canvas.drawLine(p1, p2, Paint()..color = lineColor..strokeWidth = 6.0..strokeCap = StrokeCap.round);
|
|
}
|
|
}
|
|
|
|
// --- 3. DISEGNO PUNTINI (SOLO QUELLI UTILI) ---
|
|
final dotPaint = Paint()..style = PaintingStyle.fill;
|
|
|
|
// Raccogliamo solo i puntini che appartengono a linee giocabili
|
|
Set<Dot> activeDots = {};
|
|
for (var line in board.lines) {
|
|
if (line.isPlayable) {
|
|
activeDots.add(line.p1);
|
|
activeDots.add(line.p2);
|
|
}
|
|
}
|
|
|
|
for (var dot in activeDots) {
|
|
Offset pos = getScreenPos(dot.x, dot.y);
|
|
if (themeType == AppThemeType.wood) {
|
|
canvas.drawCircle(pos, 3.5, dotPaint..color = const Color(0xFF3E2723).withOpacity(0.2));
|
|
} else if (themeType == AppThemeType.cyberpunk) {
|
|
canvas.drawCircle(pos, 6.0, Paint()..color = theme.gridLine.withOpacity(0.3));
|
|
canvas.drawCircle(pos, 3.0, Paint()..color = Colors.white.withOpacity(0.5));
|
|
} else if (themeType == AppThemeType.doodle) {
|
|
canvas.drawRect(Rect.fromCenter(center: pos, width: 4, height: 4), dotPaint..color = Colors.black.withOpacity(0.25));
|
|
} else {
|
|
canvas.drawCircle(pos, 5.0, dotPaint..color = theme.text.withOpacity(0.6));
|
|
}
|
|
}
|
|
}
|
|
|
|
void _drawIconInBox(Canvas canvas, Rect rect, IconData icon, Color color) {
|
|
TextPainter textPainter = TextPainter(textDirection: TextDirection.ltr);
|
|
textPainter.text = TextSpan(
|
|
text: String.fromCharCode(icon.codePoint),
|
|
style: TextStyle(
|
|
color: color.withOpacity(0.7),
|
|
fontSize: rect.width * 0.45,
|
|
fontFamily: icon.fontFamily,
|
|
package: icon.fontPackage,
|
|
shadows: [Shadow(color: color.withOpacity(0.6), blurRadius: 10, offset: const Offset(0, 0))]
|
|
),
|
|
);
|
|
textPainter.layout();
|
|
textPainter.paint(canvas, Offset(rect.center.dx - textPainter.width / 2, rect.center.dy - textPainter.height / 2));
|
|
}
|
|
|
|
void _drawFlameBox(Canvas canvas, Rect baseRect, bool isRed) {
|
|
final rand = Random((baseRect.left + baseRect.top).toInt());
|
|
Offset center = baseRect.center;
|
|
|
|
double w = baseRect.width * 0.35;
|
|
double h = baseRect.height * 0.55;
|
|
|
|
Offset bottomCenter = Offset(center.dx, center.dy + h * 0.5);
|
|
|
|
Color outerColor = isRed ? Colors.red.shade600.withOpacity(0.85) : Colors.blue.shade700.withOpacity(0.85);
|
|
Color midColor = isRed ? Colors.orangeAccent : Colors.lightBlueAccent;
|
|
Color coreColor = isRed ? Colors.yellowAccent : Colors.white;
|
|
|
|
canvas.drawOval(
|
|
Rect.fromCenter(center: bottomCenter, width: w * 1.5, height: w * 0.5),
|
|
Paint()
|
|
..color = Colors.black.withOpacity(0.4)
|
|
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4.0),
|
|
);
|
|
|
|
void drawFlameLayer(double scale, Color color, double tipOffsetX) {
|
|
Path path = Path();
|
|
double fw = w * scale;
|
|
double fh = h * scale;
|
|
|
|
path.moveTo(bottomCenter.dx, bottomCenter.dy);
|
|
|
|
path.cubicTo(
|
|
bottomCenter.dx + fw, bottomCenter.dy,
|
|
bottomCenter.dx + fw * 0.8, bottomCenter.dy - fh * 0.6,
|
|
bottomCenter.dx + tipOffsetX, bottomCenter.dy - fh,
|
|
);
|
|
|
|
path.cubicTo(
|
|
bottomCenter.dx - fw * 0.8, bottomCenter.dy - fh * 0.6,
|
|
bottomCenter.dx - fw, bottomCenter.dy,
|
|
bottomCenter.dx, bottomCenter.dy,
|
|
);
|
|
|
|
canvas.drawPath(
|
|
path,
|
|
Paint()
|
|
..color = color
|
|
..style = PaintingStyle.fill
|
|
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 1.5),
|
|
);
|
|
}
|
|
|
|
double randomTipX = (rand.nextDouble() - 0.5) * w * 0.8;
|
|
|
|
drawFlameLayer(1.0, outerColor, randomTipX);
|
|
drawFlameLayer(0.65, midColor.withOpacity(0.9), randomTipX * 0.6);
|
|
drawFlameLayer(0.35, coreColor.withOpacity(0.9), randomTipX * 0.2);
|
|
}
|
|
|
|
void _drawScribbleBox(Canvas canvas, Rect baseRect, Color color) {
|
|
final rand = Random((baseRect.left + baseRect.top).toInt());
|
|
final paint = Paint()
|
|
..color = color.withOpacity(0.85)
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = 3.5
|
|
..strokeCap = StrokeCap.round
|
|
..strokeJoin = StrokeJoin.round;
|
|
|
|
final path = Path();
|
|
Rect rect = baseRect.deflate(4.0);
|
|
|
|
int numZigs = 15 + rand.nextInt(6);
|
|
double stepY = rect.height / numZigs;
|
|
|
|
path.moveTo(rect.left + rand.nextDouble() * 5, rect.top + rand.nextDouble() * 5);
|
|
|
|
for (int i = 1; i <= numZigs; i++) {
|
|
double targetX = (i % 2 != 0) ? rect.right + (rand.nextDouble() * 4 - 2) : rect.left + (rand.nextDouble() * 4 - 2);
|
|
double targetY = rect.top + stepY * i + (rand.nextDouble() - 0.5) * 3;
|
|
double ctrlX = rect.center.dx + (rand.nextDouble() - 0.5) * 20;
|
|
double ctrlY = targetY - stepY / 2;
|
|
path.quadraticBezierTo(ctrlX, ctrlY, targetX, targetY);
|
|
}
|
|
canvas.drawPath(path, paint);
|
|
}
|
|
|
|
void _drawRealisticMatch(Canvas canvas, Offset p1, Offset p2, Color headColor) {
|
|
int seed = (p1.dx * 1000 + p1.dy).toInt();
|
|
Random rand = Random(seed);
|
|
Vector2 dir = Vector2(p2.dx - p1.dx, p2.dy - p1.dy).normalized();
|
|
double shrink = 8.0;
|
|
Offset start = Offset(p1.dx + dir.x * shrink, p1.dy + dir.y * shrink);
|
|
Offset end = Offset(p2.dx - dir.x * shrink, p2.dy - dir.y * shrink);
|
|
start += Offset(rand.nextDouble() * 4 - 2, rand.nextDouble() * 4 - 2);
|
|
end += Offset(rand.nextDouble() * 4 - 2, rand.nextDouble() * 4 - 2);
|
|
bool headAtEnd = rand.nextBool();
|
|
Offset headPos = headAtEnd ? end : start;
|
|
Offset tailPos = headAtEnd ? start : end;
|
|
Vector2 matchDir = Vector2(headPos.dx - tailPos.dx, headPos.dy - tailPos.dy).normalized();
|
|
canvas.drawLine(tailPos + const Offset(4, 4), headPos + const Offset(4, 4), Paint()..color = Colors.black.withOpacity(0.6)..strokeWidth = 7.0..strokeCap = StrokeCap.round);
|
|
canvas.drawLine(tailPos, headPos, Paint()..color = const Color(0xFF6D4C41)..strokeWidth = 7.0..strokeCap = StrokeCap.round);
|
|
canvas.drawLine(tailPos, headPos, Paint()..color = const Color(0xFFEDC498)..strokeWidth = 4.0..strokeCap = StrokeCap.round);
|
|
Offset burnPos = Offset(headPos.dx - matchDir.x * 8, headPos.dy - matchDir.y * 8);
|
|
canvas.drawLine(burnPos, headPos, Paint()..color = const Color(0xFF2E1A14)..strokeWidth = 6.0..strokeCap = StrokeCap.round);
|
|
canvas.save();
|
|
canvas.translate(headPos.dx, headPos.dy);
|
|
double angle = atan2(matchDir.y, matchDir.x);
|
|
canvas.rotate(angle);
|
|
Rect headOval = Rect.fromCenter(center: Offset.zero, width: 18.0, height: 13.0);
|
|
canvas.drawOval(headOval.shift(const Offset(1, 2)), Paint()..color = Colors.black.withOpacity(0.6));
|
|
canvas.drawOval(headOval, Paint()..color = headColor);
|
|
canvas.restore();
|
|
}
|
|
|
|
void _drawNeonLine(Canvas canvas, Offset p1, Offset p2, Color color, bool isConquered) {
|
|
double mainWidth = isConquered ? 6.0 : 3.0;
|
|
|
|
canvas.drawLine(p1, p2, Paint()
|
|
..color = color.withOpacity(isConquered ? 0.4 : 0.2)
|
|
..strokeWidth = mainWidth * 4
|
|
..strokeCap = StrokeCap.round
|
|
..maskFilter = MaskFilter.blur(BlurStyle.normal, isConquered ? 12.0 : 6.0)
|
|
);
|
|
|
|
if (isConquered) {
|
|
canvas.drawLine(p1, p2, Paint()
|
|
..color = color.withOpacity(0.7)
|
|
..strokeWidth = mainWidth * 2
|
|
..strokeCap = StrokeCap.round
|
|
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 6.0)
|
|
);
|
|
}
|
|
|
|
canvas.drawLine(p1, p2, Paint()
|
|
..color = isConquered ? Colors.white.withOpacity(0.9) : color.withOpacity(0.6)
|
|
..strokeWidth = mainWidth
|
|
..strokeCap = StrokeCap.round
|
|
);
|
|
}
|
|
|
|
void _drawWobblyLine(Canvas canvas, Offset p1, Offset p2, Color color, bool isConquered) {
|
|
final random = Random((p1.dx + p1.dy + p2.dx + p2.dy).toInt());
|
|
final dx = p2.dx - p1.dx;
|
|
final dy = p2.dy - p1.dy;
|
|
|
|
double strokeW = isConquered ? 4.5 : 2.0;
|
|
|
|
final basePaint = Paint()
|
|
..color = color
|
|
..strokeWidth = strokeW
|
|
..style = PaintingStyle.stroke
|
|
..strokeCap = StrokeCap.round;
|
|
|
|
final mid1 = Offset(p1.dx + dx / 2 + (random.nextDouble() - 0.5) * 8, p1.dy + dy / 2 + (random.nextDouble() - 0.5) * 8);
|
|
canvas.drawPath(Path()..moveTo(p1.dx, p1.dy)..quadraticBezierTo(mid1.dx, mid1.dy, p2.dx, p2.dy), basePaint);
|
|
|
|
final mid2 = Offset(p1.dx + dx / 2 + (random.nextDouble() - 0.5) * 6, p1.dy + dy / 2 + (random.nextDouble() - 0.5) * 6);
|
|
canvas.drawPath(Path()..moveTo(p1.dx, p1.dy)..quadraticBezierTo(mid2.dx, mid2.dy, p2.dx, p2.dy), basePaint..strokeWidth = strokeW * 0.5..color = color.withOpacity(0.8));
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant BoardPainter oldDelegate) => true;
|
|
}
|
|
|
|
class Vector2 {
|
|
final double x, y;
|
|
Vector2(this.x, this.y);
|
|
double get length => sqrt(x * x + y * y);
|
|
Vector2 normalized() {
|
|
double l = length;
|
|
return l == 0 ? Vector2(0, 0) : Vector2(x / l, y / l);
|
|
}
|
|
}
|
|
// ===========================================================================
|
|
// FILE: lib/ui/game/game_screen.dart
|
|
// ===========================================================================
|
|
|
|
import 'dart:ui';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
import '../../logic/game_controller.dart';
|
|
import '../../core/theme_manager.dart';
|
|
import '../../core/app_colors.dart';
|
|
import 'board_painter.dart';
|
|
import 'score_board.dart';
|
|
import '../../widgets/game_over_dialog.dart';
|
|
import '../../models/game_board.dart';
|
|
|
|
class GameScreen extends StatelessWidget {
|
|
const GameScreen({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final themeManager = context.watch<ThemeManager>();
|
|
final themeType = themeManager.currentThemeType;
|
|
final theme = themeManager.currentColors;
|
|
final gameController = context.watch<GameController>();
|
|
|
|
// --- CONTROLLO EVENTI DI FINE PARTITA O ABBANDONO ---
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (gameController.opponentLeft) {
|
|
showDialog(
|
|
barrierDismissible: false,
|
|
context: context,
|
|
builder: (dialogContext) => AlertDialog(
|
|
backgroundColor: theme.background,
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
|
title: Text("VITTORIA A TAVOLINO!", textAlign: TextAlign.center, style: TextStyle(color: theme.playerRed, fontWeight: FontWeight.bold)),
|
|
content: Text("L'avversario ha abbandonato la stanza.\nSei il vincitore incontestato!", textAlign: TextAlign.center, style: TextStyle(color: theme.text, fontSize: 16)),
|
|
actionsAlignment: MainAxisAlignment.center,
|
|
actions: [
|
|
ElevatedButton(
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: theme.playerBlue,
|
|
foregroundColor: Colors.white,
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15))
|
|
),
|
|
onPressed: () {
|
|
gameController.disconnectOnlineGame();
|
|
Navigator.pop(dialogContext);
|
|
Navigator.pop(context);
|
|
},
|
|
child: const Text("MENU PRINCIPALE", style: TextStyle(fontWeight: FontWeight.bold)),
|
|
)
|
|
],
|
|
)
|
|
);
|
|
} else if (gameController.board.isGameOver) {
|
|
showDialog(barrierDismissible: false, context: context, builder: (_) => const GameOverDialog());
|
|
}
|
|
});
|
|
|
|
String? bgImage;
|
|
if (themeType == AppThemeType.wood) bgImage = 'assets/images/wood_bg.jpg';
|
|
if (themeType == AppThemeType.doodle) bgImage = 'assets/images/doodle_bg.jpg';
|
|
|
|
Color indicatorColor = themeType == AppThemeType.cyberpunk ? Colors.white : Colors.black;
|
|
|
|
// --- IL CONTENUTO PRINCIPALE (Plancia e UI) ---
|
|
Widget gameContent = SafeArea(
|
|
child: Column(
|
|
children: [
|
|
const ScoreBoard(),
|
|
|
|
Expanded(
|
|
child: Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(10.0),
|
|
child: AspectRatio(
|
|
aspectRatio: 1,
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
return GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTapDown: (details) => _handleTap(details.localPosition, constraints.maxWidth, gameController, themeType),
|
|
child: CustomPaint(
|
|
size: Size(constraints.maxWidth, constraints.maxHeight),
|
|
painter: BoardPainter(
|
|
board: gameController.board,
|
|
theme: theme,
|
|
themeType: themeType,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
// --- ZONA INFERIORE: INDICATORE A SX, PULSANTE ESCI A DX ---
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 20.0, left: 20.0, right: 20.0),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
if (gameController.isVsCPU)
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
color: indicatorColor.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(color: indicatorColor.withOpacity(0.3)),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.smart_toy_rounded, size: 16, color: indicatorColor),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
"LIVELLO CPU: ${gameController.cpuLevel}",
|
|
style: TextStyle(
|
|
color: indicatorColor,
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 13,
|
|
letterSpacing: 1.0,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
)
|
|
else
|
|
const SizedBox(),
|
|
|
|
// --- PULSANTE ESCI ---
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(20),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.4),
|
|
offset: const Offset(0, 4),
|
|
blurRadius: 5,
|
|
),
|
|
],
|
|
),
|
|
child: TextButton.icon(
|
|
style: TextButton.styleFrom(
|
|
backgroundColor: bgImage != null ? Colors.black87 : theme.background,
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(20),
|
|
side: BorderSide(color: Colors.white.withOpacity(0.1), width: 1),
|
|
)
|
|
),
|
|
icon: Icon(Icons.exit_to_app, color: bgImage != null ? Colors.white : theme.text, size: 20),
|
|
onPressed: () {
|
|
gameController.disconnectOnlineGame();
|
|
Navigator.pop(context);
|
|
},
|
|
label: Text("ESCI", style: TextStyle(color: bgImage != null ? Colors.white : theme.text, fontWeight: FontWeight.bold, fontSize: 14)),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
)
|
|
],
|
|
),
|
|
);
|
|
|
|
// --- STRUTTURA A LIVELLI (STACK) DELLO SCHERMO ---
|
|
return PopScope(
|
|
canPop: true,
|
|
onPopInvoked: (didPop) {
|
|
gameController.disconnectOnlineGame();
|
|
},
|
|
child: Scaffold(
|
|
backgroundColor: bgImage != null ? Colors.transparent : theme.background,
|
|
body: CustomPaint(
|
|
painter: themeType == AppThemeType.minimal
|
|
? FullScreenGridPainter(Colors.black.withOpacity(0.06))
|
|
: null,
|
|
child: Container(
|
|
decoration: bgImage != null
|
|
? BoxDecoration(
|
|
image: DecorationImage(
|
|
image: AssetImage(bgImage),
|
|
fit: BoxFit.cover,
|
|
colorFilter: themeType == AppThemeType.doodle
|
|
? ColorFilter.mode(Colors.white.withOpacity(0.7), BlendMode.lighten)
|
|
: null,
|
|
),
|
|
)
|
|
: null,
|
|
|
|
// ==========================================
|
|
// LA MAGIA DELLO SFARFALLIO AVVIENE QUI
|
|
// ==========================================
|
|
child: Stack(
|
|
children: [
|
|
// EFFETTO 1: TIMER LAMPEGGIANTE (Se restano 5 secondi)
|
|
if (!gameController.isCPUThinking && !gameController.isGameOver && gameController.timeLeft > 0 && gameController.timeLeft <= 5)
|
|
Positioned.fill(
|
|
child: BlitzBackgroundEffect(
|
|
timeLeft: gameController.timeLeft,
|
|
color: theme.playerRed, // Rosso Allarme!
|
|
),
|
|
),
|
|
|
|
// --- NUOVO EFFETTO 2: POPUP ORO (+2) E BOMBA (-1) ---
|
|
if (gameController.effectText.isNotEmpty)
|
|
Positioned.fill(
|
|
child: SpecialEventBackgroundEffect(
|
|
text: gameController.effectText,
|
|
color: gameController.effectColor,
|
|
),
|
|
),
|
|
|
|
// LA PLANCIA DI GIOCO SOPRA TUTTI GLI EFFETTI
|
|
Positioned.fill(
|
|
child: gameContent,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ==========================================
|
|
// NUOVA GESTIONE DEI TOCCHI E DELLE DISTANZE
|
|
// ==========================================
|
|
void _handleTap(Offset tapPos, double size, GameController controller, AppThemeType themeType) {
|
|
final board = controller.board;
|
|
if (board.isGameOver) return;
|
|
|
|
int gridPoints = board.radius * 2 + 2;
|
|
double spacing = size / gridPoints;
|
|
double offset = spacing / 2;
|
|
|
|
Line? closestLine;
|
|
double minDistance = double.infinity;
|
|
double maxTouchDistance = spacing * 0.4;
|
|
|
|
// Scorriamo TUTTE le linee valide della plancia
|
|
for (var line in board.lines) {
|
|
// FIX FONDAMENTALE: Ignoriamo le linee invisibili o già prese!
|
|
if (line.owner != Player.none || !line.isPlayable) continue;
|
|
|
|
Offset screenP1 = Offset(line.p1.x * spacing + offset, line.p1.y * spacing + offset);
|
|
Offset screenP2 = Offset(line.p2.x * spacing + offset, line.p2.y * spacing + offset);
|
|
|
|
double dist = _distanceToSegment(tapPos, screenP1, screenP2);
|
|
|
|
if (dist < minDistance && dist < maxTouchDistance) {
|
|
minDistance = dist;
|
|
closestLine = line;
|
|
}
|
|
}
|
|
|
|
if (closestLine != null) {
|
|
controller.handleLineTap(closestLine, themeType);
|
|
}
|
|
}
|
|
|
|
// Calcolo della distanza su coordinate schermo reali
|
|
double _distanceToSegment(Offset p, Offset a, Offset b) {
|
|
double l2 = (a.dx - b.dx) * (a.dx - b.dx) + (a.dy - b.dy) * (a.dy - b.dy);
|
|
if (l2 == 0) return (p - a).distance;
|
|
double t = (((p.dx - a.dx) * (b.dx - a.dx) + (p.dy - a.dy) * (b.dy - a.dy)) / l2).clamp(0.0, 1.0);
|
|
Offset projection = Offset(a.dx + t * (b.dx - a.dx), a.dy + t * (b.dy - a.dy));
|
|
return (p - projection).distance;
|
|
}
|
|
}
|
|
|
|
// --- CLASSE PER LO SFONDO A QUADRETTI ---
|
|
class FullScreenGridPainter extends CustomPainter {
|
|
final Color gridColor;
|
|
FullScreenGridPainter(this.gridColor);
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final Paint paperGridPaint = Paint()
|
|
..color = gridColor
|
|
..strokeWidth = 1.0
|
|
..style = PaintingStyle.stroke;
|
|
|
|
double paperStep = 20.0;
|
|
for (double i = 0; i <= size.width; i += paperStep) {
|
|
canvas.drawLine(Offset(i, 0), Offset(i, size.height), paperGridPaint);
|
|
}
|
|
for (double i = 0; i <= size.height; i += paperStep) {
|
|
canvas.drawLine(Offset(0, i), Offset(size.width, i), paperGridPaint);
|
|
}
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
|
}
|
|
|
|
// ==============================================================
|
|
// WIDGET ANIMATO PER IL TIMER (BATTITO CARDIACO / ALLARME)
|
|
// ==============================================================
|
|
class BlitzBackgroundEffect extends StatefulWidget {
|
|
final int timeLeft;
|
|
final Color color;
|
|
|
|
const BlitzBackgroundEffect({super.key, required this.timeLeft, required this.color});
|
|
|
|
@override
|
|
State<BlitzBackgroundEffect> createState() => _BlitzBackgroundEffectState();
|
|
}
|
|
|
|
class _BlitzBackgroundEffectState extends State<BlitzBackgroundEffect> with SingleTickerProviderStateMixin {
|
|
late AnimationController _controller;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 400))..repeat(reverse: true);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AnimatedBuilder(
|
|
animation: _controller,
|
|
builder: (context, child) {
|
|
return Container(
|
|
color: widget.color.withOpacity(0.12 * _controller.value),
|
|
child: Center(
|
|
child: ImageFiltered(
|
|
imageFilter: ImageFilter.blur(sigmaX: 2.0, sigmaY: 2.0),
|
|
child: Text(
|
|
'${widget.timeLeft}',
|
|
style: TextStyle(
|
|
fontSize: 300,
|
|
fontWeight: FontWeight.w900,
|
|
color: widget.color.withOpacity(0.35 + (0.3 * _controller.value)),
|
|
height: 1.0,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
// ==============================================================
|
|
// WIDGET ANIMATO PER LE CASELLE SPECIALI (+2 / -1)
|
|
// ==============================================================
|
|
class SpecialEventBackgroundEffect extends StatefulWidget {
|
|
final String text;
|
|
final Color color;
|
|
|
|
const SpecialEventBackgroundEffect({super.key, required this.text, required this.color});
|
|
|
|
@override
|
|
State<SpecialEventBackgroundEffect> createState() => _SpecialEventBackgroundEffectState();
|
|
}
|
|
|
|
class _SpecialEventBackgroundEffectState extends State<SpecialEventBackgroundEffect> with SingleTickerProviderStateMixin {
|
|
late AnimationController _controller;
|
|
late Animation<double> _scaleAnimation;
|
|
late Animation<double> _opacityAnimation;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 1000))..forward();
|
|
|
|
_scaleAnimation = Tween<double>(begin: 0.5, end: 1.5).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic));
|
|
_opacityAnimation = Tween<double>(begin: 0.9, end: 0.0).animate(CurvedAnimation(parent: _controller, curve: Curves.easeIn));
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(covariant SpecialEventBackgroundEffect oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (oldWidget.text != widget.text) {
|
|
_controller.reset();
|
|
_controller.forward();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AnimatedBuilder(
|
|
animation: _controller,
|
|
builder: (context, child) {
|
|
return Center(
|
|
child: Transform.scale(
|
|
scale: _scaleAnimation.value,
|
|
child: Opacity(
|
|
opacity: _opacityAnimation.value,
|
|
child: ImageFiltered(
|
|
imageFilter: ImageFilter.blur(sigmaX: 3.0, sigmaY: 3.0),
|
|
child: Text(
|
|
widget.text,
|
|
style: TextStyle(
|
|
fontSize: 250,
|
|
fontWeight: FontWeight.w900,
|
|
color: widget.color,
|
|
height: 1.0,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
// ===========================================================================
|
|
// FILE: lib/ui/game/score_board.dart
|
|
// ===========================================================================
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
import '../../logic/game_controller.dart';
|
|
import '../../models/game_board.dart';
|
|
import '../../core/theme_manager.dart';
|
|
import '../../services/audio_service.dart';
|
|
import '../../core/app_colors.dart';
|
|
|
|
class ScoreBoard extends StatefulWidget {
|
|
const ScoreBoard({super.key});
|
|
|
|
@override
|
|
State<ScoreBoard> createState() => _ScoreBoardState();
|
|
}
|
|
|
|
class _ScoreBoardState extends State<ScoreBoard> {
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final controller = context.watch<GameController>();
|
|
final themeManager = context.watch<ThemeManager>();
|
|
final theme = themeManager.currentColors;
|
|
final themeType = themeManager.currentThemeType;
|
|
|
|
int redScore = controller.board.scoreRed;
|
|
int blueScore = controller.board.scoreBlue;
|
|
|
|
bool isRedTurn = controller.board.currentPlayer == Player.red;
|
|
bool isMuted = AudioService.instance.isMuted;
|
|
|
|
// --- LOGICA PER I NOMI ---
|
|
String nameRed = "ROSSO";
|
|
String nameBlue = themeType == AppThemeType.cyberpunk ? "VERDE" : "BLU";
|
|
|
|
if (controller.isOnline) {
|
|
nameRed = controller.onlineHostName.toUpperCase();
|
|
nameBlue = controller.onlineGuestName.toUpperCase();
|
|
} else if (controller.isVsCPU) {
|
|
nameRed = "TU";
|
|
nameBlue = "CPU";
|
|
}
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.only(top: 10, bottom: 20, left: 20, right: 20),
|
|
decoration: BoxDecoration(
|
|
color: theme.background.withOpacity(0.95),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.3),
|
|
offset: const Offset(0, 4),
|
|
blurRadius: 8,
|
|
),
|
|
],
|
|
borderRadius: const BorderRadius.only(
|
|
bottomLeft: Radius.circular(30),
|
|
bottomRight: Radius.circular(30),
|
|
),
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
_PlayerScore(color: theme.playerRed, score: redScore, isTurn: isRedTurn, textColor: theme.text, title: nameRed),
|
|
|
|
Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
"TETRAQ",
|
|
style: TextStyle(
|
|
fontSize: 24,
|
|
fontWeight: FontWeight.w900,
|
|
color: theme.text,
|
|
letterSpacing: 4,
|
|
shadows: [Shadow(color: Colors.black.withOpacity(0.3), offset: const Offset(1, 2), blurRadius: 2)]
|
|
)
|
|
),
|
|
IconButton(
|
|
icon: Icon(isMuted ? Icons.volume_off : Icons.volume_up, color: theme.text.withOpacity(0.7)),
|
|
onPressed: () {
|
|
setState(() {
|
|
AudioService.instance.toggleMute();
|
|
});
|
|
},
|
|
),
|
|
],
|
|
),
|
|
|
|
_PlayerScore(color: theme.playerBlue, score: blueScore, isTurn: !isRedTurn, textColor: theme.text, title: nameBlue),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PlayerScore extends StatelessWidget {
|
|
final Color color;
|
|
final int score;
|
|
final bool isTurn;
|
|
final Color textColor;
|
|
final String title;
|
|
|
|
const _PlayerScore({required this.color, required this.score, required this.isTurn, required this.textColor, required this.title});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(title, style: TextStyle(fontWeight: FontWeight.bold, color: isTurn ? color : textColor.withOpacity(0.5), fontSize: 12)),
|
|
const SizedBox(height: 5),
|
|
AnimatedContainer(
|
|
duration: const Duration(milliseconds: 300),
|
|
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10),
|
|
decoration: BoxDecoration(
|
|
color: color.withOpacity(isTurn ? 1.0 : 0.2),
|
|
borderRadius: BorderRadius.circular(15),
|
|
border: isTurn ? Border.all(color: Colors.white.withOpacity(0.4), width: 2) : Border.all(color: Colors.transparent, width: 2),
|
|
boxShadow: isTurn ? [
|
|
BoxShadow(color: color.withOpacity(0.5), offset: const Offset(0, 4), blurRadius: 6)
|
|
] : [],
|
|
),
|
|
child: Text('$score', style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: isTurn ? Colors.white : textColor.withOpacity(0.5))),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
// ===========================================================================
|
|
// FILE: lib/ui/home/history_screen.dart
|
|
// ===========================================================================
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:intl/intl.dart';
|
|
import '../../core/theme_manager.dart';
|
|
import '../../services/storage_service.dart';
|
|
|
|
class HistoryScreen extends StatelessWidget {
|
|
const HistoryScreen({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = context.watch<ThemeManager>().currentColors;
|
|
final history = StorageService.instance.matchHistory;
|
|
|
|
return Scaffold(
|
|
backgroundColor: theme.background,
|
|
appBar: AppBar(
|
|
title: Text("STORICO PARTITE", style: TextStyle(fontWeight: FontWeight.w900, color: theme.text, letterSpacing: 2)),
|
|
backgroundColor: Colors.transparent,
|
|
elevation: 0,
|
|
iconTheme: IconThemeData(color: theme.text),
|
|
),
|
|
body: history.isEmpty
|
|
? Center(
|
|
child: Text(
|
|
"Nessuna partita giocata.\nScendi in campo!",
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(color: theme.text.withOpacity(0.5), fontSize: 18, fontWeight: FontWeight.bold),
|
|
),
|
|
)
|
|
: ListView.builder(
|
|
padding: const EdgeInsets.all(20),
|
|
itemCount: history.length,
|
|
itemBuilder: (context, index) {
|
|
final match = history[index];
|
|
|
|
DateTime date = DateTime.parse(match['date']);
|
|
String formattedDate = DateFormat('dd MMM yyyy - HH:mm').format(date);
|
|
|
|
// Leggiamo entrambi i nomi
|
|
String myName = match['myName'] ?? "IO"; // Usa 'IO' se è una partita vecchia
|
|
String opponent = match['opponent'];
|
|
|
|
int myScore = match['myScore'];
|
|
int oppScore = match['oppScore'];
|
|
bool isOnline = match['isOnline'];
|
|
|
|
bool isWin = myScore > oppScore;
|
|
bool isDraw = myScore == oppScore;
|
|
|
|
Color resultColor = isWin ? Colors.green : (isDraw ? Colors.grey : theme.playerRed);
|
|
String resultText = isWin ? "VITTORIA" : (isDraw ? "PAREGGIO" : "SCONFITTA");
|
|
|
|
return Container(
|
|
margin: const EdgeInsets.only(bottom: 15),
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: theme.text.withOpacity(0.05),
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(color: resultColor.withOpacity(0.5), width: 2),
|
|
boxShadow: [
|
|
BoxShadow(color: Colors.black.withOpacity(0.2), offset: const Offset(0, 4), blurRadius: 6),
|
|
],
|
|
),
|
|
child: Row(
|
|
children: [
|
|
// Icona Tipo di Partita
|
|
Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: resultColor.withOpacity(0.1),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Icon(
|
|
isOnline ? Icons.public : (opponent.contains("CPU") ? Icons.smart_toy : Icons.people_alt),
|
|
color: resultColor,
|
|
size: 28,
|
|
),
|
|
),
|
|
const SizedBox(width: 15),
|
|
|
|
// Dati Partita (Ora con i nomi chiari)
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(resultText, style: TextStyle(color: resultColor, fontWeight: FontWeight.w900, fontSize: 16, letterSpacing: 1.5)),
|
|
const SizedBox(height: 5),
|
|
// NOMI GIOCATORI
|
|
RichText(
|
|
text: TextSpan(
|
|
children: [
|
|
TextSpan(text: myName, style: TextStyle(color: theme.playerBlue, fontWeight: FontWeight.bold, fontSize: 15)),
|
|
TextSpan(text: " vs ", style: TextStyle(color: theme.text.withOpacity(0.5), fontStyle: FontStyle.italic, fontSize: 12)),
|
|
TextSpan(text: opponent, style: TextStyle(color: theme.playerRed, fontWeight: FontWeight.bold, fontSize: 15)),
|
|
]
|
|
)
|
|
),
|
|
const SizedBox(height: 5),
|
|
Text(formattedDate, style: TextStyle(color: theme.text.withOpacity(0.5), fontSize: 12)),
|
|
],
|
|
),
|
|
),
|
|
|
|
// Punteggio
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
color: theme.background,
|
|
borderRadius: BorderRadius.circular(15),
|
|
border: Border.all(color: theme.gridLine.withOpacity(0.3)),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Text("$myScore", style: TextStyle(fontSize: 22, fontWeight: FontWeight.w900, color: theme.playerBlue)),
|
|
Text(" - ", style: TextStyle(fontSize: 18, color: theme.text.withOpacity(0.5))),
|
|
Text("$oppScore", style: TextStyle(fontSize: 22, fontWeight: FontWeight.w900, color: theme.playerRed)),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
// ===========================================================================
|
|
// FILE: lib/ui/home/home_screen.dart
|
|
// ===========================================================================
|
|
|
|
// ===========================================================================
|
|
// FILE: lib/ui/home/home_screen.dart
|
|
// ===========================================================================
|
|
|
|
import 'dart:ui';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'dart:math' as math;
|
|
import '../../core/theme_manager.dart';
|
|
import '../../core/app_colors.dart';
|
|
import '../../models/game_board.dart';
|
|
import '../game/game_screen.dart';
|
|
import '../settings/settings_screen.dart';
|
|
import '../../logic/game_controller.dart';
|
|
import '../../services/storage_service.dart';
|
|
import '../multiplayer/lobby_screen.dart';
|
|
import 'history_screen.dart';
|
|
|
|
// --- NUOVI WIDGET PERSONALIZZATI ---
|
|
|
|
class _NeonShapeButton extends StatelessWidget {
|
|
final IconData icon;
|
|
final String label;
|
|
final bool isSelected;
|
|
final ThemeColors theme;
|
|
final VoidCallback onTap;
|
|
|
|
const _NeonShapeButton({required this.icon, required this.label, required this.isSelected, required this.theme, required this.onTap});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GestureDetector(
|
|
onTap: onTap,
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 300),
|
|
curve: Curves.easeInOut,
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(15),
|
|
color: isSelected ? theme.playerBlue.withOpacity(0.2) : theme.text.withOpacity(0.05),
|
|
border: Border.all(
|
|
color: isSelected ? theme.playerBlue : theme.gridLine.withOpacity(0.3),
|
|
width: isSelected ? 2 : 1,
|
|
),
|
|
boxShadow: isSelected ? [BoxShadow(color: theme.playerBlue.withOpacity(0.4), blurRadius: 12, spreadRadius: 2)] : [],
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(icon, color: isSelected ? theme.playerBlue : theme.text.withOpacity(0.5), size: 24),
|
|
const SizedBox(height: 6),
|
|
Text(label, style: TextStyle(color: isSelected ? theme.text : theme.text.withOpacity(0.5), fontSize: 11, fontWeight: isSelected ? FontWeight.bold : FontWeight.normal)),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _NeonSizeButton extends StatelessWidget {
|
|
final String label;
|
|
final bool isSelected;
|
|
final ThemeColors theme;
|
|
final VoidCallback onTap;
|
|
|
|
const _NeonSizeButton({required this.label, required this.isSelected, required this.theme, required this.onTap});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GestureDetector(
|
|
onTap: onTap,
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 300),
|
|
curve: Curves.easeInOut,
|
|
width: 50,
|
|
height: 50,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: isSelected ? theme.playerRed.withOpacity(0.2) : theme.text.withOpacity(0.05),
|
|
border: Border.all(
|
|
color: isSelected ? theme.playerRed : theme.gridLine.withOpacity(0.3),
|
|
width: isSelected ? 2 : 1,
|
|
),
|
|
boxShadow: isSelected ? [BoxShadow(color: theme.playerRed.withOpacity(0.4), blurRadius: 10, spreadRadius: 1.5)] : [],
|
|
),
|
|
child: Center(
|
|
child: Text(label, style: TextStyle(color: isSelected ? theme.text : theme.text.withOpacity(0.5), fontSize: 14, fontWeight: isSelected ? FontWeight.w900 : FontWeight.bold)),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _NeonTimeSwitch extends StatelessWidget {
|
|
final bool isTimeMode;
|
|
final ThemeColors theme;
|
|
final VoidCallback onTap;
|
|
|
|
const _NeonTimeSwitch({required this.isTimeMode, required this.theme, required this.onTap});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GestureDetector(
|
|
onTap: onTap,
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 300),
|
|
curve: Curves.easeInOut,
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(20),
|
|
color: isTimeMode ? Colors.amber.withOpacity(0.2) : theme.text.withOpacity(0.05),
|
|
border: Border.all(
|
|
color: isTimeMode ? Colors.amber : theme.gridLine.withOpacity(0.3),
|
|
width: isTimeMode ? 2 : 1,
|
|
),
|
|
boxShadow: isTimeMode ? [BoxShadow(color: Colors.amber.withOpacity(0.4), blurRadius: 12, spreadRadius: 2)] : [],
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.max,
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(isTimeMode ? Icons.timer : Icons.timer_off, color: isTimeMode ? Colors.amber : theme.text.withOpacity(0.5), size: 28),
|
|
const SizedBox(width: 12),
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
isTimeMode ? 'A TEMPO' : 'RELAX',
|
|
style: TextStyle(color: isTimeMode ? theme.text : theme.text.withOpacity(0.5), fontWeight: FontWeight.w900, fontSize: 14, letterSpacing: 1.5)
|
|
),
|
|
Text(
|
|
isTimeMode ? '15 sec a mossa' : 'Nessun limite di tempo',
|
|
style: TextStyle(color: isTimeMode ? Colors.amber.shade200 : theme.text.withOpacity(0.4), fontSize: 11, fontWeight: FontWeight.bold)
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class HomeScreen extends StatefulWidget {
|
|
const HomeScreen({super.key});
|
|
|
|
@override
|
|
State<HomeScreen> createState() => _HomeScreenState();
|
|
}
|
|
|
|
class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
|
|
// Il raggio di partenza è 4 (Taglia M) come richiesto
|
|
int _selectedRadius = 4;
|
|
ArenaShape _selectedShape = ArenaShape.classic;
|
|
bool _isTimeMode = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addObserver(this);
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_checkPlayerName();
|
|
});
|
|
_checkClipboardForInvite();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
if (state == AppLifecycleState.resumed) {
|
|
_checkClipboardForInvite();
|
|
}
|
|
}
|
|
|
|
void _checkPlayerName() {
|
|
if (StorageService.instance.playerName.isEmpty) {
|
|
_showNameDialog();
|
|
}
|
|
}
|
|
|
|
void _showNameDialog() {
|
|
final TextEditingController nameController = TextEditingController();
|
|
showDialog(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
barrierColor: Colors.black.withOpacity(0.8),
|
|
builder: (context) {
|
|
final themeManager = context.watch<ThemeManager>();
|
|
final theme = themeManager.currentColors;
|
|
final themeType = themeManager.currentThemeType;
|
|
|
|
Widget dialogContent;
|
|
|
|
if (themeType == AppThemeType.cyberpunk) {
|
|
dialogContent = _AnimatedCyberBorder(child: _buildDialogContent(context, theme, nameController));
|
|
} else if (themeType == AppThemeType.wood) {
|
|
dialogContent = Container(
|
|
decoration: BoxDecoration(
|
|
color: theme.background,
|
|
borderRadius: BorderRadius.circular(25),
|
|
border: Border.all(color: const Color(0xFF8B4513), width: 5),
|
|
boxShadow: [
|
|
BoxShadow(color: Colors.black.withOpacity(0.6), blurRadius: 15, offset: const Offset(0, 8)),
|
|
const BoxShadow(color: Color(0xFFD2691E), blurRadius: 0, spreadRadius: 2, offset: Offset(0, 0)),
|
|
],
|
|
),
|
|
padding: const EdgeInsets.all(5),
|
|
child: _buildDialogContent(context, theme, nameController),
|
|
);
|
|
} else if (themeType == AppThemeType.doodle) {
|
|
dialogContent = Container(
|
|
decoration: BoxDecoration(
|
|
color: theme.background,
|
|
borderRadius: BorderRadius.circular(25),
|
|
border: Border.all(color: theme.text, width: 3, style: BorderStyle.solid),
|
|
boxShadow: [
|
|
BoxShadow(color: theme.text.withOpacity(0.3), blurRadius: 0, offset: const Offset(4, 4)),
|
|
BoxShadow(color: theme.text.withOpacity(0.3), blurRadius: 0, offset: const Offset(-2, -2)),
|
|
],
|
|
),
|
|
padding: const EdgeInsets.all(4),
|
|
child: _buildDialogContent(context, theme, nameController),
|
|
);
|
|
} else {
|
|
dialogContent = Container(
|
|
decoration: BoxDecoration(
|
|
color: theme.background,
|
|
borderRadius: BorderRadius.circular(25),
|
|
border: Border.all(color: theme.playerBlue.withOpacity(0.5), width: 2),
|
|
boxShadow: [
|
|
BoxShadow(color: theme.playerBlue.withOpacity(0.3), blurRadius: 20, spreadRadius: 5),
|
|
]
|
|
),
|
|
child: _buildDialogContent(context, theme, nameController),
|
|
);
|
|
}
|
|
|
|
return Dialog(backgroundColor: Colors.transparent, insetPadding: const EdgeInsets.all(20), child: dialogContent);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildDialogContent(BuildContext context, ThemeColors theme, TextEditingController controller) {
|
|
return SingleChildScrollView(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 30.0, horizontal: 25.0),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text('BENVENUTO IN TETRAQ!', style: TextStyle(color: theme.text, fontWeight: FontWeight.w900, fontSize: 24, letterSpacing: 1.5), textAlign: TextAlign.center),
|
|
const SizedBox(height: 20),
|
|
Text('Scegli il tuo nome da battaglia per sfidare i tuoi amici online.', style: TextStyle(color: theme.text.withOpacity(0.8), fontSize: 16), textAlign: TextAlign.center),
|
|
const SizedBox(height: 40),
|
|
TextField(
|
|
controller: controller,
|
|
textCapitalization: TextCapitalization.characters,
|
|
textAlign: TextAlign.center,
|
|
maxLength: 5,
|
|
style: TextStyle(color: theme.text, fontSize: 28, fontWeight: FontWeight.bold, letterSpacing: 4),
|
|
decoration: InputDecoration(
|
|
hintText: 'NOME', hintStyle: TextStyle(color: theme.text.withOpacity(0.3), letterSpacing: 4),
|
|
filled: true, fillColor: theme.text.withOpacity(0.05), counterText: "",
|
|
enabledBorder: OutlineInputBorder(borderSide: BorderSide(color: theme.gridLine.withOpacity(0.5), width: 2), borderRadius: BorderRadius.circular(15)),
|
|
focusedBorder: OutlineInputBorder(borderSide: BorderSide(color: theme.playerBlue, width: 3), borderRadius: BorderRadius.circular(15)),
|
|
),
|
|
),
|
|
const SizedBox(height: 40),
|
|
SizedBox(
|
|
width: double.infinity, height: 55,
|
|
child: ElevatedButton(
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: theme.playerBlue, foregroundColor: Colors.white,
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
|
|
elevation: 8, shadowColor: theme.playerBlue.withOpacity(0.5),
|
|
),
|
|
onPressed: () {
|
|
final name = controller.text.trim();
|
|
if (name.isNotEmpty) {
|
|
StorageService.instance.savePlayerName(name);
|
|
Navigator.of(context).pop();
|
|
setState(() {});
|
|
}
|
|
},
|
|
child: const Text('SALVA E GIOCA', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, letterSpacing: 1.5)),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _checkClipboardForInvite() async {
|
|
try {
|
|
ClipboardData? data = await Clipboard.getData(Clipboard.kTextPlain);
|
|
String? text = data?.text;
|
|
|
|
if (text != null && text.contains("TetraQ") && text.contains("codice:")) {
|
|
RegExp regExp = RegExp(r'codice:\s*([A-Z0-9]{5})', caseSensitive: false);
|
|
Match? match = regExp.firstMatch(text);
|
|
|
|
if (match != null) {
|
|
String roomCode = match.group(1)!.toUpperCase();
|
|
await Clipboard.setData(const ClipboardData(text: ''));
|
|
if (mounted && ModalRoute.of(context)?.isCurrent == true) {
|
|
_promptJoinRoom(roomCode);
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
debugPrint("Errore lettura appunti: $e");
|
|
}
|
|
}
|
|
|
|
void _promptJoinRoom(String roomCode) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) {
|
|
final theme = context.watch<ThemeManager>().currentColors;
|
|
return AlertDialog(
|
|
backgroundColor: theme.background,
|
|
title: Text("Invito Trovato!", style: TextStyle(color: theme.text, fontWeight: FontWeight.bold)),
|
|
content: Text("Vuoi unirti alla stanza $roomCode?", style: TextStyle(color: theme.text)),
|
|
actions: [
|
|
TextButton(onPressed: () => Navigator.pop(context), child: const Text("No", style: TextStyle(color: Colors.red))),
|
|
ElevatedButton(
|
|
style: ElevatedButton.styleFrom(backgroundColor: theme.playerBlue),
|
|
onPressed: () {
|
|
Navigator.of(context).pop();
|
|
Navigator.push(context, MaterialPageRoute(builder: (_) => LobbyScreen(initialRoomCode: roomCode, selectedRadius: _selectedRadius, selectedShape: _selectedShape)));
|
|
},
|
|
child: const Text("Unisciti", style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// --- RIPRISTINATO: Forza la modalità a schermo intero e l'orientamento verticale ---
|
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
|
SystemChrome.setPreferredOrientations([
|
|
DeviceOrientation.portraitUp,
|
|
DeviceOrientation.portraitDown,
|
|
]);
|
|
|
|
final themeManager = context.watch<ThemeManager>();
|
|
final themeType = themeManager.currentThemeType;
|
|
final theme = themeManager.currentColors;
|
|
|
|
String? bgImage;
|
|
if (themeType == AppThemeType.wood) bgImage = 'assets/images/wood_bg.jpg';
|
|
if (themeType == AppThemeType.doodle) bgImage = 'assets/images/doodle_bg.jpg';
|
|
|
|
int wins = StorageService.instance.wins;
|
|
int losses = StorageService.instance.losses;
|
|
String playerName = StorageService.instance.playerName;
|
|
if (playerName.isEmpty) playerName = "GUEST";
|
|
|
|
Widget uiContent = SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 10.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
CircleAvatar(
|
|
backgroundColor: theme.playerBlue.withOpacity(0.2),
|
|
radius: 25,
|
|
child: Icon(Icons.person, color: theme.playerBlue, size: 30),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text("BENVENUTO,", style: TextStyle(color: theme.text.withOpacity(0.5), fontSize: 10, fontWeight: FontWeight.bold, letterSpacing: 1.2)),
|
|
Text(playerName, style: TextStyle(color: theme.text, fontSize: 18, fontWeight: FontWeight.w900, letterSpacing: 1.5)),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
color: theme.text.withOpacity(0.05),
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(color: theme.gridLine.withOpacity(0.2)),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.emoji_events, color: Colors.amber.shade600, size: 18),
|
|
const SizedBox(width: 4),
|
|
Text("$wins", style: TextStyle(color: theme.text, fontWeight: FontWeight.bold)),
|
|
const SizedBox(width: 10),
|
|
Icon(Icons.sentiment_very_dissatisfied, color: theme.playerRed.withOpacity(0.8), size: 18),
|
|
const SizedBox(width: 4),
|
|
Text("$losses", style: TextStyle(color: theme.text, fontWeight: FontWeight.bold)),
|
|
],
|
|
),
|
|
)
|
|
],
|
|
),
|
|
|
|
const SizedBox(height: 20),
|
|
|
|
Center(
|
|
child: Column(
|
|
children: [
|
|
Text(
|
|
"TETRAQ",
|
|
style: TextStyle(
|
|
fontSize: 50,
|
|
fontWeight: FontWeight.w900,
|
|
color: theme.text,
|
|
letterSpacing: 8,
|
|
shadows: [Shadow(color: Colors.black.withOpacity(0.3), offset: const Offset(2, 4), blurRadius: 4)]
|
|
)
|
|
),
|
|
Text(
|
|
"THE ROMBUS PUZZLE",
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.bold,
|
|
color: theme.gridLine,
|
|
letterSpacing: 3,
|
|
)
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
const SizedBox(height: 20),
|
|
|
|
// --- PANNELLO IMPOSTAZIONI ARENA CON LOGICA GRANDEZZA CORRETTA ---
|
|
Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: theme.background.withOpacity(0.8),
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(color: theme.gridLine.withOpacity(0.3)),
|
|
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.2), blurRadius: 10, offset: const Offset(0, 4))],
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// 1. SEZIONE FORMA
|
|
Text("FORMA DELL'ARENA", style: TextStyle(fontSize: 11, fontWeight: FontWeight.w900, color: theme.text.withOpacity(0.5), letterSpacing: 1.5)),
|
|
const SizedBox(height: 10),
|
|
Wrap(
|
|
spacing: 10,
|
|
runSpacing: 10,
|
|
alignment: WrapAlignment.center,
|
|
children: [
|
|
_NeonShapeButton(
|
|
icon: Icons.diamond_outlined, label: 'Rombo', isSelected: _selectedShape == ArenaShape.classic, theme: theme,
|
|
onTap: () => setState(() => _selectedShape = ArenaShape.classic),
|
|
),
|
|
_NeonShapeButton(
|
|
icon: Icons.add, label: 'Croce', isSelected: _selectedShape == ArenaShape.cross, theme: theme,
|
|
onTap: () => setState(() => _selectedShape = ArenaShape.cross),
|
|
),
|
|
_NeonShapeButton(
|
|
icon: Icons.donut_large, label: 'Buco Nero', isSelected: _selectedShape == ArenaShape.donut, theme: theme,
|
|
onTap: () => setState(() => _selectedShape = ArenaShape.donut),
|
|
),
|
|
_NeonShapeButton(
|
|
icon: Icons.hourglass_bottom, label: 'Clessidra', isSelected: _selectedShape == ArenaShape.hourglass, theme: theme,
|
|
onTap: () => setState(() => _selectedShape = ArenaShape.hourglass),
|
|
),
|
|
],
|
|
),
|
|
|
|
const SizedBox(height: 20),
|
|
Divider(color: theme.gridLine.withOpacity(0.2), height: 1),
|
|
const SizedBox(height: 20),
|
|
|
|
// 2. SEZIONE TAGLIA (SCALA CORRETTA DA 3 A 6)
|
|
Text("GRANDEZZA", style: TextStyle(fontSize: 11, fontWeight: FontWeight.w900, color: theme.text.withOpacity(0.5), letterSpacing: 1.5)),
|
|
const SizedBox(height: 10),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
_NeonSizeButton(label: 'S', isSelected: _selectedRadius == 3, theme: theme, onTap: () => setState(() => _selectedRadius = 3)),
|
|
_NeonSizeButton(label: 'M', isSelected: _selectedRadius == 4, theme: theme, onTap: () => setState(() => _selectedRadius = 4)),
|
|
_NeonSizeButton(label: 'L', isSelected: _selectedRadius == 5, theme: theme, onTap: () => setState(() => _selectedRadius = 5)),
|
|
_NeonSizeButton(label: 'MAX', isSelected: _selectedRadius == 6, theme: theme, onTap: () => setState(() => _selectedRadius = 6)),
|
|
],
|
|
),
|
|
|
|
const SizedBox(height: 20),
|
|
Divider(color: theme.gridLine.withOpacity(0.2), height: 1),
|
|
const SizedBox(height: 20),
|
|
|
|
// 3. SEZIONE TEMPO
|
|
Text("MODALITÀ DI GIOCO", style: TextStyle(fontSize: 11, fontWeight: FontWeight.w900, color: theme.text.withOpacity(0.5), letterSpacing: 1.5)),
|
|
const SizedBox(height: 10),
|
|
_NeonTimeSwitch(
|
|
isTimeMode: _isTimeMode,
|
|
theme: theme,
|
|
onTap: () => setState(() => _isTimeMode = !_isTimeMode),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
// --------------------------------------------------------------
|
|
|
|
const SizedBox(height: 20),
|
|
|
|
Expanded(
|
|
child: GridView.count(
|
|
crossAxisCount: 2,
|
|
crossAxisSpacing: 15,
|
|
mainAxisSpacing: 15,
|
|
childAspectRatio: 1.1,
|
|
physics: const BouncingScrollPhysics(),
|
|
children: [
|
|
_FeatureCard(
|
|
title: "ONLINE", subtitle: "Sfida il mondo", icon: Icons.public, color: theme.playerBlue, theme: theme, isFeatured: true,
|
|
onTap: () {
|
|
Navigator.push(context, MaterialPageRoute(builder: (_) => LobbyScreen(selectedRadius: _selectedRadius, selectedShape: _selectedShape)));
|
|
},
|
|
),
|
|
_FeatureCard(
|
|
title: "VS CPU", subtitle: "Allenati", icon: Icons.smart_toy, color: Colors.purple.shade400, theme: theme,
|
|
onTap: () async {
|
|
context.read<GameController>().startNewGame(_selectedRadius, vsCPU: true, shape: _selectedShape);
|
|
await Navigator.push(context, MaterialPageRoute(builder: (_) => const GameScreen()));
|
|
setState(() {});
|
|
},
|
|
),
|
|
_FeatureCard(
|
|
title: "LOCALE", subtitle: "Stesso schermo", icon: Icons.people_alt, color: theme.playerRed, theme: theme,
|
|
onTap: () {
|
|
context.read<GameController>().startNewGame(_selectedRadius, vsCPU: false, shape: _selectedShape);
|
|
Navigator.push(context, MaterialPageRoute(builder: (_) => const GameScreen()));
|
|
},
|
|
),
|
|
Column(
|
|
children: [
|
|
Expanded(child: _MiniCard(title: "STORICO", icon: Icons.history, color: Colors.orange.shade400, theme: theme, onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const HistoryScreen())))),
|
|
const SizedBox(height: 15),
|
|
Expanded(child: _MiniCard(title: "TEMI", icon: Icons.palette, color: Colors.teal.shade400, theme: theme, onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const SettingsScreen())))),
|
|
],
|
|
)
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
|
|
return Scaffold(
|
|
backgroundColor: bgImage != null ? Colors.transparent : theme.background,
|
|
body: Container(
|
|
decoration: bgImage != null ? BoxDecoration(image: DecorationImage(image: AssetImage(bgImage), fit: BoxFit.cover)) : null,
|
|
child: bgImage != null ? BackdropFilter(filter: ImageFilter.blur(sigmaX: 3.5, sigmaY: 3.5), child: Container(color: themeType == AppThemeType.doodle ? Colors.white.withOpacity(0.1) : Colors.transparent, child: uiContent)) : uiContent,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _FeatureCard extends StatelessWidget {
|
|
final String title;
|
|
final String subtitle;
|
|
final IconData icon;
|
|
final Color color;
|
|
final ThemeColors theme;
|
|
final VoidCallback onTap;
|
|
final bool isFeatured;
|
|
|
|
const _FeatureCard({required this.title, required this.subtitle, required this.icon, required this.color, required this.theme, required this.onTap, this.isFeatured = false});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GestureDetector(
|
|
onTap: onTap,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: isFeatured ? color : theme.background,
|
|
borderRadius: BorderRadius.circular(25),
|
|
border: Border.all(color: isFeatured ? Colors.transparent : color.withOpacity(0.5), width: 2),
|
|
boxShadow: [BoxShadow(color: color.withOpacity(isFeatured ? 0.4 : 0.1), offset: const Offset(0, 8), blurRadius: 15)]
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(10),
|
|
decoration: BoxDecoration(color: isFeatured ? Colors.white.withOpacity(0.2) : color.withOpacity(0.1), shape: BoxShape.circle),
|
|
child: Icon(icon, color: isFeatured ? Colors.white : color, size: 32),
|
|
),
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(title, style: TextStyle(color: isFeatured ? Colors.white : theme.text, fontSize: 20, fontWeight: FontWeight.w900)),
|
|
Text(subtitle, style: TextStyle(color: isFeatured ? Colors.white.withOpacity(0.8) : theme.text.withOpacity(0.5), fontSize: 12, fontWeight: FontWeight.bold)),
|
|
],
|
|
)
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MiniCard extends StatelessWidget {
|
|
final String title;
|
|
final IconData icon;
|
|
final Color color;
|
|
final ThemeColors theme;
|
|
final VoidCallback onTap;
|
|
|
|
const _MiniCard({required this.title, required this.icon, required this.color, required this.theme, required this.onTap});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GestureDetector(
|
|
onTap: onTap,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: theme.background,
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(color: color.withOpacity(0.5), width: 1.5),
|
|
boxShadow: [BoxShadow(color: color.withOpacity(0.1), blurRadius: 10, offset: const Offset(0, 5))]
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(icon, color: color, size: 24),
|
|
const SizedBox(width: 8),
|
|
Text(title, style: TextStyle(color: theme.text, fontWeight: FontWeight.bold, fontSize: 14)),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _AnimatedCyberBorder extends StatefulWidget {
|
|
final Widget child;
|
|
const _AnimatedCyberBorder({required this.child});
|
|
@override
|
|
State<_AnimatedCyberBorder> createState() => _AnimatedCyberBorderState();
|
|
}
|
|
|
|
class _AnimatedCyberBorderState extends State<_AnimatedCyberBorder> with SingleTickerProviderStateMixin {
|
|
late AnimationController _controller;
|
|
@override
|
|
void initState() { super.initState(); _controller = AnimationController(vsync: this, duration: const Duration(seconds: 3))..repeat(); }
|
|
@override
|
|
void dispose() { _controller.dispose(); super.dispose(); }
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = context.watch<ThemeManager>().currentColors;
|
|
return AnimatedBuilder(
|
|
animation: _controller,
|
|
builder: (context, child) {
|
|
return CustomPaint(
|
|
painter: _CyberBorderPainter(animationValue: _controller.value, color1: theme.playerBlue, color2: theme.playerRed),
|
|
child: Container(
|
|
decoration: BoxDecoration(color: theme.background.withOpacity(0.9), borderRadius: BorderRadius.circular(25), boxShadow: [BoxShadow(color: theme.playerBlue.withOpacity(0.3), blurRadius: 25, spreadRadius: 2)]),
|
|
padding: const EdgeInsets.all(3),
|
|
child: widget.child,
|
|
),
|
|
);
|
|
},
|
|
child: widget.child,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _CyberBorderPainter extends CustomPainter {
|
|
final double animationValue;
|
|
final Color color1;
|
|
final Color color2;
|
|
|
|
_CyberBorderPainter({required this.animationValue, required this.color1, required this.color2});
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final rect = Offset.zero & size;
|
|
final RRect rrect = RRect.fromRectAndRadius(rect, const Radius.circular(25));
|
|
final Paint paint = Paint()
|
|
..shader = SweepGradient(colors: [color1, color2, color1, color2, color1], stops: const [0.0, 0.25, 0.5, 0.75, 1.0], transform: GradientRotation(animationValue * 2 * math.pi)).createShader(rect)
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = 4.0
|
|
..maskFilter = const MaskFilter.blur(BlurStyle.solid, 4);
|
|
canvas.drawRRect(rrect, paint);
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant _CyberBorderPainter oldDelegate) => oldDelegate.animationValue != animationValue;
|
|
}
|
|
// ===========================================================================
|
|
// FILE: lib/ui/multiplayer/lobby_screen.dart
|
|
// ===========================================================================
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
import '../../core/theme_manager.dart';
|
|
import '../../models/game_board.dart'; // <-- IMPORTANTE
|
|
import '../../services/multiplayer_service.dart';
|
|
import '../../services/storage_service.dart';
|
|
import '../game/game_screen.dart';
|
|
import '../../logic/game_controller.dart';
|
|
|
|
class LobbyScreen extends StatefulWidget {
|
|
final String? initialRoomCode;
|
|
final int selectedRadius;
|
|
final ArenaShape selectedShape; // <-- RICEVIAMO LA FORMA
|
|
|
|
const LobbyScreen({super.key, this.initialRoomCode, this.selectedRadius = 2, this.selectedShape = ArenaShape.classic});
|
|
|
|
@override
|
|
State<LobbyScreen> createState() => _LobbyScreenState();
|
|
}
|
|
|
|
class _LobbyScreenState extends State<LobbyScreen> {
|
|
final MultiplayerService _multiplayerService = MultiplayerService();
|
|
late TextEditingController _codeController;
|
|
|
|
bool _isLoading = false;
|
|
String? _myRoomCode;
|
|
String _playerName = '';
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_codeController = TextEditingController();
|
|
_playerName = StorageService.instance.playerName;
|
|
|
|
if (widget.initialRoomCode != null && widget.initialRoomCode!.isNotEmpty) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
setState(() { _codeController.text = widget.initialRoomCode!; });
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() { _codeController.dispose(); super.dispose(); }
|
|
|
|
Future<void> _createRoom() async {
|
|
if (_isLoading) return;
|
|
setState(() => _isLoading = true);
|
|
|
|
try {
|
|
int radius = widget.selectedRadius;
|
|
// PASSIAMO ANCHE IL NOME DELLA FORMA!
|
|
String code = await _multiplayerService.createGameRoom(radius, _playerName, widget.selectedShape.name);
|
|
|
|
if (!mounted) return;
|
|
setState(() { _myRoomCode = code; _isLoading = false; });
|
|
|
|
_multiplayerService.shareInviteLink(code);
|
|
_showWaitingDialog(code);
|
|
} catch (e) {
|
|
if (mounted) { setState(() => _isLoading = false); _showError("Errore durante la creazione della partita."); }
|
|
}
|
|
}
|
|
|
|
Future<void> _joinRoom() async {
|
|
if (_isLoading) return;
|
|
FocusScope.of(context).unfocus();
|
|
|
|
String code = _codeController.text.trim().toUpperCase();
|
|
if (code.isEmpty || code.length != 5) { _showError("Inserisci un codice valido di 5 caratteri."); return; }
|
|
|
|
setState(() => _isLoading = true);
|
|
|
|
try {
|
|
Map<String, dynamic>? roomData = await _multiplayerService.joinGameRoom(code, _playerName);
|
|
|
|
if (!mounted) return;
|
|
setState(() => _isLoading = false);
|
|
|
|
if (roomData != null) {
|
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("Stanza trovata! Partita in avvio..."), backgroundColor: Colors.green));
|
|
|
|
int hostRadius = roomData['radius'] ?? 2;
|
|
|
|
// LEGGIAMO LA FORMA DELL'HOST
|
|
String shapeStr = roomData['shape'] ?? 'classic';
|
|
ArenaShape hostShape = ArenaShape.values.firstWhere((e) => e.name == shapeStr, orElse: () => ArenaShape.classic);
|
|
|
|
context.read<GameController>().startNewGame(hostRadius, isOnline: true, roomCode: code, isHost: false, shape: hostShape);
|
|
Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => const GameScreen()));
|
|
} else {
|
|
_showError("Stanza non trovata, piena o partita già iniziata.");
|
|
}
|
|
} catch (e) {
|
|
if (mounted) { setState(() => _isLoading = false); _showError("Errore di connessione: $e"); }
|
|
}
|
|
}
|
|
|
|
void _showError(String message) { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message, style: const TextStyle(color: Colors.white)), backgroundColor: Colors.red)); }
|
|
|
|
void _showWaitingDialog(String code) {
|
|
showDialog(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (context) {
|
|
final theme = context.watch<ThemeManager>().currentColors;
|
|
|
|
return StreamBuilder<DocumentSnapshot>(
|
|
stream: _multiplayerService.listenToRoom(code),
|
|
builder: (context, snapshot) {
|
|
if (snapshot.hasData && snapshot.data!.exists) {
|
|
var data = snapshot.data!.data() as Map<String, dynamic>;
|
|
if (data['status'] == 'playing') {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
Navigator.pop(context);
|
|
int radius = widget.selectedRadius;
|
|
// AVVIAMO IL GIOCO CON LA FORMA SCELTA DALL'HOST!
|
|
context.read<GameController>().startNewGame(radius, isOnline: true, roomCode: code, isHost: true, shape: widget.selectedShape);
|
|
Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => const GameScreen()));
|
|
});
|
|
}
|
|
}
|
|
|
|
return AlertDialog(
|
|
backgroundColor: theme.background,
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
|
title: Text("In attesa...", textAlign: TextAlign.center, style: TextStyle(color: theme.text, fontWeight: FontWeight.bold)),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const CircularProgressIndicator(), const SizedBox(height: 25),
|
|
Text("CODICE STANZA", style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: theme.text.withOpacity(0.6), letterSpacing: 2)),
|
|
Text(code, style: TextStyle(fontSize: 40, fontWeight: FontWeight.w900, color: theme.playerRed, letterSpacing: 8)),
|
|
const SizedBox(height: 20),
|
|
Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(color: theme.text.withOpacity(0.05), borderRadius: BorderRadius.circular(15), border: Border.all(color: theme.playerBlue.withOpacity(0.3), width: 1.5)),
|
|
child: Column(
|
|
children: [
|
|
Icon(Icons.auto_awesome, color: theme.playerBlue, size: 28), const SizedBox(height: 10),
|
|
Text("Condividi l'invito con un amico.", textAlign: TextAlign.center, style: TextStyle(color: theme.text, fontWeight: FontWeight.bold, fontSize: 14)),
|
|
const SizedBox(height: 5),
|
|
Text("Gli basterà copiare il messaggio e aprire l'app.", textAlign: TextAlign.center, style: TextStyle(color: theme.text.withOpacity(0.8), fontSize: 13, height: 1.4)),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
actionsAlignment: MainAxisAlignment.center,
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () { FirebaseFirestore.instance.collection('games').doc(code).delete(); Navigator.pop(context); },
|
|
child: const Text("ANNULLA", style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold, fontSize: 16, letterSpacing: 1.5)),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = context.watch<ThemeManager>().currentColors;
|
|
|
|
return Scaffold(
|
|
backgroundColor: theme.background,
|
|
appBar: AppBar(title: Text("Multiplayer Online", style: TextStyle(color: theme.text, fontWeight: FontWeight.bold)), backgroundColor: Colors.transparent, elevation: 0, iconTheme: IconThemeData(color: theme.text)),
|
|
body: _isLoading ? Center(child: CircularProgressIndicator(color: theme.playerRed)) : Center(
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.all(24.0),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Icon(Icons.wifi, size: 80, color: theme.playerBlue), const SizedBox(height: 10),
|
|
Text("Giocatore: $_playerName", textAlign: TextAlign.center, style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: theme.playerRed)),
|
|
const SizedBox(height: 40),
|
|
SizedBox(height: 60, child: ElevatedButton(style: ElevatedButton.styleFrom(backgroundColor: theme.playerRed, foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15))), onPressed: _createRoom, child: const Text("CREA PARTITA", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, letterSpacing: 2)))),
|
|
const SizedBox(height: 40),
|
|
TextField(
|
|
controller: _codeController, textCapitalization: TextCapitalization.characters, textAlign: TextAlign.center, maxLength: 5,
|
|
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: theme.text, letterSpacing: 8),
|
|
decoration: InputDecoration(hintText: "CODICE", hintStyle: TextStyle(color: theme.text.withOpacity(0.3)), enabledBorder: OutlineInputBorder(borderSide: BorderSide(color: theme.text.withOpacity(0.3), width: 2), borderRadius: BorderRadius.circular(15)), focusedBorder: OutlineInputBorder(borderSide: BorderSide(color: theme.playerBlue, width: 3), borderRadius: BorderRadius.circular(15))),
|
|
),
|
|
const SizedBox(height: 16),
|
|
SizedBox(height: 60, child: ElevatedButton(style: ElevatedButton.styleFrom(backgroundColor: theme.playerBlue, foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15))), onPressed: _joinRoom, child: const Text("UNISCITI", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, letterSpacing: 2)))),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
// ===========================================================================
|
|
// FILE: lib/ui/settings/settings_screen.dart
|
|
// ===========================================================================
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
import '../../core/theme_manager.dart';
|
|
import '../../core/app_colors.dart';
|
|
|
|
class SettingsScreen extends StatelessWidget {
|
|
const SettingsScreen({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final themeManager = context.watch<ThemeManager>();
|
|
final theme = themeManager.currentColors;
|
|
|
|
return Scaffold(
|
|
backgroundColor: theme.background,
|
|
appBar: AppBar(
|
|
title: Text("SELEZIONA TEMA", style: TextStyle(fontWeight: FontWeight.bold, color: theme.text)),
|
|
backgroundColor: Colors.transparent,
|
|
elevation: 0,
|
|
iconTheme: IconThemeData(color: theme.text),
|
|
),
|
|
body: ListView(
|
|
padding: const EdgeInsets.all(20),
|
|
children: [
|
|
_ThemeCard(
|
|
title: "Minimal",
|
|
subtitle: "Linee pulite, sfondo chiaro",
|
|
type: AppThemeType.minimal,
|
|
previewColors: AppColors.minimal,
|
|
),
|
|
const SizedBox(height: 15),
|
|
_ThemeCard(
|
|
title: "Quaderno (Doodle)",
|
|
subtitle: "Sfondo a quadretti, tratto a penna",
|
|
type: AppThemeType.doodle,
|
|
previewColors: AppColors.doodle,
|
|
),
|
|
const SizedBox(height: 15),
|
|
_ThemeCard(
|
|
title: "Cyberpunk",
|
|
subtitle: "Nero profondo, luci al neon",
|
|
type: AppThemeType.cyberpunk,
|
|
previewColors: AppColors.cyberpunk,
|
|
),
|
|
const SizedBox(height: 15),
|
|
// --- NUOVA CARD TEMA LEGNO ---
|
|
_ThemeCard(
|
|
title: "Legno & Fiammiferi",
|
|
subtitle: "Tavolo di legno, linee come fiammiferi",
|
|
type: AppThemeType.wood,
|
|
previewColors: AppColors.wood,
|
|
),
|
|
// -----------------------------
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ThemeCard extends StatelessWidget {
|
|
final String title;
|
|
final String subtitle;
|
|
final AppThemeType type;
|
|
final ThemeColors previewColors;
|
|
|
|
const _ThemeCard({required this.title, required this.subtitle, required this.type, required this.previewColors});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final themeManager = context.watch<ThemeManager>();
|
|
bool isSelected = themeManager.currentThemeType == type;
|
|
|
|
return GestureDetector(
|
|
onTap: () => themeManager.setTheme(type),
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 300),
|
|
padding: const EdgeInsets.all(20),
|
|
decoration: BoxDecoration(
|
|
color: previewColors.background,
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(
|
|
color: isSelected ? previewColors.playerBlue : previewColors.gridLine.withOpacity(0.5),
|
|
width: isSelected ? 4 : 2,
|
|
),
|
|
boxShadow: isSelected ? [BoxShadow(color: previewColors.playerBlue.withOpacity(0.4), blurRadius: 10, spreadRadius: 2)] : [],
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(title, style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: previewColors.text)),
|
|
Text(subtitle, style: TextStyle(fontSize: 14, color: previewColors.text.withOpacity(0.7))),
|
|
],
|
|
),
|
|
),
|
|
Container(width: 20, height: 20, decoration: BoxDecoration(color: previewColors.playerRed, shape: BoxShape.circle)),
|
|
const SizedBox(width: 10),
|
|
Container(width: 20, height: 20, decoration: BoxDecoration(color: previewColors.playerBlue, shape: BoxShape.circle)),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
// ===========================================================================
|
|
// FILE: lib/widgets/custom_button.dart
|
|
// ===========================================================================
|
|
|
|
|
|
// ===========================================================================
|
|
// FILE: lib/widgets/custom_settings_button.dart
|
|
// ===========================================================================
|
|
|
|
import 'package:flutter/material.dart';
|
|
import '../theme/app_colors.dart';
|
|
|
|
// Widget per i pulsanti di selezione della forma dell'arena
|
|
class NeonShapeButton extends StatelessWidget {
|
|
final IconData icon;
|
|
final String label;
|
|
final bool isSelected;
|
|
final VoidCallback onTap;
|
|
final ShapeBorder shape; // La forma geometrica del pulsante
|
|
|
|
const NeonShapeButton({
|
|
super.key,
|
|
required this.icon,
|
|
required this.label,
|
|
required this.isSelected,
|
|
required this.onTap,
|
|
this.shape = const RoundedRectangleBorder( // Forma di default
|
|
borderRadius: BorderRadius.all(Radius.circular(12.0))),
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GestureDetector(
|
|
onTap: onTap,
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 300),
|
|
curve: Curves.easeInOut,
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
decoration: ShapeDecoration(
|
|
shape: shape,
|
|
color: isSelected
|
|
? AppColors.neonGreen.withOpacity(0.2) // Sfondo luminoso se selezionato
|
|
: AppColors.surface.withOpacity(0.5), // Sfondo più scuro se non selezionato
|
|
shadows: isSelected
|
|
? [ // Bagliore intenso se selezionato
|
|
BoxShadow(
|
|
color: AppColors.neonGreen.withOpacity(0.6),
|
|
blurRadius: 12.0,
|
|
spreadRadius: 2.0,
|
|
),
|
|
]
|
|
: [],
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
icon,
|
|
color: isSelected ? AppColors.neonGreen : AppColors.textSecondary,
|
|
size: 28,
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
color: isSelected ? AppColors.textPrimary : AppColors.textSecondary,
|
|
fontSize: 12,
|
|
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Widget per i pulsanti di selezione della taglia dell'arena
|
|
class NeonSizeButton extends StatelessWidget {
|
|
final String label;
|
|
final bool isSelected;
|
|
final VoidCallback onTap;
|
|
|
|
const NeonSizeButton({
|
|
super.key,
|
|
required this.label,
|
|
required this.isSelected,
|
|
required this.onTap,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GestureDetector(
|
|
onTap: onTap,
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 300),
|
|
curve: Curves.easeInOut,
|
|
width: 50,
|
|
height: 50,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle, // Forma circolare
|
|
color: isSelected
|
|
? AppColors.neonBlue.withOpacity(0.2)
|
|
: AppColors.surface.withOpacity(0.5),
|
|
border: Border.all(
|
|
color: isSelected ? AppColors.neonBlue : AppColors.surfaceLight,
|
|
width: 2.0,
|
|
),
|
|
shadows: isSelected
|
|
? [
|
|
BoxShadow(
|
|
color: AppColors.neonBlue.withOpacity(0.6),
|
|
blurRadius: 10.0,
|
|
spreadRadius: 1.5,
|
|
),
|
|
]
|
|
: [],
|
|
),
|
|
child: Center(
|
|
child: Text(
|
|
label,
|
|
style: TextStyle(
|
|
color: isSelected ? AppColors.textPrimary : AppColors.textSecondary,
|
|
fontSize: 16,
|
|
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Widget per l'interruttore della modalità tempo (Clessidra)
|
|
class NeonTimeSwitch extends StatelessWidget {
|
|
final bool isTimeMode;
|
|
final VoidCallback onTap;
|
|
|
|
const NeonTimeSwitch({
|
|
super.key,
|
|
required this.isTimeMode,
|
|
required this.onTap,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GestureDetector(
|
|
onTap: onTap,
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 300),
|
|
curve: Curves.easeInOut,
|
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(30.0), // Forma arrotondata per lo switch
|
|
color: isTimeMode
|
|
? AppColors.neonGreen.withOpacity(0.2)
|
|
: AppColors.surface.withOpacity(0.5),
|
|
border: Border.all(
|
|
color: isTimeMode ? AppColors.neonGreen : AppColors.surfaceLight,
|
|
width: 2.0,
|
|
),
|
|
shadows: isTimeMode
|
|
? [
|
|
BoxShadow(
|
|
color: AppColors.neonGreen.withOpacity(0.6),
|
|
blurRadius: 12.0,
|
|
spreadRadius: 2.0,
|
|
),
|
|
]
|
|
: [],
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.hourglass_empty, // Icona clessidra
|
|
color: isTimeMode ? AppColors.neonGreen : AppColors.textSecondary,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
isTimeMode ? 'A TEMPO' : 'SENZA TEMPO',
|
|
style: TextStyle(
|
|
color: isTimeMode ? AppColors.textPrimary : AppColors.textSecondary,
|
|
fontWeight: isTimeMode ? FontWeight.bold : FontWeight.normal,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
// ===========================================================================
|
|
// FILE: lib/widgets/game_over_dialog.dart
|
|
// ===========================================================================
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
import '../logic/game_controller.dart';
|
|
import '../core/theme_manager.dart';
|
|
import '../core/app_colors.dart';
|
|
|
|
class GameOverDialog extends StatelessWidget {
|
|
const GameOverDialog({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final game = context.read<GameController>();
|
|
final themeManager = context.read<ThemeManager>();
|
|
final theme = themeManager.currentColors;
|
|
final themeType = themeManager.currentThemeType;
|
|
|
|
int red = game.board.scoreRed;
|
|
int blue = game.board.scoreBlue;
|
|
|
|
bool playerBeatCPU = game.isVsCPU && red > blue;
|
|
|
|
// --- LOGICA NOMI ---
|
|
String nameRed = "ROSSO";
|
|
String nameBlue = themeType == AppThemeType.cyberpunk ? "VERDE" : "BLU";
|
|
|
|
if (game.isOnline) {
|
|
nameRed = game.onlineHostName.toUpperCase();
|
|
nameBlue = game.onlineGuestName.toUpperCase();
|
|
} else if (game.isVsCPU) {
|
|
nameRed = "TU";
|
|
nameBlue = "CPU";
|
|
}
|
|
|
|
// --- DETERMINA IL VINCITORE ---
|
|
String winnerText = "";
|
|
Color winnerColor = theme.text;
|
|
|
|
if (red > blue) {
|
|
winnerText = "VINCE $nameRed!";
|
|
winnerColor = theme.playerRed;
|
|
} else if (blue > red) {
|
|
winnerText = "VINCE $nameBlue!";
|
|
winnerColor = theme.playerBlue;
|
|
} else {
|
|
winnerText = "PAREGGIO!";
|
|
winnerColor = theme.text;
|
|
}
|
|
|
|
return AlertDialog(
|
|
backgroundColor: theme.background,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(20),
|
|
side: BorderSide(color: winnerColor.withOpacity(0.5), width: 2),
|
|
),
|
|
title: Text("FINE PARTITA", textAlign: TextAlign.center, style: TextStyle(color: theme.text, fontWeight: FontWeight.bold, fontSize: 22)),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(winnerText, textAlign: TextAlign.center, style: TextStyle(fontSize: 26, fontWeight: FontWeight.w900, color: winnerColor)),
|
|
const SizedBox(height: 20),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
|
decoration: BoxDecoration(
|
|
color: theme.text.withOpacity(0.05),
|
|
borderRadius: BorderRadius.circular(15),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text("$nameRed: $red", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: theme.playerRed)),
|
|
Text(" - ", style: TextStyle(fontSize: 18, color: theme.text)),
|
|
Text("$nameBlue: $blue", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: theme.playerBlue)),
|
|
],
|
|
),
|
|
),
|
|
|
|
if (game.isVsCPU) ...[
|
|
const SizedBox(height: 15),
|
|
Text("Difficoltà CPU: Livello ${game.cpuLevel}", style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: theme.text.withOpacity(0.7))),
|
|
]
|
|
],
|
|
),
|
|
actionsPadding: const EdgeInsets.only(left: 20, right: 20, bottom: 20, top: 10),
|
|
actionsAlignment: MainAxisAlignment.center,
|
|
actions: [
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
if (playerBeatCPU)
|
|
ElevatedButton(
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: winnerColor,
|
|
foregroundColor: Colors.white,
|
|
padding: const EdgeInsets.symmetric(vertical: 15),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
|
|
elevation: 5,
|
|
),
|
|
onPressed: () {
|
|
Navigator.pop(context);
|
|
game.increaseLevelAndRestart();
|
|
},
|
|
child: const Text("PROSSIMO LIVELLO ➔", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
|
)
|
|
else if (game.isOnline)
|
|
ElevatedButton(
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: winnerColor == theme.text ? theme.playerBlue : winnerColor,
|
|
foregroundColor: Colors.white,
|
|
padding: const EdgeInsets.symmetric(vertical: 15),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
|
|
elevation: 5,
|
|
),
|
|
onPressed: () {
|
|
Navigator.pop(context);
|
|
if (game.board.isGameOver) {
|
|
game.requestRematch();
|
|
}
|
|
},
|
|
child: const Text("RIGIOCA ONLINE", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, letterSpacing: 1.5)),
|
|
)
|
|
else
|
|
ElevatedButton(
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: winnerColor == theme.text ? theme.playerBlue : winnerColor,
|
|
foregroundColor: Colors.white,
|
|
padding: const EdgeInsets.symmetric(vertical: 15),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
|
|
elevation: 5,
|
|
),
|
|
onPressed: () {
|
|
Navigator.pop(context);
|
|
game.startNewGame(game.board.radius, vsCPU: game.isVsCPU);
|
|
},
|
|
child: const Text("RIGIOCA", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, letterSpacing: 2)),
|
|
),
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
OutlinedButton(
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: theme.text,
|
|
side: BorderSide(color: theme.text.withOpacity(0.3), width: 2),
|
|
padding: const EdgeInsets.symmetric(vertical: 15),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
|
|
),
|
|
onPressed: () {
|
|
if (game.isOnline) {
|
|
game.disconnectOnlineGame();
|
|
}
|
|
Navigator.pop(context);
|
|
Navigator.pop(context);
|
|
},
|
|
child: Text("TORNA AL MENU", style: TextStyle(fontWeight: FontWeight.bold, color: theme.text, fontSize: 14, letterSpacing: 1.5)),
|
|
),
|
|
],
|
|
)
|
|
],
|
|
);
|
|
}
|
|
} |