feat: procedural season background with drifting petals

This commit is contained in:
2026-06-11 21:17:12 +09:00
parent d283bf6959
commit 6e4d3b60df
5 changed files with 222 additions and 45 deletions
+54 -45
View File
@@ -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(),
),
),
],
),
),
],
),
);
}
+134
View File
@@ -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;
}