feat: procedural season background with drifting petals
This commit is contained in:
@@ -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<GameScreen> {
|
||||
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(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<SeasonBackground> createState() => _SeasonBackgroundState();
|
||||
}
|
||||
|
||||
class _SeasonBackgroundState extends State<SeasonBackground>
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:block_seasons/ui/widgets/season_background.dart';
|
||||
|
||||
Future<void> testExecutable(FutureOr<void> Function() testMain) async {
|
||||
// Looping ambience animations never settle under pumpAndSettle.
|
||||
debugDisableLoopingAnimations = true;
|
||||
await testMain();
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 41 KiB |
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user