import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../game/engine/game_engine.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/hud_widget.dart'; import '../widgets/piece_painter.dart'; import '../widgets/tray_widget.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 { final _boardKey = GlobalKey(); final _stackKey = GlobalKey(); 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); } } 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!; if (placement.linesCleared > 0) { audio.play(placement.comboStreak >= 2 ? Sfx.combo : Sfx.clear); } else { audio.play(Sfx.place); } } if (prev?.phase != next.phase) { if (next.phase == GamePhase.won) { audio.play(Sfx.win); // recordResult keeps the best run, so re-entry is harmless. ref .read(seasonFlowProvider.notifier) .recordWin(stars: next.starsEarned, score: next.score); } if (next.phase == GamePhase.lost) audio.play(Sfx.lose); } } @override Widget build(BuildContext context) { ref.listen(gameSessionProvider, _onSessionChange); final view = ref.watch(gameSessionProvider); if (view == null) { return const Scaffold(body: Center(child: CircularProgressIndicator())); } final ghost = _ghost(view); final draggedTopLeft = _draggedPieceTopLeft(view); final boardBox = _boardBox; return Scaffold( body: SafeArea( child: Stack( key: _stackKey, children: [ Padding( padding: const EdgeInsets.all(16), child: Column( children: [ HudWidget(view: view), Expanded( child: Center( 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), if (view.phase != GamePhase.playing) _resultOverlay(view), 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(), ), ), ], ), ), ); } 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, child: Text(l10n.nextStage), ), TextButton( onPressed: notifier.restart, child: Text(l10n.playAgain), ), ], ), (GamePhase.stuck, StuckReason.outOfMoves) => ( l10n.outOfMoves, [ FilledButton( onPressed: notifier.addExtraMoves, child: Text(l10n.plusFiveMoves), ), TextButton( onPressed: notifier.declineAndLose, child: Text(l10n.giveUp), ), ], ), (GamePhase.stuck, _) => ( l10n.boardFull, [ FilledButton( onPressed: notifier.useContinue, child: Text(l10n.watchAdContinue), ), TextButton( onPressed: notifier.declineAndLose, child: Text(l10n.giveUp), ), ], ), (_, _) => ( l10n.stageFailed, [ FilledButton( onPressed: 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++) Icon( Icons.star, size: 40, color: i < view.starsEarned ? Colors.amber : Colors.white24, ), ], ), ], const SizedBox(height: 20), ...actions, ], ), ), ), ), ), ); } }