diff --git a/lib/ui/screens/game_screen.dart b/lib/ui/screens/game_screen.dart index 7e189ff..d4fa339 100644 --- a/lib/ui/screens/game_screen.dart +++ b/lib/ui/screens/game_screen.dart @@ -12,6 +12,7 @@ import '../widgets/board_painter.dart'; import '../widgets/board_widget.dart'; import '../widgets/hud_widget.dart'; import '../widgets/piece_painter.dart'; +import '../widgets/season_background.dart'; import '../widgets/tray_widget.dart'; /// Renders whatever session [gameSessionProvider] holds; callers start the @@ -140,56 +141,64 @@ class _GameScreenState extends ConsumerState { final draggedTopLeft = _draggedPieceTopLeft(view); final boardBox = _boardBox; + final theme = ref.watch(activeThemeProvider); 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, + 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: 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(), ), ), - 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(), - ), - ), - ], - ), + ), + ], ), ); } diff --git a/lib/ui/widgets/season_background.dart b/lib/ui/widgets/season_background.dart new file mode 100644 index 0000000..d9e78a6 --- /dev/null +++ b/lib/ui/widgets/season_background.dart @@ -0,0 +1,134 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../../game/models/season.dart'; +import '../theme/palette.dart'; + +/// Set true in tests (flutter_test_config.dart): looping ambience would make +/// pumpAndSettle spin forever. +bool debugDisableLoopingAnimations = false; + +/// Full-screen season ambience: vertical gradient plus drifting particles +/// (petals for season 1). Pure procedural — no image assets required; an AI +/// illustration layer can be added on top later without touching callers. +class SeasonBackground extends StatefulWidget { + const SeasonBackground({super.key, required this.theme}); + + final SeasonTheme theme; + + @override + State createState() => _SeasonBackgroundState(); +} + +class _SeasonBackgroundState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _drift = AnimationController( + vsync: this, + duration: const Duration(seconds: 18), + ); + + @override + void initState() { + super.initState(); + if (!debugDisableLoopingAnimations) _drift.repeat(); + } + + @override + void dispose() { + _drift.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colors = ThemeColors(widget.theme); + return RepaintBoundary( + child: AnimatedBuilder( + animation: _drift, + builder: (context, _) => CustomPaint( + size: Size.infinite, + painter: _AmbiencePainter(colors: colors, t: _drift.value), + ), + ), + ); + } +} + +class _AmbiencePainter extends CustomPainter { + const _AmbiencePainter({required this.colors, required this.t}); + + final ThemeColors colors; + final double t; + + static const _particles = 9; + + // Deterministic pseudo-random in [0, 1) from an index. + static double _hash(int i, double salt) { + final v = math.sin(i * 12.9898 + salt) * 43758.5453; + return v - v.floorToDouble(); + } + + @override + void paint(Canvas canvas, Size size) { + final rect = Offset.zero & size; + canvas.drawRect( + rect, + Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: colors.gradient, + stops: colors.gradient.length == 3 + ? const [0.0, 0.55, 1.0] + : null, + ).createShader(rect), + ); + + if (colors.particleType == 'none') return; + for (var i = 0; i < _particles; i++) { + final speed = 0.5 + _hash(i, 1) * 0.6; + final phase = _hash(i, 2); + final fall = (t * speed + phase) % 1.15 - 0.075; + final x = (_hash(i, 3) + + 0.05 * math.sin(t * 2 * math.pi + i * 1.7)) * + size.width; + final y = fall * size.height; + final scale = 7 + _hash(i, 4) * 9; + final angle = t * 2 * math.pi * (0.4 + _hash(i, 5)) + i; + _paintParticle(canvas, Offset(x, y), scale, angle); + } + } + + void _paintParticle(Canvas canvas, Offset c, double s, double angle) { + canvas.save(); + canvas.translate(c.dx, c.dy); + canvas.rotate(angle); + final paint = Paint(); + switch (colors.particleType) { + case 'snow': + paint.color = Colors.white.withValues(alpha: 0.35); + canvas.drawCircle(Offset.zero, s * 0.4, paint); + case 'leaves': + paint.color = const Color(0xFFE8945A).withValues(alpha: 0.35); + canvas.drawOval( + Rect.fromCenter(center: Offset.zero, width: s, height: s * 0.55), + paint); + default: // petals + paint.color = colors.accent.withValues(alpha: 0.30); + canvas.drawOval( + Rect.fromCenter( + center: Offset(s * 0.18, 0), width: s, height: s * 0.62), + paint); + canvas.drawOval( + Rect.fromCenter( + center: Offset(-s * 0.18, 0), width: s, height: s * 0.62), + paint); + } + canvas.restore(); + } + + @override + bool shouldRepaint(_AmbiencePainter old) => + old.t != t || old.colors != colors; +} diff --git a/test/flutter_test_config.dart b/test/flutter_test_config.dart new file mode 100644 index 0000000..0545302 --- /dev/null +++ b/test/flutter_test_config.dart @@ -0,0 +1,9 @@ +import 'dart:async'; + +import 'package:block_seasons/ui/widgets/season_background.dart'; + +Future testExecutable(FutureOr Function() testMain) async { + // Looping ambience animations never settle under pumpAndSettle. + debugDisableLoopingAnimations = true; + await testMain(); +} diff --git a/test/ui/goldens/game_screen.png b/test/ui/goldens/game_screen.png index f85011c..47ffbef 100644 Binary files a/test/ui/goldens/game_screen.png and b/test/ui/goldens/game_screen.png differ diff --git a/test/ui/season_background_test.dart b/test/ui/season_background_test.dart new file mode 100644 index 0000000..aac2aac --- /dev/null +++ b/test/ui/season_background_test.dart @@ -0,0 +1,25 @@ +import 'package:block_seasons/game/models/season.dart'; +import 'package:block_seasons/ui/widgets/season_background.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('renders and settles with looping animations disabled', + (tester) async { + await tester.pumpWidget(const MaterialApp( + home: SeasonBackground(theme: SeasonTheme.fallback), + )); + await tester.pumpAndSettle(); + expect(find.byType(SeasonBackground), findsOneWidget); + }); + + testWidgets('particleType none still renders', (tester) async { + await tester.pumpWidget(const MaterialApp( + home: SeasonBackground( + theme: SeasonTheme(particleType: 'none'), + ), + )); + await tester.pumpAndSettle(); + expect(find.byType(CustomPaint), findsWidgets); + }); +}