import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../game/engine/game_engine.dart'; import '../../game/models/grid.dart'; import '../../l10n/gen/app_localizations.dart'; import '../../services/audio_service.dart'; import '../../state/game_session_notifier.dart'; import '../../state/providers.dart'; import '../theme/palette.dart'; import '../widgets/board_geometry.dart'; import '../widgets/board_painter.dart'; import '../widgets/board_widget.dart'; import '../widgets/effects_overlay.dart'; import '../widgets/hud_widget.dart'; import '../widgets/piece_painter.dart'; import '../widgets/season_background.dart'; import '../widgets/tray_widget.dart'; import '../widgets/tutorial_overlay.dart'; /// Renders whatever session [gameSessionProvider] holds; callers start the /// stage (via SeasonFlowNotifier) before navigating here. class GameScreen extends ConsumerStatefulWidget { const GameScreen({super.key}); @override ConsumerState createState() => _GameScreenState(); } class _GameScreenState extends ConsumerState with TickerProviderStateMixin { final _boardKey = GlobalKey(); final _stackKey = GlobalKey(); final _effectsKey = GlobalKey(); late final AnimationController _shake = AnimationController( vsync: this, duration: const Duration(milliseconds: 350), ); bool _tutorialStartChecked = false; bool _endlessNewBest = false; int? _dragIndex; Offset? _dragGlobal; /// How far the dragged piece floats above the finger so it stays visible. static const double _lift = 70; RenderBox? get _boardBox => _boardKey.currentContext?.findRenderObject() as RenderBox?; /// Global top-left of the dragged piece, rendered at board cell size. Offset? _draggedPieceTopLeft(GameViewState view) { final i = _dragIndex; final g = _dragGlobal; final box = _boardBox; if (i == null || g == null || box == null || i >= view.tray.length) { return null; } final geo = BoardGeometry(boardSize: box.size.width); final piece = view.tray[i]; final (w, h) = pieceCellBounds(piece); final pw = w * geo.cellSize; final ph = h * geo.cellSize; return g + Offset(-pw / 2, -_lift - ph); } GhostSpec? _ghost(GameViewState view) { final i = _dragIndex; final box = _boardBox; final topLeftGlobal = _draggedPieceTopLeft(view); if (i == null || box == null || topLeftGlobal == null) return null; final geo = BoardGeometry(boardSize: box.size.width); final piece = view.tray[i]; final (w, h) = pieceCellBounds(piece); final centerLocal = box.globalToLocal( topLeftGlobal + Offset(w * geo.cellSize / 2, h * geo.cellSize / 2), ); // Only preview while the piece is actually over the board. if (centerLocal.dx < -geo.cellSize || centerLocal.dy < -geo.cellSize || centerLocal.dx > box.size.width + geo.cellSize || centerLocal.dy > box.size.height + geo.cellSize) { return null; } final topLeftLocal = box.globalToLocal(topLeftGlobal); final (ax, ay) = geo.snapAnchor(piece, topLeftLocal); final legal = ref.read(gameSessionProvider.notifier).canPlaceAt(i, ax, ay); return GhostSpec(piece: piece, anchorX: ax, anchorY: ay, legal: legal); } void _onDragEnd(GameViewState view) { final i = _dragIndex; final ghost = _ghost(view); setState(() { _dragIndex = null; _dragGlobal = null; }); if (i != null && ghost != null && ghost.legal) { ref .read(gameSessionProvider.notifier) .tryPlace(i, ghost.anchorX, ghost.anchorY); } } @override void dispose() { _shake.dispose(); super.dispose(); } void _onSessionChange(GameViewState? prev, GameViewState? next) { if (next == null) return; final audio = ref.read(audioServiceProvider); if (prev?.fxTick != next.fxTick && next.lastPlacement != null) { final placement = next.lastPlacement!; final hapticsOn = ref.read(soundEnabledProvider); if (placement.linesCleared > 0) { audio.play(placement.comboStreak >= 2 ? Sfx.combo : Sfx.clear); if (hapticsOn) HapticFeedback.mediumImpact(); if (placement.comboStreak >= 4) { if (hapticsOn) HapticFeedback.heavyImpact(); _shake.forward(from: 0); } } else { audio.play(Sfx.place); if (hapticsOn) HapticFeedback.lightImpact(); } ref.read(tutorialProvider.notifier).onPlaced(); if (placement.linesCleared > 0) { ref.read(tutorialProvider.notifier).onLineCleared(); } final boardBox = _boardBox; final stackBox = _stackKey.currentContext?.findRenderObject() as RenderBox?; if (boardBox != null && stackBox != null) { final topLeft = stackBox.globalToLocal(boardBox.localToGlobal(Offset.zero)); _effectsKey.currentState?.onPlacement( placement, boardRect: topLeft & boardBox.size, ); } } if (prev?.phase != next.phase) { // A finished stage ends the tutorial; otherwise the overlay would sit // on top of the result card and leak into the next stage. if (next.phase != GamePhase.playing) { ref.read(tutorialProvider.notifier).skip(); } if (next.phase == GamePhase.won || next.phase == GamePhase.lost) { ref.read(adServiceProvider).onStageCompleted(); } if (next.phase == GamePhase.won) { audio.play(Sfx.win); // recordResult keeps the best run, so re-entry is harmless. if (!next.endless) { ref .read(seasonFlowProvider.notifier) .recordWin(stars: next.starsEarned, score: next.score); final flow = ref.read(seasonFlowProvider); if (flow != null) { ref.read(analyticsProvider).stageEnd( seasonId: flow.pack.seasonId, stageId: flow.stage.id, won: true, stars: next.starsEarned, score: next.score, movesUsed: next.moveLimit - next.movesLeft, ); } } final stackBox = _stackKey.currentContext?.findRenderObject() as RenderBox?; if (stackBox != null) { _effectsKey.currentState?.onWin(stackBox.size); } } if (next.phase == GamePhase.lost) audio.play(Sfx.lose); if (next.phase == GamePhase.lost && next.endless) { ref.read(endlessBestProvider.notifier).record(next.score).then((isNew) { if (mounted) setState(() => _endlessNewBest = isNew); ref.read(analyticsProvider).endlessEnd(score: next.score, isNewBest: isNew); }); } if (next.phase == GamePhase.lost && !next.endless) { final flow = ref.read(seasonFlowProvider); if (flow != null) { ref.read(analyticsProvider).stageEnd( seasonId: flow.pack.seasonId, stageId: flow.stage.id, won: false, stars: 0, score: next.score, movesUsed: next.moveLimit - next.movesLeft, ); } } if (next.phase == GamePhase.won || next.phase == GamePhase.lost) { ref.read(streakProvider.notifier).onStagePlayed(DateTime.now()); } } } @override Widget build(BuildContext context) { ref.listen(gameSessionProvider, _onSessionChange); ref.listen(streakProvider, (prev, next) { final milestone = next.hitMilestone; if (milestone != null && prev?.hitMilestone != milestone) { final l10n = AppLocalizations.of(context)!; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(l10n.streakMilestone(milestone))), ); } }); final view = ref.watch(gameSessionProvider); if (view == null) { return const Scaffold(body: Center(child: CircularProgressIndicator())); } final tutorialStep = ref.watch(tutorialProvider); if (!_tutorialStartChecked) { _tutorialStartChecked = true; final flow = ref.read(seasonFlowProvider); if (flow != null && flow.index == 0 && !ref.read(saveRepositoryProvider).tutorialDone) { WidgetsBinding.instance.addPostFrameCallback( (_) => ref.read(tutorialProvider.notifier).start()); } } final ghost = _ghost(view); final draggedTopLeft = _draggedPieceTopLeft(view); final boardBox = _boardBox; final theme = ref.watch(activeThemeProvider); return Scaffold( backgroundColor: Colors.transparent, body: Stack( fit: StackFit.expand, children: [ SeasonBackground(theme: theme), SafeArea( child: Stack( key: _stackKey, children: [ Padding( padding: const EdgeInsets.all(16), child: Column( children: [ HudWidget(view: view), Expanded( child: Center( child: AnimatedBuilder( animation: _shake, builder: (context, child) { final t = _shake.value; final dx = math.sin(t * math.pi * 10) * 6 * (1 - t); return Transform.translate( offset: Offset(dx, 0), child: child); }, child: BoardWidget( key: _boardKey, view: view, ghost: ghost, ), ), ), ), TrayWidget( tray: view.tray, draggingIndex: _dragIndex, onDragStart: (index, global) => setState(() { _dragIndex = index; _dragGlobal = global; }), onDragUpdate: (global) => setState(() => _dragGlobal = global), onDragEnd: () => _onDragEnd(view), ), ], ), ), if (_dragIndex != null && draggedTopLeft != null && boardBox != null && _dragIndex! < view.tray.length) _draggedPieceOverlay(view, draggedTopLeft, boardBox), Positioned.fill(child: EffectsOverlay(key: _effectsKey)), if (view.phase != GamePhase.playing) _resultOverlay(view), if (tutorialStep != null) Positioned.fill( child: TutorialOverlay( step: tutorialStep, handFrom: _tutorialHandFrom(), handTo: _tutorialHandTo(view), onSkip: () => ref.read(tutorialProvider.notifier).skip(), onDismissHud: () => ref.read(tutorialProvider.notifier).dismissHud(), ), ), if (Navigator.of(context).canPop()) Positioned( top: 4, left: 4, child: IconButton( icon: const Icon(Icons.close, color: Colors.white54), onPressed: () => Navigator.of(context).pop(), ), ), ], ), ), ], ), ); } Offset _tutorialHandFrom() { final stackBox = _stackKey.currentContext?.findRenderObject() as RenderBox?; if (stackBox == null) return Offset.zero; // Tray sits at the bottom; aim at the left slot. final size = stackBox.size; return Offset(size.width * 0.18, size.height - 80); } Offset _tutorialHandTo(GameViewState view) { final boardBox = _boardBox; final stackBox = _stackKey.currentContext?.findRenderObject() as RenderBox?; if (boardBox == null || stackBox == null || view.tray.isEmpty) { return Offset.zero; } final geo = BoardGeometry(boardSize: boardBox.size.width); final notifier = ref.read(gameSessionProvider.notifier); for (var y = 0; y < GridState.size; y++) { for (var x = 0; x < GridState.size; x++) { if (notifier.canPlaceAt(0, x, y)) { final local = Offset((x + 0.5) * geo.cellSize, (y + 0.5) * geo.cellSize); return stackBox.globalToLocal(boardBox.localToGlobal(local)); } } } return Offset.zero; } Widget _draggedPieceOverlay( GameViewState view, Offset topLeftGlobal, RenderBox boardBox) { final stackBox = _stackKey.currentContext!.findRenderObject()! as RenderBox; final local = stackBox.globalToLocal(topLeftGlobal); final cellSize = boardBox.size.width / 8; return Positioned( left: local.dx, top: local.dy, child: IgnorePointer( child: PieceWidget(piece: view.tray[_dragIndex!], cellSize: cellSize), ), ); } Widget _resultOverlay(GameViewState view) { final l10n = AppLocalizations.of(context)!; final notifier = ref.read(gameSessionProvider.notifier); final theme = Theme.of(context); final flow = ref.read(seasonFlowProvider); final (title, actions) = switch ((view.phase, view.stuckReason)) { (GamePhase.won, _) => ( l10n.stageClear, [ if (flow != null && flow.hasNext) FilledButton( onPressed: () { ref.read(seasonFlowProvider.notifier).nextStage(); if (!view.endless) { ref.read(adServiceProvider).maybeShowInterstitial(); } }, child: Text(l10n.nextStage), ), TextButton( onPressed: notifier.restart, child: Text(l10n.playAgain), ), ], ), (GamePhase.stuck, StuckReason.outOfMoves) => ( l10n.outOfMoves, [ if (!view.rescueUsed) FilledButton( onPressed: () async { final earned = await ref.read(adServiceProvider).showRewarded(); if (!earned) return; ref.read(analyticsProvider).rescueUsed(type: 'extra_moves'); notifier.addExtraMoves(); }, child: Text(l10n.plusFiveMoves), ), if (view.rescueUsed) FilledButton( onPressed: notifier.declineAndLose, child: Text(l10n.giveUp), ) else TextButton( onPressed: notifier.declineAndLose, child: Text(l10n.giveUp), ), ], ), (GamePhase.stuck, _) => ( l10n.boardFull, [ if (!view.rescueUsed) FilledButton( onPressed: () async { final earned = await ref.read(adServiceProvider).showRewarded(); if (!earned) return; ref.read(analyticsProvider).rescueUsed(type: 'continue'); notifier.useContinue(); }, child: Text(l10n.watchAdContinue), ), if (view.rescueUsed) FilledButton( onPressed: notifier.declineAndLose, child: Text(l10n.giveUp), ) else TextButton( onPressed: notifier.declineAndLose, child: Text(l10n.giveUp), ), ], ), (GamePhase.lost, _) when view.endless => ( l10n.gameOver, [ FilledButton( onPressed: () { setState(() => _endlessNewBest = false); notifier.restart(); }, child: Text(l10n.playAgain), ), ], ), (_, _) => ( l10n.stageFailed, [ FilledButton( onPressed: () { ref.read(adServiceProvider).maybeShowInterstitial(); notifier.restart(); }, child: Text(l10n.playAgain), ), ], ), }; return Positioned.fill( child: ColoredBox( color: Colors.black54, child: Center( child: Card( color: GamePalette.boardBackground, child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, children: [ Text(title, style: theme.textTheme.headlineSmall), if (view.phase == GamePhase.won) ...[ const SizedBox(height: 12), Row( mainAxisSize: MainAxisSize.min, children: [ for (var i = 0; i < 3; i++) TweenAnimationBuilder( key: ValueKey(i), tween: Tween(begin: 0, end: 1), duration: Duration(milliseconds: 400 + i * 250), curve: Interval(i * 0.22, 1, curve: Curves.elasticOut), builder: (context, v, child) => Transform.scale(scale: v, child: child), child: Icon( Icons.star, size: 44, color: i < view.starsEarned ? Colors.amber : Colors.white24, ), ), ], ), ], if (view.phase == GamePhase.lost && view.endless) ...[ const SizedBox(height: 10), Text('${view.score}', style: theme.textTheme.displaySmall ?.copyWith(fontWeight: FontWeight.w900)), const SizedBox(height: 4), Text( _endlessNewBest ? l10n.newBest : l10n.bestScore(ref.read(endlessBestProvider)), style: TextStyle( color: _endlessNewBest ? Colors.amber : Colors.white60, fontWeight: FontWeight.w800, ), ), ], if (view.phase == GamePhase.lost && !view.endless && view.objectiveProgress > 0) ...[ const SizedBox(height: 16), SizedBox( width: 88, height: 88, child: Stack( fit: StackFit.expand, children: [ CircularProgressIndicator( value: view.objectiveProgress, strokeWidth: 7, backgroundColor: Colors.white12, color: Colors.amber, ), Center( child: Text( l10n.almostThere((view.objectiveProgress * 100).round()), textAlign: TextAlign.center, maxLines: 2, overflow: TextOverflow.ellipsis, style: theme.textTheme.labelSmall, ), ), ], ), ), ], const SizedBox(height: 20), ...actions, ], ), ), ), ), ), ); } }